From 7a5f96ede5585fe333d380d99cbc63548f26b3cc Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 22 Aug 2026 13:13:29 +0200 Subject: [PATCH] feat(render): implement Campaign AR and terrain fidelity --- AcDream.slnx | 12 + docs/ISSUES.md | 98 +- .../retail-divergence-register.md | 13 +- docs/plans/2026-04-11-roadmap.md | 2 + .../plans/2026-08-21-atmospheric-rendering.md | 931 +++++++++ docs/release-gate.md | 6 +- docs/render-packs/README.md | 227 +++ .../compatibility-and-failure-v1.md | 134 ++ .../plugin-manifest-v1.schema.json | 68 + docs/render-packs/semantic-bindings-v1.md | 381 ++++ ...il-building-detail-texturing-pseudocode.md | 210 ++ ...6-08-21-terrain-fidelity-track-a-report.md | 75 + ...08-22-atmospheric-stage1-automated-gate.md | 95 + ...2026-08-22-atmospheric-stage1-live-gate.md | 46 + ...6-08-22-dereth-celestial-shadow-sources.md | 200 ++ ...cDream.RenderPacks.AtmosphericTier2.csproj | 22 + .../AtmosphericTier2RenderPack.cs | 591 ++++++ .../Shaders/cinematic-composite.frag.spv | Bin 0 -> 7016 bytes .../Shaders/cinematic-composite.vert.spv | Bin 0 -> 956 bytes .../Shaders/cutout-shadow.frag.spv | Bin 0 -> 924 bytes .../Shaders/cutout-shadow.vert.spv | Bin 0 -> 2668 bytes .../Shaders/glow-filter.frag.spv | Bin 0 -> 2496 bytes .../Shaders/glow-filter.vert.spv | Bin 0 -> 956 bytes .../Shaders/highlight-extract.frag.spv | Bin 0 -> 2720 bytes .../Shaders/highlight-extract.vert.spv | Bin 0 -> 956 bytes .../Shaders/landscape-lit.frag.spv | Bin 0 -> 21944 bytes .../Shaders/landscape-lit.vert.spv | Bin 0 -> 10168 bytes .../Shaders/landscape-shadow.frag.spv | Bin 0 -> 152 bytes .../Shaders/landscape-shadow.vert.spv | Bin 0 -> 1436 bytes .../Shaders/lit-air.frag.spv | Bin 0 -> 6276 bytes .../Shaders/lit-air.vert.spv | Bin 0 -> 956 bytes .../Shaders/object-lit.frag.spv | Bin 0 -> 17360 bytes .../Shaders/object-lit.vert.spv | Bin 0 -> 10564 bytes .../Shaders/shadow-pass.frag.spv | Bin 0 -> 152 bytes .../Shaders/shadow-pass.vert.spv | Bin 0 -> 1820 bytes .../Shaders/solar-visibility.frag.spv | Bin 0 -> 1744 bytes .../Shaders/solar-visibility.vert.spv | Bin 0 -> 956 bytes .../Shaders/solid-shadow.frag.spv | Bin 0 -> 152 bytes .../Shaders/solid-shadow.vert.spv | Bin 0 -> 1820 bytes .../Shaders/sun-scatter.frag.spv | Bin 0 -> 5296 bytes .../Shaders/sun-scatter.vert.spv | Bin 0 -> 956 bytes .../packages.neutral.lock.json | 10 + .../plugin.json | 10 + .../AcDream.RenderPacks.NoOp.csproj | 19 + .../NoOpRenderPack.cs | 54 + .../packages.neutral.lock.json | 10 + samples/AcDream.RenderPacks.NoOp/plugin.json | 10 + ...cDream.RenderPacks.ShadowsOnlyTier2.csproj | 22 + .../ShadowsOnlyTier2RenderPack.cs | 231 +++ .../packages.neutral.lock.json | 10 + .../plugin.json | 10 + .../Composition/FrameRootComposition.cs | 178 +- .../Composition/HostInputCameraComposition.cs | 15 +- .../InteractionRetainedUiComposition.cs | 56 +- .../InteractionUiRuntimeSources.cs | 61 + .../LivePresentationComposition.cs | 20 +- .../SettingsDevToolsComposition.cs | 34 +- ...VulkanHostInputCameraCompositionFactory.cs | 19 +- .../Composition/WorldRenderComposition.cs | 16 +- .../Diagnostics/FrameScreenshotController.cs | 54 +- .../WorldLifecycleAutomationController.cs | 151 +- .../Plugins/BufferedRenderPackRegistry.cs | 171 ++ .../Plugins/GraphicalPluginSession.cs | 10 +- src/AcDream.App/Program.cs | 15 +- .../Rendering/CompositeTextureArrayCache.cs | 10 +- .../DirectionalShadowCascadeFitter.cs | 286 +++ .../Rendering/DirectionalShadowQuality.cs | 362 ++++ .../Rendering/DirectionalShadowReceiver.cs | 154 ++ .../DirectionalShadowTransformBufferSet.cs | 439 +++++ .../Rendering/DirectionalShadowUniforms.cs | 111 ++ .../Rendering/DirectionalSunShadowRenderer.cs | 951 +++++++++ src/AcDream.App/Rendering/GameWindow.cs | 104 +- .../Rendering/Gpu/GpuBindingModel.cs | 58 +- .../Rendering/Gpu/GpuCapabilityRecord.cs | 42 + src/AcDream.App/Rendering/Gpu/GpuEnums.cs | 20 +- .../Rendering/Gpu/GpuPassDescription.cs | 61 +- .../Rendering/Gpu/GpuPipelineDescription.cs | 61 +- .../Rendering/Gpu/GpuPushConstants.cs | 4 +- .../Rendering/Gpu/GpuResourceDescriptions.cs | 39 +- src/AcDream.App/Rendering/Gpu/GpuResources.cs | 41 +- src/AcDream.App/Rendering/Gpu/IGpuDevice.cs | 8 + src/AcDream.App/Rendering/Gpu/IGpuFrame.cs | 8 + .../Gpu/IGpuPipelineFormatVariantHost.cs | 13 + .../Gpu/Vk/VulkanCapabilityRecord.cs | 99 +- .../Gpu/Vk/VulkanCompositionFramePhases.cs | 293 ++- .../Gpu/Vk/VulkanDeviceMemoryAllocator.cs | 154 +- .../Gpu/Vk/VulkanDirectionalDepthTarget.cs | 157 ++ .../Gpu/Vk/VulkanDrawBindingState.cs | 34 + .../Rendering/Gpu/Vk/VulkanFrameBindings.cs | 156 +- .../Gpu/Vk/VulkanFrameFlightController.cs | 164 +- .../Rendering/Gpu/Vk/VulkanGpuBuffer.cs | 2 + .../Gpu/Vk/VulkanGpuDevice.Resources.cs | 675 ++++++- .../Rendering/Gpu/Vk/VulkanGpuDevice.cs | 19 +- .../Rendering/Gpu/Vk/VulkanGpuFrame.cs | 3 + .../Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs | 84 +- .../Rendering/Gpu/Vk/VulkanGpuPipeline.cs | 121 +- .../Rendering/Gpu/Vk/VulkanGpuRenderTarget.cs | 97 +- .../Rendering/Gpu/Vk/VulkanGpuTexture.cs | 33 +- .../Rendering/Gpu/Vk/VulkanGpuTimerPool.cs | 2 +- .../Rendering/Gpu/Vk/VulkanGraphicsContext.cs | 11 +- .../Gpu/Vk/VulkanHostStorageVisibility.cs | 30 + .../Rendering/Gpu/Vk/VulkanInterop.cs | 59 +- .../Rendering/Gpu/Vk/VulkanPipelineLayouts.cs | 322 ++- .../Gpu/Vk/VulkanRenderFailurePolicy.cs | 43 + .../Gpu/Vk/VulkanTextureFormatMapping.cs | 19 +- .../Rendering/Gpu/Vk/VulkanTextureTable.cs | 82 +- .../Rendering/Gpu/Vk/VulkanViewportMapping.cs | 3 + .../Rendering/Gpu/Vk/VulkanWorldPassScope.cs | 22 +- .../Packs/AtmosphericAutoQualityController.cs | 228 +++ .../Packs/AtmosphericCpuStageProfiler.cs | 153 ++ .../Rendering/Packs/AtmosphericFrameInputs.cs | 229 +++ .../Packs/AtmosphericGpuTimerSampling.cs | 24 + .../Packs/AtmosphericPostProcessGraph.cs | 1731 +++++++++++++++++ .../Packs/AuthoredCelestialShadowSource.cs | 183 ++ .../Packs/BuiltInAtmosphericRenderPack.cs | 519 +++++ .../DeclaredFullscreenRenderPackGraph.cs | 965 +++++++++ .../RenderPackAtmospherePolicyEvaluation.cs | 87 + .../Packs/RenderPackCapabilityResolver.cs | 66 + .../Packs/RenderPackCatalogSource.cs | 37 + .../Rendering/Packs/RenderPackController.cs | 1404 +++++++++++++ .../Rendering/Packs/RenderPackDiagnostics.cs | 284 +++ .../Packs/RenderPackPerformanceWindow.cs | 166 ++ .../Packs/RenderPackPreparationScheduler.cs | 49 + .../RenderPackReceiverPipelineCoordinator.cs | 124 ++ .../Packs/RenderPackResourceBudgetPlanner.cs | 270 +++ .../Packs/RenderPackSelectionBinding.cs | 85 + .../Packs/RenderPackSettingResolution.cs | 77 + .../Rendering/Packs/RenderPackShaderAssets.cs | 71 + .../Packs/RenderPackTextureBindingResolver.cs | 54 + .../Rendering/Packs/RenderPackValidation.cs | 1604 +++++++++++++++ .../Packs/VolumetricShaftRenderer.cs | 471 +++++ .../RenderFrameDiagnosticsController.cs | 11 +- .../Rendering/RetailDetailTextureContract.cs | 47 + .../Rendering/Scene/Arch/ArchRenderScene.cs | 493 ++++- .../Scene/CurrentRenderSceneOracle.cs | 16 + .../Scene/DirectionalShadowCasterFrame.cs | 542 ++++++ .../Scene/LiveRenderProjectionJournal.cs | 41 +- .../Scene/RenderProjectionRecordFactory.cs | 11 +- .../Rendering/Scene/RenderSceneContracts.cs | 118 +- .../Scene/RenderSceneShadowRuntime.cs | 7 +- .../Scene/StaticRenderProjectionJournal.cs | 14 +- .../Selection/RetailSelectionScene.cs | 6 +- .../Shaders/atmospheric_bloom_blur.frag | 17 + .../Shaders/atmospheric_bloom_blur.vert | 10 + .../Shaders/atmospheric_bloom_downsample.frag | 23 + .../Shaders/atmospheric_bloom_downsample.vert | 10 + .../Rendering/Shaders/atmospheric_common.glsl | 34 + .../Rendering/Shaders/atmospheric_filmic.frag | 88 + .../Rendering/Shaders/atmospheric_filmic.vert | 10 + .../Shaders/atmospheric_sun_occlusion.frag | 14 + .../Shaders/atmospheric_sun_occlusion.vert | 12 + .../Shaders/atmospheric_sun_rays.frag | 58 + .../Shaders/atmospheric_sun_rays.vert | 10 + .../Shaders/atmospheric_volumetric.frag | 67 + .../Shaders/atmospheric_volumetric.vert | 10 + .../Shaders/directional_shadow_common.glsl | 15 + .../Shaders/directional_shadow_receiver.glsl | 176 ++ .../Shaders/directional_shadow_terrain.frag | 4 + .../Shaders/directional_shadow_terrain.vert | 16 + .../directional_shadow_terrain_multiview.frag | 4 + .../directional_shadow_terrain_multiview.vert | 15 + .../directional_shadow_world_cutout.frag | 14 + .../directional_shadow_world_cutout.vert | 41 + ...ctional_shadow_world_cutout_multiview.frag | 14 + ...ctional_shadow_world_cutout_multiview.vert | 38 + .../directional_shadow_world_opaque.frag | 4 + .../directional_shadow_world_opaque.vert | 21 + ...ctional_shadow_world_opaque_multiview.frag | 4 + ...ctional_shadow_world_opaque_multiview.vert | 20 + .../Rendering/Shaders/mesh_atmospheric.frag | 130 ++ .../Rendering/Shaders/mesh_atmospheric.vert | 363 ++++ .../Rendering/Shaders/mesh_detail.frag | 42 + .../Rendering/Shaders/mesh_detail.vert | 94 + .../spv/atmospheric_bloom_blur.frag.spv | Bin 0 -> 2496 bytes .../spv/atmospheric_bloom_blur.vert.spv | Bin 0 -> 956 bytes .../spv/atmospheric_bloom_downsample.frag.spv | Bin 0 -> 2720 bytes .../spv/atmospheric_bloom_downsample.vert.spv | Bin 0 -> 956 bytes .../Shaders/spv/atmospheric_filmic.frag.spv | Bin 0 -> 7016 bytes .../Shaders/spv/atmospheric_filmic.vert.spv | Bin 0 -> 956 bytes .../spv/atmospheric_sun_occlusion.frag.spv | Bin 0 -> 1744 bytes .../spv/atmospheric_sun_occlusion.vert.spv | Bin 0 -> 956 bytes .../Shaders/spv/atmospheric_sun_rays.frag.spv | Bin 0 -> 5296 bytes .../Shaders/spv/atmospheric_sun_rays.vert.spv | Bin 0 -> 956 bytes .../spv/atmospheric_volumetric.frag.spv | Bin 0 -> 6276 bytes .../spv/atmospheric_volumetric.vert.spv | Bin 0 -> 956 bytes .../spv/directional_shadow_terrain.frag.spv | Bin 0 -> 152 bytes .../spv/directional_shadow_terrain.vert.spv | Bin 0 -> 1436 bytes ...ectional_shadow_terrain_multiview.frag.spv | Bin 0 -> 152 bytes ...ectional_shadow_terrain_multiview.vert.spv | Bin 0 -> 1136 bytes .../directional_shadow_world_cutout.frag.spv | Bin 0 -> 924 bytes .../directional_shadow_world_cutout.vert.spv | Bin 0 -> 2668 bytes ...nal_shadow_world_cutout_multiview.frag.spv | Bin 0 -> 924 bytes ...nal_shadow_world_cutout_multiview.vert.spv | Bin 0 -> 2676 bytes .../directional_shadow_world_opaque.frag.spv | Bin 0 -> 152 bytes .../directional_shadow_world_opaque.vert.spv | Bin 0 -> 1820 bytes ...nal_shadow_world_opaque_multiview.frag.spv | Bin 0 -> 152 bytes ...nal_shadow_world_opaque_multiview.vert.spv | Bin 0 -> 1504 bytes .../Shaders/spv/mesh_atmospheric.frag.spv | Bin 0 -> 17360 bytes .../Shaders/spv/mesh_atmospheric.vert.spv | Bin 0 -> 10564 bytes .../Shaders/spv/mesh_detail.frag.spv | Bin 0 -> 2332 bytes .../Shaders/spv/mesh_detail.vert.spv | Bin 0 -> 4404 bytes .../Shaders/spv/shaders.manifest.json | 240 +++ .../Shaders/spv/terrain_atmospheric.frag.spv | Bin 0 -> 21944 bytes .../Shaders/spv/terrain_atmospheric.vert.spv | Bin 0 -> 10168 bytes .../Shaders/terrain_atmospheric.frag | 208 ++ .../Shaders/terrain_atmospheric.vert | 197 ++ src/AcDream.App/Rendering/TerrainAtlas.cs | 194 +- ...dernRenderer.DirectionalShadowReceivers.cs | 62 + ...errainModernRenderer.DirectionalShadows.cs | 146 ++ .../Rendering/TerrainModernRenderer.Rhi.cs | 28 +- .../Rendering/TerrainModernRenderer.cs | 4 + .../Rendering/VolumetricShaftQuality.cs | 116 ++ .../Rendering/Wb/EnvCellRenderer.Rhi.cs | 149 +- .../Rendering/Wb/EnvCellRenderer.cs | 5 + ...awDispatcher.DirectionalShadowReceivers.cs | 130 ++ .../Wb/WbDrawDispatcher.DirectionalShadows.cs | 1186 +++++++++++ .../Wb/WbDrawDispatcher.PackedOracle.cs | 42 +- .../Rendering/Wb/WbDrawDispatcher.Rhi.cs | 556 +++++- .../Rendering/Wb/WbDrawDispatcher.cs | 126 +- .../Rendering/Wb/WorldTransformFrameArena.cs | 264 +++ .../Rendering/WorldRenderFrameBuilder.cs | 16 + .../Rendering/WorldSceneRenderer.cs | 133 +- .../Rendering/WorldSceneRuntimeSources.cs | 11 + src/AcDream.App/RuntimeOptions.cs | 43 + .../Settings/RuntimeSettingsController.cs | 55 +- .../Settings/RuntimeSettingsTargets.cs | 37 +- src/AcDream.App/Streaming/GpuWorldState.cs | 76 + .../Streaming/ResidentStreamingWindowFact.cs | 31 + .../UI/Layout/ConfigOptionsPageController.cs | 812 +++++++- src/AcDream.App/UI/Layout/OptionPageModel.cs | 36 +- src/AcDream.App/UI/RetailUiRuntime.cs | 19 +- .../Testing/RetailUiAutomationScriptRunner.cs | 237 ++- src/AcDream.App/UI/UiMenu.cs | 28 +- src/AcDream.App/UI/UiTemplateListBox.cs | 23 + .../World/WorldEnvironmentController.cs | 7 +- src/AcDream.Core/Plugins/LoadedPlugin.cs | 15 +- src/AcDream.Core/Plugins/PluginLoader.cs | 161 +- src/AcDream.Core/Plugins/PluginManifest.cs | 63 +- src/AcDream.Core/Plugins/PluginSession.cs | 146 +- .../Plugins/ScopedRenderPackRegistry.cs | 96 + .../Rendering/TranslucencyFadeManager.cs | 35 +- src/AcDream.Core/Terrain/LandblockMesh.cs | 105 +- src/AcDream.Core/Terrain/TerrainVertex.cs | 10 +- src/AcDream.Core/World/SkyDescLoader.cs | 59 +- .../Plugins/HeadlessPluginSession.cs | 4 +- src/AcDream.Platform/ApplicationPathSet.cs | 13 + .../Rendering/RenderPackContracts.cs | 50 + .../Rendering/RenderPackDeclarations.cs | 432 ++++ .../Rendering/RenderPackSettingValueCodec.cs | 113 ++ .../Rendering/RenderPackShaderAbi.cs | 28 + .../Rendering/RenderPackSpirvValidator.cs | 611 ++++++ src/AcDream.Plugins.MossTank/MossTankPanel.cs | 125 +- .../packages.linux-x64.lock.json | 11 + .../packages.win-x64.lock.json | 11 + .../Panels/Settings/DisplaySettings.cs | 121 ++ .../Panels/Settings/SettingsStore.cs | 64 +- .../AcDream.App.Tests.csproj | 8 + .../HostInputCameraCompositionTests.cs | 52 +- .../InteractionUiRuntimeSourcesTests.cs | 57 + .../WorldRenderCompositionTests.cs | 3 +- ...mosphericPerformanceMatrixContractTests.cs | 567 ++++++ ...AtmosphericPreviewLauncherContractTests.cs | 121 ++ .../ConnectedRenderPackGateContractTests.cs | 505 +++++ .../ConnectedWorldSoakRouteContractTests.cs | 132 +- ...WorldLifecycleAutomationControllerTests.cs | 182 +- ...ExternalRenderPackPackageLifecycleTests.cs | 673 +++++++ .../Rendering/ArchRenderSceneTests.cs | 424 ++++ .../DirectionalShadowCascadeFitterTests.cs | 222 +++ .../DirectionalShadowCasterFrameTests.cs | 896 +++++++++ .../DirectionalShadowEnvironmentGateTests.cs | 214 ++ .../Rendering/DirectionalShadowGpuTests.cs | 795 ++++++++ .../DirectionalShadowQualityTests.cs | 58 + .../DirectionalShadowReceiverTests.cs | 137 ++ ...irectionalShadowTransformBufferSetTests.cs | 391 ++++ .../GameWindowStartupOptionsTests.cs | 57 + .../Rendering/Gpu/GpuContractTests.cs | 41 +- .../Rendering/Gpu/RecordingGpuDevice.cs | 350 +++- .../Rendering/Gpu/RecordingGpuDeviceTests.cs | 106 + .../Gpu/Vk/VulkanCapabilityGateTests.cs | 85 +- ...VulkanDirectionalMultiviewContractTests.cs | 27 + .../Gpu/Vk/VulkanDrawBindingStateTests.cs | 52 + .../VulkanGraphicsContextAcquisitionTests.cs | 61 + .../Vk/VulkanHostStorageVisibilityTests.cs | 32 + .../Gpu/Vk/VulkanRenderFailurePolicyTests.cs | 37 + .../Vk/VulkanShaderDescriptorContractTests.cs | 38 +- .../Gpu/Vk/VulkanShaderManifestTests.cs | 81 +- .../Gpu/Vk/VulkanViewportMappingTests.cs | 3 + .../Gpu/Vk/VulkanWorldPassScopeTests.cs | 60 + .../LiveRenderProjectionJournalTests.cs | 50 +- .../AtmosphericAutoQualityControllerTests.cs | 114 ++ .../Packs/AtmosphericCpuStageProfilerTests.cs | 94 + .../Packs/AtmosphericGpuTimerSamplingTests.cs | 63 + .../Packs/AtmosphericPostProcessGraphTests.cs | 1525 +++++++++++++++ .../Packs/AtmosphericShaderAbiTests.cs | 163 ++ ...horedCelestialShadowSourceResolverTests.cs | 321 +++ ...oOpRenderPackProductionIntegrationTests.cs | 353 ++++ .../Packs/PackSettingsUniformsTests.cs | 117 ++ .../Packs/RenderPackAutoRuntimeTests.cs | 650 +++++++ .../RenderPackCapabilityResolverTests.cs | 167 ++ .../Packs/RenderPackControllerTests.cs | 1171 +++++++++++ .../RenderPackLongCycleConvergenceTests.cs | 636 ++++++ .../Packs/RenderPackPerformanceWindowTests.cs | 78 + .../RenderPackResourceBudgetPlannerTests.cs | 99 + .../RenderPackRuntimeFailureRecoveryTests.cs | 341 ++++ .../Packs/RenderPackSpirvValidatorTests.cs | 448 +++++ .../Packs/VolumetricShaftRendererTests.cs | 369 ++++ .../RetailDetailTextureContractTests.cs | 90 + .../StaticRenderProjectionJournalTests.cs | 25 +- .../Rendering/VolumetricShaftQualityTests.cs | 106 + .../Wb/DirectionalShadowPreparedDrawTests.cs | 501 +++++ ...rectionalShadowTerrainPreparedDrawTests.cs | 75 + .../Rendering/Wb/EnvCellRendererTests.cs | 56 + .../Rendering/Wb/InstanceGroupClearTests.cs | 46 +- .../Wb/PackedDispatcherOracleTests.cs | 33 + .../Wb/WorldTransformFrameArenaTests.cs | 225 +++ .../Rendering/WorldSceneRendererTests.cs | 126 +- .../AcDream.App.Tests/RuntimeOptionsTests.cs | 76 + .../RuntimeSettingsControllerTests.cs | 91 +- .../ResidentStreamingWindowFactTests.cs | 142 ++ .../ConfigOptionsPageControllerTests.cs | 382 +++- .../UI/Layout/OptionPageModelTests.cs | 43 + .../UI/RetailUiAutomationProbeTests.cs | 248 +++ .../UI/UiTemplateListBoxViewportTests.cs | 23 + .../HelloPlugin.cs | 38 +- .../Plugins/PluginLoaderTests.cs | 62 +- .../Plugins/PluginManifestTests.cs | 46 + .../Plugins/PluginSessionTests.cs | 166 +- .../Rendering/TranslucencyFadeManagerTests.cs | 22 + .../WbDrawDispatcherIndirectBuilderTests.cs | 106 + .../Terrain/LandblockMeshTests.cs | 144 ++ .../World/SkyDescLoaderTests.cs | 78 + .../HeadlessPluginSessionTests.cs | 39 + .../Program.cs | 44 +- .../Updates/LauncherSelfUpdateProcessTests.cs | 35 +- .../ApplicationPathSetTests.cs | 54 + .../HostPlugin.cs | 60 +- ....Fixtures.InvalidRenderPackInternal.csproj | 14 + .../InternalRenderPackPlugin.cs | 8 + .../packages.neutral.lock.json | 10 + ....Fixtures.InvalidRenderPackMultiple.csproj | 14 + .../MultipleRenderPackPlugins.cs | 13 + .../packages.neutral.lock.json | 10 + .../MossTankPanelTests.cs | 251 +++ .../AcDream.RenderPackValidator.Tests.csproj | 31 + .../RenderPackValidatorCommandTests.cs | 1230 ++++++++++++ .../packages.neutral.lock.json | 107 + .../Panels/Settings/DisplaySettingsTests.cs | 42 + .../Panels/Settings/SettingsStoreTests.cs | 39 +- .../AcDream.Tools.RenderPackValidator.csproj | 18 + tools/RenderPackValidator/PackManifest.cs | 141 ++ tools/RenderPackValidator/Program.cs | 7 + .../RenderPackSdkValidator.cs | 1500 ++++++++++++++ .../RenderPackValidatorCommand.cs | 235 +++ .../packages.neutral.lock.json | 10 + tools/ShaderCompiler/GlslIncludeExpander.cs | 51 + tools/ShaderCompiler/Program.cs | 53 +- tools/ShaderCompiler/VulkanGlslPreamble.cs | 31 +- .../ShaderCompiler/packages.win-x64.lock.json | 48 + .../atmospheric-performance-matrix-common.ps1 | 325 ++++ tools/compile-shaders.ps1 | 13 +- ...-atmospheric-exposure-comparison.route.txt | 51 + tools/connected-render-pack-gate-common.ps1 | 341 ++++ ...onnected-render-pack-transitions.route.txt | 59 + tools/launch-atmospheric-preview.ps1 | 196 ++ tools/run-atmospheric-performance-matrix.ps1 | 853 ++++++++ tools/run-connected-r6-soak.ps1 | 62 +- tools/run-connected-world-lifecycle-gate.ps1 | 331 +++- tools/run-offline-pixel-gate.ps1 | 251 ++- 368 files changed, 50611 insertions(+), 950 deletions(-) create mode 100644 docs/plans/2026-08-21-atmospheric-rendering.md create mode 100644 docs/render-packs/README.md create mode 100644 docs/render-packs/compatibility-and-failure-v1.md create mode 100644 docs/render-packs/plugin-manifest-v1.schema.json create mode 100644 docs/render-packs/semantic-bindings-v1.md create mode 100644 docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md create mode 100644 docs/research/2026-08-21-terrain-fidelity-track-a-report.md create mode 100644 docs/research/2026-08-22-atmospheric-stage1-automated-gate.md create mode 100644 docs/research/2026-08-22-atmospheric-stage1-live-gate.md create mode 100644 docs/research/2026-08-22-dereth-celestial-shadow-sources.md create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/AcDream.RenderPacks.AtmosphericTier2.csproj create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/AtmosphericTier2RenderPack.cs create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cinematic-composite.frag.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cinematic-composite.vert.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cutout-shadow.frag.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cutout-shadow.vert.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/glow-filter.frag.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/glow-filter.vert.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/highlight-extract.frag.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/highlight-extract.vert.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/landscape-lit.frag.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/landscape-lit.vert.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/landscape-shadow.frag.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/landscape-shadow.vert.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/lit-air.frag.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/lit-air.vert.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/object-lit.frag.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/object-lit.vert.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/shadow-pass.frag.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/shadow-pass.vert.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/solar-visibility.frag.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/solar-visibility.vert.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/solid-shadow.frag.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/solid-shadow.vert.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/sun-scatter.frag.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/sun-scatter.vert.spv create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/packages.neutral.lock.json create mode 100644 samples/AcDream.RenderPacks.AtmosphericTier2/plugin.json create mode 100644 samples/AcDream.RenderPacks.NoOp/AcDream.RenderPacks.NoOp.csproj create mode 100644 samples/AcDream.RenderPacks.NoOp/NoOpRenderPack.cs create mode 100644 samples/AcDream.RenderPacks.NoOp/packages.neutral.lock.json create mode 100644 samples/AcDream.RenderPacks.NoOp/plugin.json create mode 100644 samples/AcDream.RenderPacks.ShadowsOnlyTier2/AcDream.RenderPacks.ShadowsOnlyTier2.csproj create mode 100644 samples/AcDream.RenderPacks.ShadowsOnlyTier2/ShadowsOnlyTier2RenderPack.cs create mode 100644 samples/AcDream.RenderPacks.ShadowsOnlyTier2/packages.neutral.lock.json create mode 100644 samples/AcDream.RenderPacks.ShadowsOnlyTier2/plugin.json create mode 100644 src/AcDream.App/Plugins/BufferedRenderPackRegistry.cs create mode 100644 src/AcDream.App/Rendering/DirectionalShadowCascadeFitter.cs create mode 100644 src/AcDream.App/Rendering/DirectionalShadowQuality.cs create mode 100644 src/AcDream.App/Rendering/DirectionalShadowReceiver.cs create mode 100644 src/AcDream.App/Rendering/DirectionalShadowTransformBufferSet.cs create mode 100644 src/AcDream.App/Rendering/DirectionalShadowUniforms.cs create mode 100644 src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs create mode 100644 src/AcDream.App/Rendering/Gpu/IGpuPipelineFormatVariantHost.cs create mode 100644 src/AcDream.App/Rendering/Gpu/Vk/VulkanDirectionalDepthTarget.cs create mode 100644 src/AcDream.App/Rendering/Gpu/Vk/VulkanDrawBindingState.cs create mode 100644 src/AcDream.App/Rendering/Gpu/Vk/VulkanHostStorageVisibility.cs create mode 100644 src/AcDream.App/Rendering/Gpu/Vk/VulkanRenderFailurePolicy.cs create mode 100644 src/AcDream.App/Rendering/Packs/AtmosphericAutoQualityController.cs create mode 100644 src/AcDream.App/Rendering/Packs/AtmosphericCpuStageProfiler.cs create mode 100644 src/AcDream.App/Rendering/Packs/AtmosphericFrameInputs.cs create mode 100644 src/AcDream.App/Rendering/Packs/AtmosphericGpuTimerSampling.cs create mode 100644 src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs create mode 100644 src/AcDream.App/Rendering/Packs/AuthoredCelestialShadowSource.cs create mode 100644 src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs create mode 100644 src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs create mode 100644 src/AcDream.App/Rendering/Packs/RenderPackAtmospherePolicyEvaluation.cs create mode 100644 src/AcDream.App/Rendering/Packs/RenderPackCapabilityResolver.cs create mode 100644 src/AcDream.App/Rendering/Packs/RenderPackCatalogSource.cs create mode 100644 src/AcDream.App/Rendering/Packs/RenderPackController.cs create mode 100644 src/AcDream.App/Rendering/Packs/RenderPackDiagnostics.cs create mode 100644 src/AcDream.App/Rendering/Packs/RenderPackPerformanceWindow.cs create mode 100644 src/AcDream.App/Rendering/Packs/RenderPackPreparationScheduler.cs create mode 100644 src/AcDream.App/Rendering/Packs/RenderPackReceiverPipelineCoordinator.cs create mode 100644 src/AcDream.App/Rendering/Packs/RenderPackResourceBudgetPlanner.cs create mode 100644 src/AcDream.App/Rendering/Packs/RenderPackSelectionBinding.cs create mode 100644 src/AcDream.App/Rendering/Packs/RenderPackSettingResolution.cs create mode 100644 src/AcDream.App/Rendering/Packs/RenderPackShaderAssets.cs create mode 100644 src/AcDream.App/Rendering/Packs/RenderPackTextureBindingResolver.cs create mode 100644 src/AcDream.App/Rendering/Packs/RenderPackValidation.cs create mode 100644 src/AcDream.App/Rendering/Packs/VolumetricShaftRenderer.cs create mode 100644 src/AcDream.App/Rendering/RetailDetailTextureContract.cs create mode 100644 src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs create mode 100644 src/AcDream.App/Rendering/Shaders/atmospheric_bloom_blur.frag create mode 100644 src/AcDream.App/Rendering/Shaders/atmospheric_bloom_blur.vert create mode 100644 src/AcDream.App/Rendering/Shaders/atmospheric_bloom_downsample.frag create mode 100644 src/AcDream.App/Rendering/Shaders/atmospheric_bloom_downsample.vert create mode 100644 src/AcDream.App/Rendering/Shaders/atmospheric_common.glsl create mode 100644 src/AcDream.App/Rendering/Shaders/atmospheric_filmic.frag create mode 100644 src/AcDream.App/Rendering/Shaders/atmospheric_filmic.vert create mode 100644 src/AcDream.App/Rendering/Shaders/atmospheric_sun_occlusion.frag create mode 100644 src/AcDream.App/Rendering/Shaders/atmospheric_sun_occlusion.vert create mode 100644 src/AcDream.App/Rendering/Shaders/atmospheric_sun_rays.frag create mode 100644 src/AcDream.App/Rendering/Shaders/atmospheric_sun_rays.vert create mode 100644 src/AcDream.App/Rendering/Shaders/atmospheric_volumetric.frag create mode 100644 src/AcDream.App/Rendering/Shaders/atmospheric_volumetric.vert create mode 100644 src/AcDream.App/Rendering/Shaders/directional_shadow_common.glsl create mode 100644 src/AcDream.App/Rendering/Shaders/directional_shadow_receiver.glsl create mode 100644 src/AcDream.App/Rendering/Shaders/directional_shadow_terrain.frag create mode 100644 src/AcDream.App/Rendering/Shaders/directional_shadow_terrain.vert create mode 100644 src/AcDream.App/Rendering/Shaders/directional_shadow_terrain_multiview.frag create mode 100644 src/AcDream.App/Rendering/Shaders/directional_shadow_terrain_multiview.vert create mode 100644 src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout.frag create mode 100644 src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout.vert create mode 100644 src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout_multiview.frag create mode 100644 src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout_multiview.vert create mode 100644 src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque.frag create mode 100644 src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque.vert create mode 100644 src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque_multiview.frag create mode 100644 src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque_multiview.vert create mode 100644 src/AcDream.App/Rendering/Shaders/mesh_atmospheric.frag create mode 100644 src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert create mode 100644 src/AcDream.App/Rendering/Shaders/mesh_detail.frag create mode 100644 src/AcDream.App/Rendering/Shaders/mesh_detail.vert create mode 100644 src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_blur.frag.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_blur.vert.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_downsample.frag.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_downsample.vert.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/atmospheric_filmic.frag.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/atmospheric_filmic.vert.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/atmospheric_sun_occlusion.frag.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/atmospheric_sun_occlusion.vert.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/atmospheric_sun_rays.frag.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/atmospheric_sun_rays.vert.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/atmospheric_volumetric.frag.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/atmospheric_volumetric.vert.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain.frag.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain.vert.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain_multiview.frag.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain_multiview.vert.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout.frag.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout.vert.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout_multiview.frag.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout_multiview.vert.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque.frag.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque.vert.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque_multiview.frag.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque_multiview.vert.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.frag.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/mesh_detail.frag.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/mesh_detail.vert.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/terrain_atmospheric.frag.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/terrain_atmospheric.vert.spv create mode 100644 src/AcDream.App/Rendering/Shaders/terrain_atmospheric.frag create mode 100644 src/AcDream.App/Rendering/Shaders/terrain_atmospheric.vert create mode 100644 src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadowReceivers.cs create mode 100644 src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadows.cs create mode 100644 src/AcDream.App/Rendering/VolumetricShaftQuality.cs create mode 100644 src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadowReceivers.cs create mode 100644 src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs create mode 100644 src/AcDream.App/Rendering/Wb/WorldTransformFrameArena.cs create mode 100644 src/AcDream.App/Streaming/ResidentStreamingWindowFact.cs create mode 100644 src/AcDream.Core/Plugins/ScopedRenderPackRegistry.cs create mode 100644 src/AcDream.Plugin.Abstractions/Rendering/RenderPackContracts.cs create mode 100644 src/AcDream.Plugin.Abstractions/Rendering/RenderPackDeclarations.cs create mode 100644 src/AcDream.Plugin.Abstractions/Rendering/RenderPackSettingValueCodec.cs create mode 100644 src/AcDream.Plugin.Abstractions/Rendering/RenderPackShaderAbi.cs create mode 100644 src/AcDream.Plugin.Abstractions/Rendering/RenderPackSpirvValidator.cs create mode 100644 src/AcDream.Plugins.MossTank/packages.linux-x64.lock.json create mode 100644 src/AcDream.Plugins.MossTank/packages.win-x64.lock.json create mode 100644 tests/AcDream.App.Tests/Diagnostics/AtmosphericPerformanceMatrixContractTests.cs create mode 100644 tests/AcDream.App.Tests/Diagnostics/AtmosphericPreviewLauncherContractTests.cs create mode 100644 tests/AcDream.App.Tests/Diagnostics/ConnectedRenderPackGateContractTests.cs create mode 100644 tests/AcDream.App.Tests/Plugins/ExternalRenderPackPackageLifecycleTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/DirectionalShadowCascadeFitterTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/DirectionalShadowCasterFrameTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/DirectionalShadowEnvironmentGateTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/DirectionalShadowQualityTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/DirectionalShadowReceiverTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/DirectionalShadowTransformBufferSetTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/GameWindowStartupOptionsTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanDirectionalMultiviewContractTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanDrawBindingStateTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanGraphicsContextAcquisitionTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanHostStorageVisibilityTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanRenderFailurePolicyTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanWorldPassScopeTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Packs/AtmosphericAutoQualityControllerTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Packs/AtmosphericCpuStageProfilerTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Packs/AtmosphericGpuTimerSamplingTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Packs/AtmosphericShaderAbiTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Packs/AuthoredCelestialShadowSourceResolverTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Packs/NoOpRenderPackProductionIntegrationTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Packs/PackSettingsUniformsTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Packs/RenderPackAutoRuntimeTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Packs/RenderPackCapabilityResolverTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Packs/RenderPackControllerTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Packs/RenderPackLongCycleConvergenceTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Packs/RenderPackPerformanceWindowTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Packs/RenderPackResourceBudgetPlannerTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Packs/RenderPackRuntimeFailureRecoveryTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Packs/RenderPackSpirvValidatorTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Packs/VolumetricShaftRendererTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/RetailDetailTextureContractTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/VolumetricShaftQualityTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowPreparedDrawTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowTerrainPreparedDrawTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/WorldTransformFrameArenaTests.cs create mode 100644 tests/AcDream.App.Tests/Streaming/ResidentStreamingWindowFactTests.cs create mode 100644 tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal.csproj create mode 100644 tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal/InternalRenderPackPlugin.cs create mode 100644 tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal/packages.neutral.lock.json create mode 100644 tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple.csproj create mode 100644 tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple/MultipleRenderPackPlugins.cs create mode 100644 tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple/packages.neutral.lock.json create mode 100644 tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs create mode 100644 tests/AcDream.RenderPackValidator.Tests/AcDream.RenderPackValidator.Tests.csproj create mode 100644 tests/AcDream.RenderPackValidator.Tests/RenderPackValidatorCommandTests.cs create mode 100644 tests/AcDream.RenderPackValidator.Tests/packages.neutral.lock.json create mode 100644 tools/RenderPackValidator/AcDream.Tools.RenderPackValidator.csproj create mode 100644 tools/RenderPackValidator/PackManifest.cs create mode 100644 tools/RenderPackValidator/Program.cs create mode 100644 tools/RenderPackValidator/RenderPackSdkValidator.cs create mode 100644 tools/RenderPackValidator/RenderPackValidatorCommand.cs create mode 100644 tools/RenderPackValidator/packages.neutral.lock.json create mode 100644 tools/ShaderCompiler/GlslIncludeExpander.cs create mode 100644 tools/ShaderCompiler/packages.win-x64.lock.json create mode 100644 tools/atmospheric-performance-matrix-common.ps1 create mode 100644 tools/connected-atmospheric-exposure-comparison.route.txt create mode 100644 tools/connected-render-pack-gate-common.ps1 create mode 100644 tools/connected-render-pack-transitions.route.txt create mode 100644 tools/launch-atmospheric-preview.ps1 create mode 100644 tools/run-atmospheric-performance-matrix.ps1 diff --git a/AcDream.slnx b/AcDream.slnx index 34d093fb..892b0ee3 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -16,17 +16,26 @@ + + + + + + + + + @@ -49,6 +58,9 @@ + + + diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 240c0622..aa44567d 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -1797,7 +1797,15 @@ tool run closes it). ## #392 — A refused/failed fullscreen enter leaves `fullscreen: true` persisted against a windowed client -**Status:** OPEN — filed 2026-08-13 from the #376/#388 blast review (M4). +**Status:** DONE — 2026-08-22. `IRuntimeDisplayWindowTarget` now returns +the observed native fullscreen post-condition. Both startup and live-save +controllers reconcile that result back through their own storage boundary, +so a refused/failed enter immediately restores `fullscreen: false` in the +retained Config row and `settings.json`; a failed leave likewise preserves +the true native state. Focused startup, target, persistence, and observer +tests pin the result seam and the requested→applied publication contract. + +**Original filing:** The save path persists the Full Screen flag BEFORE the apply runs; when the state-aware apply then refuses (mode not offered / catalog absent) or the native switch fails, the client stays windowed while settings.json and @@ -6869,7 +6877,14 @@ it. Do #297 FIRST — #298 depends on it. `#153` closed 2026-07-30 on the AD-30 hold + arrival StopCompletely + canonical outbound + reveal-barrier evidence chain). TS-50/TS-51/TS-53 are tracked in the divergence register. -- **Deferred visual fidelity:** `#226` retail landscape detail overlay. +- **Resolved visual fidelity (2026-08-21):** `#226` implements retail's + building/EnvCell detail overlay through the existing Building Detail + Textures preference. The reachable retail `ChangeRegion` caller disables + landscape detail, so no separate landscape-detail item remains queued. + The same Track A closeout ports retail's incident-face-averaged shared + terrain vertex normals without changing positions, indices, or collision; + terrain subdivision was rejected because quantized source samples cannot + recover detail and the retail-correct normal interpolation is now present. - **Deferred frame-pacing fidelity:** `#235`, capped/RDP jump presentation aliases the retail 30 Hz object clock; uncapped Release presentation is smooth and physics, collision, and wire state remain correct. @@ -9190,42 +9205,67 @@ the full 6,558-pass / 5-skip suite remains green. --- -## #226 — Retail landscape detail-texture overlay is not rendered +## #226 — Retail building/EnvCell detail-texture overlay is not rendered -**Status:** OPEN — deferred visual fidelity; the user-visible tiling regression -in #155 is fixed +**Status:** IMPLEMENTED + CONNECTED-VISUAL-VERIFIED 2026-08-21 **Severity:** LOW **Filed:** 2026-07-20 -**Component:** rendering / terrain material +**Component:** rendering / building and environment materials -**Description:** Retail can overlay a high-frequency landscape detail texture, -faded by viewer distance and gated by the Environment Detail Textures setting. -acdream now repeats every base/overlay/road surface at its authored -`TerrainTex.TexTiling`, which fixed the stretched/blurry symptom in #155, but -does not yet render this separate optional detail layer. +**Description:** Retail overlays a category-scoped detail texture on building +shells and interior/EnvCell geometry, faded by viewer distance and gated by +the Building Detail Textures preference. acdream's existing “Building +Detail Textures” checkbox persisted that preference but previously had no +renderer consumer. Outdoor landscape detail is forced off by the reachable +Sept-2013 retail preference caller and is not this issue's user-visible target. **Root cause / status:** The earlier #155 investigation conflated two retail mechanisms. `bb5acab9` ported the behavior that produced the observed mismatch: `TexMerge::CopyAndTile`/`Merge` pass each source's authored base tiling into the -terrain composition. The still-missing detail pass is a distinct -`LScape::GenerateDetailSurfaces`/`ACRender::landPolyDraw` path. The first -experimental detail-array implementation sampled the wrong neutral/data -contract and was reverted rather than shipping a darkened ground. TS-52 records -the current divergence. +terrain composition. #226 now resolves Dereth category 1/2 detail surfaces, +uploads their authored texture/tiling with retail wrap/linear sampling, and +replays building and EnvCell built-mesh subsets with the exact +`DESTCOLOR + INVSRCALPHA` blend. This includes opaque, ClipMap, straight-alpha, +additive, and inverse-alpha material subsets; transparent base/detail commands +remain adjacent in acdream's authoritative shared alpha order with depth writes +disabled (retail bypasses delayed alpha while detail is installed; retaining +the accepted queue is the registered bounded ordering seam). The existing +persisted checkbox is read at draw time. Opaque object replay is restricted to +coalesced command runs containing a building, with mixed commands filtered per +instance in the shader. Its depth-equal, non-A2C overlay inherits the exact +per-sample coverage written by the opaque/A2C base, including ClipMap edges. +Ordinary objects and landscape remain excluded; the base pass is untouched +when the option is off. The first experimental +landscape array used the wrong target, topology, neutral point, and blend and +was reverted rather than shipping a darkened ground. **Files:** `src/AcDream.App/Rendering/TerrainAtlas.cs`; -`src/AcDream.App/Rendering/TerrainModernRenderer.cs`; -`src/AcDream.App/Rendering/Shaders/terrain_modern.frag`. +`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs`; +`src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs`; +`src/AcDream.App/Rendering/Shaders/mesh_detail.vert`; +`src/AcDream.App/Rendering/Shaders/mesh_detail.frag`. **Research:** `docs/research/2026-07-13-retail-terrain-texture-tiling-pseudocode.md` -covers the now-shipped base contract. The detail symbols cited above must be -distilled into a dedicated pseudocode note as the first #226 implementation -step; the reverted experiment remains available in git history. +covers the already-shipped base contract. The dedicated, corrected detail +contract is `docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md`; +its evidence source is +`docs/research/2026-08-21-terrain-and-atmospheric-rendering-findings.md`. -**Acceptance:** With retail Environment Detail Textures enabled, close ground -shows the same high-frequency detail and distance fade without changing base -color/brightness. Disabling it produces the already-accepted authored base -tiling. +**Acceptance:** Toggling the existing “Building Detail Textures” checkbox +visibly changes nearby building and interior surfaces without a restart. +Enabled detail is full through 10 m positive view depth, fades linearly to an +exact no-op at 50 m, and preserves retail's measured slight brightening. +Disabling it submits no detail replay and preserves the already-accepted base +render. Landscape, ordinary objects, physics, and collision remain unchanged. +The automated gates cover the setting gate, data/blend/fade contract, +built-mesh subset eligibility, opaque command filtering and A2C coverage, +transparent depth/order seam, Vulkan descriptor +binding and total/per-stage storage-descriptor limits, shader artifacts, and +build. The connected Facility Hub A/B/A gate applied the real Config checkbox +on -> off -> restored-on: nearby static walls/floor changed immediately, the +restored frame returned to the original-on image (right-wall RGB MAE 2.132 +on/off versus 0.007 on/restored), the persisted preference was observed false +during B and restored true, and logout was ACE-confirmed graceful. --- @@ -12052,9 +12092,11 @@ field through `TerrainAtlas`, uploads a layer-indexed table, and applies it in the modern shader while leaving cell-scale alpha masks unchanged. The user confirmed the outdoor textures now match the expected scale. -The optional high-frequency Environment Detail Textures pass is a different -retail mechanism. It remains deferred under #226/TS-52 and does not keep this -fixed user-visible regression open. +The high-frequency detail pass is a different retail mechanism. #226 completed +its reachable user-visible target on 2026-08-21: building shells and EnvCell +geometry. Retail's reachable `ChangeRegion` caller passes zero landscape-detail +surfaces, so the former TS-52 landscape premise is retired and does not keep +this fixed user-visible regression open. **Files:** `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 2a0d6875..c9928813 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -1,4 +1,4 @@ -# Retail Divergence Register — current through 2026-07-31 +# Retail Divergence Register — current through 2026-08-22 **What this is.** The single auditable register of every known place acdream's runtime behavior can deviate from the retail client (Sept 2013 EoR build, @@ -37,7 +37,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 1. Intentional architecture (IA) — 20 active rows (IA-23 filed 2026-08-17 at the night-round review fix round (F8) — the House tab's not-yet-expired purchase-restriction line renders .NET's culture-default `DateTime.ToString()` where retail renders the C runtime's `strftime("%c", localtime(...))`, a different formatting engine producing a different-shaped (but equivalent-intent) date string; IA-22 filed 2026-08-13 — the #391 user-directed modern-only curated resolution list + desktop-mode default, replacing retail's full adapter enumeration + authored 800x600 default) +## 1. Intentional architecture (IA) — 21 active rows (IA-24 filed 2026-08-22 for Campaign AR's opt-in real-time sun/moon directional shadows; IA-23 filed 2026-08-17 at the night-round review fix round (F8) — the House tab's not-yet-expired purchase-restriction line renders .NET's culture-default `DateTime.ToString()` where retail renders the C runtime's `strftime("%c", localtime(...))`, a different formatting engine producing a different-shaped (but equivalent-intent) date string; IA-22 filed 2026-08-13 — the #391 user-directed modern-only curated resolution list + desktop-mode default, replacing retail's full adapter enumeration + authored 800x600 default) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -62,6 +62,9 @@ accepted-divergence entries (#96, #49, #50). | IA-22 | **Filed 2026-08-13 (#391, user-directed: "we should only support modern resolutions. Not any old format").** The Config Resolution dropdown offers a CURATED list — the monitor's real mode enumeration filtered to modern widescreen families (16:9/16:10/21:9/32:9, ≥1280 wide, fitting the desktop; `DisplayModeCatalog.Curate`) — and its Defaults value is the desktop's own mode. Retail offered the adapter's complete enumeration including 4:3 legacy modes and authored `800x600` as the row default (`gmConfigUI::InitOptions SetDefaultValue(0x03200258)`; `gmClient::Init @0x004047af` `Device::ForceDisplayResolution(1, 0x320, 0x258)`). | `src/AcDream.App/Rendering/DisplayModeCatalog.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (Resolution row); fixture fallback `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs` (`AvailableResolutions`, 800x600 removed) | Explicit product direction. **Amended 2026-08-16 (#407, Campaign CC gate round 1):** the dropdown now offers `DisplayModeCatalog.WindowedResolutions` — the curated hardware modes UNIONed with the static modern-ladder sizes that fit the desktop — because a WINDOWED pick is a plain Size write needing no video mode, and remote/RDP virtual displays advertise almost no modes (the live RDP display exposed only 1920x1080 + the 2056x1290 desktop, starving the dropdown). The original "an offered mode is supported by construction" invariant now holds for the FULLSCREEN half only: the fullscreen apply still validates against the hardware `Resolutions` list plus `GlfwDisplayModeSwitcher`'s monitor-mode-list hard guard, so a fullscreen pick of a windowed-only entry refuses safely (log-and-stay, #388; the #392 apply-result seam is that family's open follow-up) — "Graphics mode not supported" crashes remain unreachable from the dropdown. | A user wanting a genuine legacy 4:3 mode cannot pick it; retail-parity comparisons of the Config tab's list/default will show the deviation. | decomp sites in the Divergence column; ISSUES #391 | | IA-23 | **Filed 2026-08-17 at the night-round review fix round (F8).** `gmHouseUI::DisplayPurchaseTimeText @0x004a3110`'s not-yet-expired branch renders `"You may buy another landscape house at " + strftime("%c", localtime(timestamp + 0x278d00)) + ". This restriction does not apply to apartments."` — byte-decoded from raw pushed literals at `@0x004a3265`/`@0x004a321d`/`@0x004a3235` (all three text pieces confirmed; a prior filing had wrongly called this "unrecoverable"). This port renders the SAME three pieces, in the same order, with the same expiry-timestamp math, but formats the middle date/time piece with .NET's culture-default `DateTime.ToString()` (no explicit format string) rather than the C runtime's `strftime("%c", ...)` — the two engines do not share a format table, so the RENDERED SHAPE of the date/time differs (e.g. .NET's short numeric date+time vs the CRT's `Ddd Mon DD HH:MM:SS YYYY`-style locale string) even though both express "the process's own locale's full date+time" and use the SAME underlying instant (local time, matching retail's `localtime()`). | `src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs` (`Recompute`'s not-expired branch) | Both are "whatever the process locale says" full date+time strings; no game-logic reads or parses this text back, it is pure chat-scroll presentation, so a differently-shaped (but equally legible) date string carries no functional risk | A retail-side-by-side visual comparison will show a differently formatted date/time (not a byte-identical `strftime("%c")` reproduction) — cosmetic only | `gmHouseUI::DisplayPurchaseTimeText @0x004a3110`; `strftime`/`localtime` CRT calls at `@0x004a322c`/`@0x004a3216` | + +| IA-24 | **Filed 2026-08-22, Campaign AR.** An explicitly selected atmospheric render pack adds cascaded real-time directional shadows from terrain, trees, buildings, players, monsters, and other retained outdoor casters. The one shadow direction follows the visible authored sun, then the dominant haloed moon (`0x01001F6A`), then the secondary moon (`0x01001F67`); a moon supplies direction only while colour/energy remains retail's single interpolated `SkyTimeOfDay.DirColor × DirBright` channel. Retail renders none of these real-time object-shadow maps and does not expose a second moon light. | `src/AcDream.App/Rendering/Packs/AuthoredCelestialShadowSource.cs`; `src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs`; pack-only receiver shaders; evidence `docs/research/2026-08-22-dereth-celestial-shadow-sources.md` | This is the user-requested headline graphics enhancement and is strictly opt-in. The retail path remains the default and authoritative fallback; pack-off does not build/select shadow work or change `SceneLighting`. One selected source reuses one cascade array, so moon support does not multiply shadow resources. | Pack-on output intentionally differs from retail. A wrong celestial identity/transform or stale source transition would visibly misalign shadows from the sky; pack-off output changing would violate the campaign's primary safety contract. | `SkyDesc::GetLighting @0x00500A80`; `GameSky::UseTime @0x005075B0`; installed Region `0x13000000`; cited research note | + --- ## 2. Adaptation (AD) — 85 active rows (AD-110 filed 2026-08-17 at the entry/exit presentation round — the in-world logoff's single confirmed-echo handoff edge versus retail's two independent ExecuteLogOff/CharacterList edges, and the Tunnel-hold tail; AD-74 RETIRED 2026-08-17 at the same round — the Exit to Character Selection "behaves as Exit Game" adaptation is deleted: the confirmed grounded exit now runs the REAL retail flow (0xF653 request, server LogOut motion, 3 s hold, reverse wormhole, return to the live-connection character-select screen via LiveSessionController.CompleteCharacterLogOff), and the previously-missing indicator-bar grounded gate now runs retail's shared three-way branch; AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice; AD-111 (renumbered from a parallel-round AD-109 collision) filed 2026-08-17 at the systemic escape-normalization round — the appraisal report's wire-domain literal- @@ -180,7 +183,7 @@ readiness/requeue adaptation. See | AD-75 | **Filed 2026-08-11 at Campaign OP slice OP3 (D5).** Urgent Assistance (`0x10000206`) and Report Abuse (`0x10000207`) never call `ShellExecuteA` against `http://support.turbine.com/ics/support/ticketnewwizard.asp?style=classic` — the endpoint is dead in 2026. Each button instead ALWAYS emits its own byte-verified retail failure body (the `ShellExecuteA`-failure `MessageBoxA` text, `(Error code %d)` dropped since no real Win32 error ever occurs, the URL kept verbatim) through the interface-text seam (`RetailLogTextType.ClientLocal`) instead of a native `MessageBoxA` popup. | `src/AcDream.Core/Chat/OptionsPanelText.cs` (`UrgentAssistanceUnavailable`/`ReportAbuseUnavailable`); `src/AcDream.App/UI/Layout/OptionsPanelController.cs` (button wiring) | The URL genuinely does not resolve to a live Turbine support endpoint; attempting `ShellExecuteA` would open a browser to a dead page rather than usefully fail. The retained failure TEXT is retail's own (byte-verified), just always shown instead of conditionally on a real launch failure, and routed to acdream's existing interface-text channel rather than a modal OS dialog (retail's own EoR-era mechanism has no acdream analogue for a one-off native `MessageBoxA`). | If Turbine ever revives the endpoint, both buttons would still short-circuit instead of opening it — a silent staleness, not a crash. | `gmGameplayOptionsUI::ListenToElementMessage @0x0049E110`; `ShellExecuteA` call sites `0x0049E154`/`0x0049E1F0`; research doc `2026-08-10-keyboard-config-and-gameplay-tab.md` §4.1/§4.2 | | AD-76 | **Filed 2026-08-11 at Campaign OP slice OP3 (D5).** In-Game Help Files (`0x10000205`) is authored and clickable but has no handler — clicking it does nothing visible. | `src/AcDream.App/UI/Layout/OptionsPanelController.cs` (button wiring — no callback bound) | Retail's own `KeyStone::OpenHelp` loads a third-party embedded help viewer (`plugins\ACHelpPlugin.dll` via `keystone.dll`) that acdream does not have and cannot port (no DAT-resident help content, no source). Retail ITSELF fails silently with the plugin absent (`KeyStone::m_fnAC2HelpPluginExecute` unresolved) — mirroring that as an inert button is the faithful behavior for "the asset is missing", not an invented stub screen. | A user clicking In-Game Help Files gets no feedback at all, same as retail with the plugin missing — indistinguishable from a dead button unless they already expect the asset-missing case. | `KeyStone::OpenHelp @0x00557010`; `KeyStone::Init @0x00556CF0` (the unresolved plugin function pointer); research doc `2026-08-10-keyboard-config-and-gameplay-tab.md` §4.5 | | AD-77 | **Filed 2026-08-11 at the Campaign OP OP3 review-fix round (dual-review S4/MUST-FIX 2 — the plan's §5 "out of scope" list explicitly delegated this ruling to the OP3 review).** Retail exposes TWO `gmPanelUI` host variants for the same panel stack — a floating host (`0x2100006E`, `gmFloatyPanelUI`) and a docked host (`0x21000017`) — so a retail user can dock the Options panel (and every other `gmPanelUI` sibling) into a fixed screen position instead of leaving it freely floating. acdream mounts every main panel through `RetailWindowFrame.Mount` + `RetailPanelUiController.RegisterMainPanel` against the floating host ONLY; no code path resolves or mounts `0x21000017` at all. | `src/AcDream.App/UI/RetailUiRuntime.cs` (every `Mount*`/`RegisterMainPanel` call site for a `gmPanelUI` sibling — Character/Inventory/Spellbook/Effects/the four indicator-detail panels/Options); `src/AcDream.App/UI/Layout/RetailWindowFrame.cs` | This predates OP3 — every `gmPanelUI` sibling has shipped floating-only since its own slice landed; OP3 did not introduce the gap, it just added a tenth panel to an already-floating-only cohort. The plan explicitly scoped filing the row to "whichever slice's review deems it a divergence" rather than blocking any one panel's slice on building a docked-host variant no prior panel has either. | A user who expects to dock the Options panel (or any other main panel) the way retail allows cannot — every `gmPanelUI` sibling is floating-only in acdream, client-wide, not an Options-specific gap. | research doc `2026-08-10-options-panel-structure.md` §10.1 (docked/floating host pair); `docs/plans/2026-08-10-options-panel-campaign.md` §5 | -| AD-78 | **Filed 2026-08-11, user-directed (verbatim: "mark all options that are not implemented now, so I can clearly see what is not implemented"), gate 2 of Campaign OP's follow-up.** Retail dims nothing on any Options-panel row or Configure-Keyboard action row — every retail row drives its own real consumer by construction, so retail has no "does this actually do anything" ambiguity to signal. acdream, by contrast, ships a large honest store-only set (AP-198/AP-199/AP-200/AP-203, TS-73/TS-74/TS-75/TS-76/TS-77/TS-78/TS-79/TS-80, and the Character-tab Group A/D rows) that persist and, where auto-save, send the wire bit, but drive nothing observable client-side. Per explicit user direction, every such row's CAPTION now renders in a shared neutral grey (`UiRenderContext.StoreOnlyCaptionColor`, `(0.5,0.5,0.5,1)` — the SAME value the existing disabled/ghosted convention already used, `UiMenu.TextColorGhosted`) instead of its normal white/DAT-authored color, while the row itself stays fully interactive (click/drag/persist exactly as before — only the caption's paint color changes). No invented marker text is added anywhere (the project's "no user-visible strings outside the DAT" rule stands); the dim IS the marker. **[FA4 fix-round addendum, 2026-08-12 — blast SHOULD-FIX 1 + mechanism SF-8/SF-9: this row's own count had drifted stale THROUGH two campaigns (FA4's D7 un-dim landed 31, but this row still read the pre-FA4 "35"; the fix round then reverted three of FA4's four un-dims — see below — landing at 34). The Character-tab count is now 34 of 50 dimmed / 16 live.]** | `src/AcDream.App/UI/UiRenderContext.cs` (`StoreOnlyCaptionColor`, the one shared constant); `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (21 of 27 rows dimmed — `ApplyLabelAndTooltip`/`SetLabelText`'s `storeOnly` parameter, threaded from each `BindXxxSection` call site); `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs` (**34 of 50 rows dimmed** — `RowSpec.StoreOnly`, derived per-row in the class doc's table, cross-checked against actual shipped consumers rather than the research doc alone. FA4 D7 originally un-dimmed 4 rows — `IgnoreFellowshipRequests`/`FellowshipAutoAcceptRequests`/`FellowshipShareXP`/`FellowshipShareLoot` — landing at 31. The FA4 FIX ROUND, 2026-08-12, reverted THREE of those four back to dimmed: `IgnoreFellowshipRequests`/`FellowshipAutoAcceptRequests` per the corrected plan D6 (retail's client reads neither option bit on the fellowship-invite path — both are pure server-side filters with no client consumer, exactly like the two allegiance bits that were always meant to parallel them; the client-side auto-respond interceptor that was their claimed consumer, `RetailUiRuntime.TryAutoRespondToFellowshipInvite`, is deleted outright), and `FellowshipShareLoot` per mechanism review SF-8 (its claimed "second checkbox surface" consumer never actually reads the stored value back — a second EDITOR of a value is not a CONSUMER of it). Only `FellowshipShareXP` survives as genuinely live (the fellowship Create flow reads it as the sent `shareXP` bit) — net ONE row un-dimmed from the pre-FA4 baseline, not four.); `src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (`BuildActionRow` dims a row when `RetailActionIdentityTable.TryResolve` fails, i.e. `MappedAction` is null — AP-203's set); `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs` (audited, zero dimmed rows — every row already has a live consumer). | Explicit, unambiguous user direction (this session, gate 2) overriding the earlier per-slice register rows' silence on presentation; the four controllers' own conformance tests (`ConfigOptionsPageControllerTests.CaptionDimming_MatchesTheStoreOnlySetExactly`, `CharacterOptionsPageControllerTests.StoreOnlyRows_MatchTheDerivationTableExactly` + `Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows`, `KeyboardConfigControllerTests.UnmappedRows_DimTheirCaption_MappedRowsStayWhite`) pin the exact dimmed set so a future consumer landing without also flipping its row's literal fails the build, not just the eye. | A reviewer comparing a byte-exact retail screenshot to acdream will see caption colors retail never has — this row exists precisely so that divergence is understood as intentional, not a bug. If a row's dim/live classification in the four cited tables ever drifts from its ACTUAL consumer state (a landed consumer whose row was never un-dimmed, or a regressed consumer whose row was never re-dimmed), the caption becomes misleading in the OPPOSITE direction it was built to prevent — treat any report of "this dimmed row visibly does something" or "this live-looking row does nothing" as a real defect, not a rendering nit (see the gate script's own note). **The FA4 fix round is itself an instance of this exact risk materializing** — the register row lagged two code-side count changes across one campaign before this addendum caught up. This row retires only when acdream reaches full retail parity (zero store-only rows remaining), at which point the convention itself — not just its content — should be deleted. | None (acdream-only divergence; retail has no store-only rows to compare against) — `docs/research/2026-08-10-character-options-map.md` §7.1 (Group A/B/C/D split); `docs/research/2026-08-11-campaign-op-test-script.md` (per-tab store-only enumerations this row's dimmed set matches) | +| AD-78 | **Filed 2026-08-11, user-directed (verbatim: "mark all options that are not implemented now, so I can clearly see what is not implemented"), gate 2 of Campaign OP's follow-up.** Retail dims nothing on any Options-panel row or Configure-Keyboard action row — every retail row drives its own real consumer by construction, so retail has no "does this actually do anything" ambiguity to signal. acdream, by contrast, ships a large honest store-only set (AP-198/AP-199/AP-200/AP-203, TS-73/TS-74/TS-75/TS-76/TS-77/TS-78/TS-79/TS-80, and the Character-tab Group A/D rows) that persist and, where auto-save, send the wire bit, but drive nothing observable client-side. Per explicit user direction, every such row's CAPTION now renders in a shared neutral grey (`UiRenderContext.StoreOnlyCaptionColor`, `(0.5,0.5,0.5,1)` — the SAME value the existing disabled/ghosted convention already used, `UiMenu.TextColorGhosted`) instead of its normal white/DAT-authored color, while the row itself stays fully interactive (click/drag/persist exactly as before — only the caption's paint color changes). No invented marker text is added anywhere (the project's "no user-visible strings outside the DAT" rule stands); the dim IS the marker. **[#226 addendum, 2026-08-21: Building Detail Textures gained a live renderer consumer and is no longer dimmed; Config is now 20 of 27 dimmed.]** **[FA4 fix-round addendum, 2026-08-12 — blast SHOULD-FIX 1 + mechanism SF-8/SF-9: this row's own count had drifted stale THROUGH two campaigns (FA4's D7 un-dim landed 31, but this row still read the pre-FA4 "35"; the fix round then reverted three of FA4's four un-dims — see below — landing at 34). The Character-tab count is now 34 of 50 dimmed / 16 live.]** | `src/AcDream.App/UI/UiRenderContext.cs` (`StoreOnlyCaptionColor`, the one shared constant); `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (20 of 27 rows dimmed — `ApplyLabelAndTooltip`/`SetLabelText`'s `storeOnly` parameter, threaded from each `BindXxxSection` call site); `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs` (**34 of 50 rows dimmed** — `RowSpec.StoreOnly`, derived per-row in the class doc's table, cross-checked against actual shipped consumers rather than the research doc alone. FA4 D7 originally un-dimmed 4 rows — `IgnoreFellowshipRequests`/`FellowshipAutoAcceptRequests`/`FellowshipShareXP`/`FellowshipShareLoot` — landing at 31. The FA4 FIX ROUND, 2026-08-12, reverted THREE of those four back to dimmed: `IgnoreFellowshipRequests`/`FellowshipAutoAcceptRequests` per the corrected plan D6 (retail's client reads neither option bit on the fellowship-invite path — both are pure server-side filters with no client consumer, exactly like the two allegiance bits that were always meant to parallel them; the client-side auto-respond interceptor that was their claimed consumer, `RetailUiRuntime.TryAutoRespondToFellowshipInvite`, is deleted outright), and `FellowshipShareLoot` per mechanism review SF-8 (its claimed "second checkbox surface" consumer never actually reads the stored value back — a second EDITOR of a value is not a CONSUMER of it). Only `FellowshipShareXP` survives as genuinely live (the fellowship Create flow reads it as the sent `shareXP` bit) — net ONE row un-dimmed from the pre-FA4 baseline, not four.); `src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (`BuildActionRow` dims a row when `RetailActionIdentityTable.TryResolve` fails, i.e. `MappedAction` is null — AP-203's set); `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs` (audited, zero dimmed rows — every row already has a live consumer). | Explicit, unambiguous user direction (this session, gate 2) overriding the earlier per-slice register rows' silence on presentation; the four controllers' own conformance tests (`ConfigOptionsPageControllerTests.CaptionDimming_MatchesTheStoreOnlySetExactly`, `CharacterOptionsPageControllerTests.StoreOnlyRows_MatchTheDerivationTableExactly` + `Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows`, `KeyboardConfigControllerTests.UnmappedRows_DimTheirCaption_MappedRowsStayWhite`) pin the exact dimmed set so a future consumer landing without also flipping its row's literal fails the build, not just the eye. | A reviewer comparing a byte-exact retail screenshot to acdream will see caption colors retail never has — this row exists precisely so that divergence is understood as intentional, not a bug. If a row's dim/live classification in the four cited tables ever drifts from its ACTUAL consumer state (a landed consumer whose row was never un-dimmed, or a regressed consumer whose row was never re-dimmed), the caption becomes misleading in the OPPOSITE direction it was built to prevent — treat any report of "this dimmed row visibly does something" or "this live-looking row does nothing" as a real defect, not a rendering nit (see the gate script's own note). **The FA4 fix round is itself an instance of this exact risk materializing** — the register row lagged two code-side count changes across one campaign before this addendum caught up. This row retires only when acdream reaches full retail parity (zero store-only rows remaining), at which point the convention itself — not just its content — should be deleted. | None (acdream-only divergence; retail has no store-only rows to compare against) — `docs/research/2026-08-10-character-options-map.md` §7.1 (Group A/B/C/D split); `docs/research/2026-08-11-campaign-op-test-script.md` (per-tab store-only enumerations this row's dimmed set matches) | | AD-79 | **MOSTLY RETIRED 2026-08-13 (user-ordered social completion batch):** Friends Add/Remove/Appear-Offline and Squelch add-character/add-account/remove are LIVE (the wire beneath had existed end-to-end since J4.1/FA1 — docs/research/2026-08-13-social-wire-completion.md §4; the panel now publishes the same Runtime commands). REMAINING scope: the Friends "Send Tell" button (`0x10000516`) only, which needs the chat-tell seam. **Original filing — 2026-08-12 at Campaign FA slice FA3, D1 (the plan's "Friends + Squelch pages bind READ-ONLY... their mutation actions are wired only if their wire is already served by ACE and trivially pinnable in-slice — otherwise the action buttons are honest INERT" decision).** The social panel's Friends page authors three buttons (Add/Remove Friend-shaped, `0x10000514`/`0x10000515`/`0x10000516`) plus an "Appear Offline"-shaped checkbox (`0x1000052C`); the Squelch page authors three buttons (`0x10000547`/`0x1000054B`/`0x1000054C`). All seven are built, laid out, and clickable exactly as authored, but carry no click handler — no Friends add/remove/appear-offline wire and no Squelch add/remove/clear wire is implemented this campaign. `gmFriendsUI`/`gmSquelchUI` were also outside lane A/B/C/D's own decompiled scope (only Fellowship/Allegiance were researched), so their real button semantics and wire opcodes are not yet established either — this row covers BOTH "not wired" and "not yet researched." | `src/AcDream.App/UI/Layout/SocialFriendsPageController.cs`; `src/AcDream.App/UI/Layout/SocialSquelchPageController.cs` (both classes' own doc comments cite this row) | FA3 is the panel SHELL slice; D1 sets the bar for which Friends/Squelch actions get wired in-slice at "trivially pinnable," which none of these seven meet without their own wire research. `SocialPanelControllerTests.FriendsAndSquelchActionButtons_AreClickable_ButHaveNoHandler` pins the INERT contract so a future consumer landing without also removing this row's citation fails nothing silently — the row is the only signal until a follow-up slice wires real handlers. | A user clicking Add/Remove Friend, Appear Offline, or any Squelch button in acdream sees no effect and no feedback — indistinguishable from a dead control unless they already expect the gap. The Friends/Squelch LISTS themselves are live (bound read-only to `RuntimeCommunicationState.Friends`/`.Squelch`) — only the mutation controls are inert. | None (no retail decomp anchor — `gmFriendsUI`/`gmSquelchUI` are outside this campaign's researched scope); `docs/research/2026-08-11-fa-panel-structure.md` §10 (coordinator addendum, the panel discovery that first surfaced these two pages); `docs/plans/2026-08-11-fellowship-allegiance-campaign.md` D1 | | AD-80 | **Filed 2026-08-12 at Campaign FA slice FA4, D5.** The fellowship page's per-fellow percentage text renders retail's own byte-decoded XP-share table verbatim (1.0/.75/.6/.55/.5/.45/.4/.35/.3111111/.28, default 0.0 — `docs/research/2026-08-11-fa-fellowship-wire.md` §7.2, byte-decoded from the PDB-paired binary because both available decompilers folded the function to a constant). The currently-targeted ACE server computes the ACTUAL distributed XP from a DIFFERENT table (`.3` at 9 fellows instead of `.3111111`, no explicit 10-fellow row, and a wrong out-of-range default of `1.0` instead of `0.0` — `Fellowship.cs:604-632`, lane B §4.3). So a full (9-member) or over-full-in-retail's-table (10-member) fellowship's displayed percentage will not exactly match the XP ACE actually grants. This is a divergence between ACE and RETAIL, not between acdream and retail — acdream's client-side display is retail-faithful — but it is filed here because it is directly user-visible through this panel and a tester comparing "panel says 31.1%" against "server granted 30%" is measuring ACE's bug, not acdream's port. | `src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs` (`EvenSplitPercentTable`, `FormatStatsText`) | The client-side table is byte-verified against the retail binary; re-deriving it to match ACE's (wrong) numbers would make acdream disagree with a REAL retail client observing the same fellowship, which is the opposite of this project's goal. | A tester with a 9- or 10-member fellowship on ACE sees a panel percentage that does not exactly match the XP bonus they actually receive; below 9 members the two agree exactly. The proportional (non-even-split) branch has a SEPARATE, narrower gap: acdream has not ported an `ExperienceToRaiseLevel`-equivalent table, so that branch omits the percentage entirely (level only) rather than computing a wrong number — see AD-81's citation of the same method. | `FellowshipSystem::GetEvenSplitXPPctg @0x005B9BA0` (lane B §7.2); ACE `Fellowship.cs:604-632`; `docs/research/2026-08-11-fa-fellowship-wire.md` §4.3 | | AD-81 | **Filed 2026-08-12 at Campaign FA slice FA4.** Two retail text-composition primitives the fellowship page's mechanism needs are not ported, so this controller renders their CONTENT as plain numeric composites instead of retail's exact resolved sentence, never invented English: (1) **`StringInfo` variable substitution** — every row field beyond the bare name is a retail `StringInfo` template with embedded variables (`ID_Fellowship_FellowStats` + `ID_Level`/`ID_Experience`; the three `…Status` fields + `ID_Cur`/`ID_Max` — `docs/research/2026-08-11-fa-panel-structure.md` §3.1/§4.1), resolved at runtime through `StringInfo::InqString` → `StringTableMetaLanguage::UnescapeString`, a cross-cutting UI-string engine acdream has never ported (the SAME gap the pre-Campaign-OP Character window recorded, `docs/research/2026-06-25-character-window-faithful-spec.md`: "NOT yet ported — current controller uses canonical AC labels"); this controller instead renders `"{level} {pct}%"` and `"{cur}/{max}"` — the retail-authored NUMBERS, without retail's surrounding words. **AMENDED 2026-08-13:** the no-metalanguage fragment/variable interleave of `StringTable::GetString @0x004300D0` IS now ported as `DatStringResolver.ResolveTemplate` (the AD-85 dialog narrowing), so VERIFIED-token-free templates can resolve exactly; this row's remaining scope is the meta-token engine (`StringTableMetaLanguage::RenderString @0x004302B1` + `StripMetaLetters`) the multi-variable stats templates may need, plus `FormatName`. (2) **`ACCharGenData::FormatName`** — retail's Create flow canonicalizes the typed fellowship name and writes the formatted text back into the entry box before sending (lane B §2.2/§6.2); acdream sends the raw typed text verbatim. Neither gap affects the WIRE — the `0x00A2` builder's `str16L` field is unaffected either way; only the client-side PRESENTATION differs. | `src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs` (`UpdateRow`, `FormatStatsText`, `SetVitals`, the create-button `OnClick`) | Porting `StringTableMetaLanguage` is a cross-cutting UI-string-engine prerequisite, not a fellowship-specific task, and guessing its token syntax without decoding `StringInfo::InqString` would risk silently-wrong substitution rather than an honestly-numeric fallback — exactly the guessing CLAUDE.md's workflow forbids. `FormatName`'s capitalization/character rules are a separate chargen algorithm with no fellowship-specific anchor read yet. | A user sees "12 31%" / "140/140" instead of retail's full sentence, and a typed fellowship name keeps whatever casing/spacing the player typed instead of retail's canonicalized form. The underlying DATA (level, percentage, cur/max, the name itself) is correct in every case — only the surrounding words/formatting are absent. | `StringInfo::InqString @0x0042e490` → `StringTableMetaLanguage::UnescapeString` (unresolved — not yet decoded); `gmFellowshipUI::CreateFellowship @0x0048F730` (the `ACCharGenData::FormatName` call, lane B §2.2); `docs/research/2026-06-25-character-window-faithful-spec.md` (the identical prior finding for the Character window) | @@ -234,7 +237,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-193 | Character option id `0x34` (`ListenToPKDeathMessages` / "Listen to PK death messages") is mapped to `CharacterOptions2` bit `0x02000000` and modeled as a batched (non-auto-save) option purely on ACE's own enum — the id does not exist in the 2013 EoR PDB (`PlayerOption` there terminates at `TotalNumberOfPlayerOptions_PlayerOption = 0x34`), so neither the mask nor its `IsAutoSaveOption`/`GetDefaultOptionValue` classification is byte-verifiable against our binary. | `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` (`HearPkDeathMessages` row) | The user's retail memory (and ACE's own `CharacterOption` enum) both carry this option; shipping wire+store coverage for it is strictly better than omitting the row the Character tab's screenshots show, and ACE never actually reads the bit server-side (`PlayerFactory.cs:659-660` — "possibly was added to Defaults post PDB we have"), so a wrong id/mask/auto-save guess here has zero server-observable consequence either way. | If the final EoR client's real id/mask/auto-save classification ever surfaces (a later PDB, or a byte-level trace against a 2015+ binary), this row's values may be wrong and need correcting — until then treat them as ACE-sourced, not retail-verified. | ACE `PlayerFactory.cs:659-660`, `CharacterOptions2.cs` (`ListenToPKDeathMessages = 0x02000000`); `named-retail/acclient.h:4162-4218` (2013 `PlayerOption` terminates at `0x34`); `docs/research/2026-08-10-set-character-options-wire.md` §8.1 | | ~~AP-196~~ | **RETIRED 2026-08-11 at Campaign OP slice OP9.** Filed at the OP4 review-fix round (MUST-FIX 3/blast M2) recording that OP4's Group-C re-point deleted only three of the eight re-pointed `GameplaySettings` fields (`AutoTarget`/`AutoRepeatAttack`/`ViewCombatTarget`), leaving `VividTargetingIndicator`/`CoordinatesOnRadar`/`LockUI`/`AcceptLootPermits`/`ToggleRun` behind as WRITE-BEHIND `settings.json` persistence/draft mirrors of the now-authoritative server bit (plus a "two writable copies" default-source change, ADDENDUM historical only). OP9 verified all remaining `GameplaySettings` members — those five plus `ShowTooltips`/`SideBySideVitals`/`SpellDuration`/`AllowGive`/`ShowHelm`/`ShowCloak`/`AdvancedCombatUI`/`UseMouseTurning`, 13 total — already had a live server-bit home in `RuntimeCharacterOptionsState` (11 as OP4 Character-tab rows through `CharacterOptionTable`/`CharacterOptionsPageController`; `LockUI` through `/lockui` + the PlayerDescription `SetUiLocked` convergence, deliberately not a Character-tab row; `UseMouseTurning` through the Gameplay-tab mouse-macro button + the Config tab's Use-Mouse-Turning row — OP9 review NIT 6's channel-attribution correction) and deleted the `GameplaySettings` record outright — the type, the `SettingsStore.LoadGameplay`/`SaveGameplay` plumbing, and `RuntimeSettingsController`'s `Gameplay` property/`SetAcceptLootPermits` write-behind method — closing the "two writable copies" gap for good: there is no longer a second store to diverge from server truth. | `src/AcDream.App/Settings/RuntimeSettingsController.cs`; `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs`; `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` | — | — | `docs/research/2026-08-10-character-options-map.md` §7.1/§7.2 (Group C re-point directive); `CharacterOptionTable.cs` | | AP-197 | **Filed 2026-08-11 at the OP4 review-fix round (SF-1/S4).** "Display Timestamps" hardcodes retail's `PlayerModule` constructor-default format string `"%#H:%M:%S "` rather than reading the PER-CHARACTER override `GenericQualitiesData::InqString(m_pPlayerOptionsData, 1, &m_TimeStampFormat)` carries when the wire's `GenericQualitiesData` string-key `1` is populated — acdream's `PlayerDescription` parser reads and discards that field (wire research doc: "timestamp string (`0x80`) \| read, discarded \| ❌ \| never sent"). | `src/AcDream.Core/Chat/ChatLog.cs` (`FormatTimestampPrefix`); parser site cited at `docs/research/2026-08-10-set-character-options-wire.md:647` | The 2013 client's own constructor default is the only format any fresh/default character would ever show — retail ships no options-panel control that authors a custom one — so hardcoding the one value every real player sees is a safe, honest approximation until a consumer needs the per-character override. | A character whose account somehow carries a non-default persisted timestamp format (a modded/legacy server, or a hypothetical later retail patch exposing a UI for it) sees acdream render the DEFAULT format instead of their stored one — cosmetic only (still a valid H:MM:SS-shaped timestamp), never a wire or data-loss risk. | `PlayerModule::PlayerModule @0x005D51F0` (ctor default literal); `GenericQualitiesData::InqString` call site (wire doc §3.3); `docs/research/2026-08-10-set-character-options-wire.md` U6 | -| AP-198 | **Filed 2026-08-11 at Campaign OP slice OP6; CORRECTED at the OP6 rework round (2026-08-11, review N1/S2) — the row count was ALWAYS ten (this row's own enumeration always listed ten items); the commit message that said "nine" was the error, now reconciled, and `Render_ScreenBrightness` no longer overloads `Gamma`.** The Config tab's "Graphics Options" + "Rendering Quality Options" sections author ten rows with no acdream renderer consumer: `Render_ScreenBrightness` (its OWN `DisplaySettings.ScreenBrightness` field, range [-1,1] default 0 — NOT the pre-existing `Gamma` multiplier, which has a different unit system and its own live legacy Settings-panel consumer; no gamma-correction pass exists for either), `Render_AutomaticDegrades`, `Render_GraphicsPerformance`, `Render_DegradeDistance`, `Render_LandscapeTextureDetail`, `Render_EnvironmentTextureDetail`, `Render_TextureFiltering`, `Render_LandscapeDrawDistance`, `Render_BuildingDetailTextures`, `Render_MultiPassAlpha`. acdream's world renderer is Vulkan driven by ONE aggregate `QualitySettings`/`QualityPreset` (near/far streaming radii, anisotropic level, alpha-to-coverage, completion budget) — there is no per-feature texture-detail/degrade-distance knob for any of these ten rows to drive. Each round-trips faithfully through `DisplaySettings`/`SettingsStore` and shows retail's own row/label/range (where applicable), with zero observable render effect. **Sub-note, `Render_LandscapeDrawDistance` specifically:** its retail default (`gmConfigUI::InitOptions @0x0049E70D`, `SetDefaultValue(8)`) does not index its own 6-entry `UIPreferences::SetEnumChoices` array (`ID_Graphics_Value_VeryLow`..`Extreme`, `gmClient::InitUIPreferences @0x004041b7`) — reproduced faithfully as an opaque `int` (`DisplaySettings.LandscapeDrawDistance`), not guessed into a clamped index; the Config-tab menu simply shows no highlighted selection at the default. | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (`BindGraphicsSection`/`BindRenderingQualitySection`) | Building ten dead per-feature render knobs into a Vulkan renderer that has no analogous per-feature toggles would be pure UI theater with no correctness payoff; persisting them faithfully keeps the panel honest (every row is clickable, nothing crashes, nothing silently discards a user's choice) while the register makes the "no effect" fact auditable rather than a silent gap a future report would have to re-discover. | A user who changes any of these ten Config-tab controls sees no visual change and, for `LandscapeDrawDistance` specifically, may see no highlighted menu item even after Defaults — both are the CONTRACTED behaviour for this row, not a bug. | `gmConfigUI::InitOptions @0x0049E400`; `gmClient::InitUIPreferences @0x004035b0` (`UIPreferences::AttachPreference`/`SetEnumChoices` calls); `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`QualitySettings`/`ReapplyQualityPreset`) | +| AP-198 | **Filed 2026-08-11 at Campaign OP slice OP6; CORRECTED at the OP6 rework round (2026-08-11, review N1/S2); NARROWED 2026-08-21 by #226.** The Config tab's "Graphics Options" + "Rendering Quality Options" sections author nine rows that still have no acdream renderer consumer: `Render_ScreenBrightness` (its OWN `DisplaySettings.ScreenBrightness` field, range [-1,1] default 0 — NOT the pre-existing `Gamma` multiplier, which has a different unit system and its own live legacy Settings-panel consumer; no gamma-correction pass exists for either), `Render_AutomaticDegrades`, `Render_GraphicsPerformance`, `Render_DegradeDistance`, `Render_LandscapeTextureDetail`, `Render_EnvironmentTextureDetail`, `Render_TextureFiltering`, `Render_LandscapeDrawDistance`, `Render_MultiPassAlpha`. #226 removed `Render_BuildingDetailTextures` from this row: the existing checkbox now directly gates the retail building/EnvCell detail replay and is no longer caption-dimmed as store-only. acdream's remaining world-quality controls are Vulkan driven by ONE aggregate `QualitySettings`/`QualityPreset` (near/far streaming radii, anisotropic level, alpha-to-coverage, completion budget) — there is no per-feature texture-detail/degrade-distance knob for the nine residual rows to drive. Each residual round-trips faithfully through `DisplaySettings`/`SettingsStore` and shows retail's own row/label/range (where applicable), with zero observable render effect. **Sub-note, `Render_LandscapeDrawDistance` specifically:** its retail default (`gmConfigUI::InitOptions @0x0049E70D`, `SetDefaultValue(8)`) does not index its own 6-entry `UIPreferences::SetEnumChoices` array (`ID_Graphics_Value_VeryLow`..`Extreme`, `gmClient::InitUIPreferences @0x004041b7`) — reproduced faithfully as an opaque `int` (`DisplaySettings.LandscapeDrawDistance`), not guessed into a clamped index; the Config-tab menu simply shows no highlighted selection at the default. | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (`BindGraphicsSection`/`BindRenderingQualitySection`) | Building the nine residual dead per-feature render knobs into a Vulkan renderer that has no analogous controls would be pure UI theater with no correctness payoff; persisting them faithfully keeps the panel honest while the register makes the "no effect" fact auditable. | A user who changes any of these nine residual Config-tab controls sees no visual change and, for `LandscapeDrawDistance` specifically, may see no highlighted menu item even after Defaults — both are the CONTRACTED behaviour for this row, not a bug. Building Detail Textures is explicitly outside this residual and must visibly change eligible building/EnvCell surfaces. | `gmConfigUI::InitOptions @0x0049E400`; `gmClient::InitUIPreferences @0x004035b0` (`UIPreferences::AttachPreference`/`SetEnumChoices` calls); `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`QualitySettings`/`ReapplyQualityPreset`); `docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md` | | AP-199 | **Filed 2026-08-11 at Campaign OP slice OP6; CORRECTED at the OP6 rework round (2026-08-11, review M2) — the field names and the "gating to zero when disabled" wording were describing an INVERTED, muted-by-default bug, not the shipped behaviour.** The Config tab's "Sound Options" section authors three rows with no acdream consumer: `Sound_SoundFeatures` (Stereo/Mono menu — acdream's OpenAL backend has no channel-count toggle), the Interface Sound toggle+slider trio (`Sound_InterfaceSoundDisabled`/`Sound_InterfaceSoundVolume` — AP-174 already documents this as retail's OWN dead knob, "registered and then never read... interface sounds are scaled by the EFFECT knob"; acdream matches that exact behaviour rather than building a working Interface bus), and `Sound_PlaySoundOnlyWhenActive` (no window-focus-based audio mute subsystem exists). All three round-trip faithfully through the new `AudioSettings.SoundFeatures`/`InterfaceEnabled`/`InterfaceVolume`/`PlaySoundOnlyWhenActive` fields. The Sound and Ambient trios' own toggle+slider pairs are NOT covered by this row — `SfxEnabled`/`AmbientEnabled`/`Sfx`/`Ambient` are LIVE (`RuntimeSettingsController.SaveAudio` now pushes into `OpenAlAudioEngine` on every change; the effective volume is zero only when the corresponding `*Enabled` flag is false — retail's own `SoundManager::effect_sounds_enabled`/`ambient_sounds_enabled` statics default to enabled, so a fresh profile is audible, not muted). | `src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (`BindSoundSection`) | Matches the SAME reasoning AP-174 already established for the Interface knob specifically; Sound Features and Play-Only-When-Active are honest new store-only rows with no existing or planned acdream subsystem to bind (stereo/mono output selection and window-focus audio gating are both out of this campaign's scope). | A user who changes any of these three Config-tab controls sees/hears no change — the CONTRACTED behaviour, matching retail's own Interface-knob precedent for two of the three. | `gmClient::InitUIPreferences @0x004035b0` (`AttachPreference(&Sound_SoundFeatures, ...)`/`&Sound_InterfaceSoundDisabled`/`&Sound_InterfaceSoundVolume`/`&Sound_PlaySoundOnlyWhenActive`); AP-174 (Interface-knob precedent); `SoundManager::InitPrefs @0x005503F0` (`UserPreferences::RegisterPreference` binding the enabled-sense statics) | | AP-200 | **Filed 2026-08-11 at Campaign OP slice OP6.** The Config tab's "UI Options" section authors `UI_ChatFontFace`/`UI_ChatFontSize` menu rows (retail Windows TrueType face name / a Tiny-Small-Medium-Large-XLarge size-tier enum). These are DELIBERATELY separate NEW fields (`ChatSettings.ChatFontFace`/`ChatFontSizeIndex`) rather than reusing the existing LIVE `ChatSettings.FontSize` (a 10..20pt float acdream's chat panel already renders with) — there is no verified index-to-point mapping from retail's five-tier enum to that float range, and acdream's text rendering has no arbitrary system-font-face swap capability (DAT-baked/bitmap fonts only, not OS TrueType files). Store-only round-trip; `FontSize` is untouched by these two rows. | `src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (`BindUiSection`) | Inventing a size-index-to-point mapping without retail evidence would risk silently overwriting `FontSize`'s own already-live, user-visible behaviour with a guessed value; keeping the two concepts separate is the honest choice until a byte-verified mapping (or a font-face-swap capability) exists. | A user who changes either Config-tab font control sees no chat-panel rendering change; the SEPARATE, pre-existing font-size control (wherever acdream currently exposes `ChatSettings.FontSize`) remains the only live one. | `gmClient::InitUIPreferences @0x0040387b`/`@0x00403a1a` (`AttachPreference(&UI_ChatFontFace, ...)`/`&UI_ChatFontSize`, `SetEnumChoices` choice arrays "Arial"/"Tiny".."XLarge") | | AP-172 | **Filed 2026-08-08 (#354 fix — spell-bar drag reorder).** Retail removes a lifted favorite from `PlayerModule` (+ UI list + wire) the instant a drag starts and the remaining shortcuts visibly slide left to close the gap for the rest of the gesture (`RecvNotice_ItemListBeginDrag` → `RemoveSpellFromMenu`, live). acdream's controller performs the same PlayerModule/wire removal at drag-begin but DEFERS the whole favorite-list's visual rebuild until the drag concludes (drop or off-bar release) — the lifted cell's icon stays visible in its old slot and siblings do not slide until release, instead of reflowing continuously through the gesture. `DropFavorite` compensates by porting retail's own `AddFavorite`-side index adjustment (decrement the target index by one when the lifted item's original index was before it) against the now-intentionally-stale sibling numbering, so the FINAL landed position is byte-identical to retail's in every case exercised (`DragFavoriteOntoAnotherSlot_ThroughTheRealPointerPipeline_ReordersAndSyncsWire`). **NARROWED + CORRECTED 2026-08-08 (drop-ring change).** Correction: this row originally claimed empty-tail-slot drops were "already-live-count-relative and are untouched" — false. The #354 `-1` adjustment sat inside `DropFavorite`, which the empty-cell path also calls, so its live-count-clamped (post-lift-numbered) index was double-corrected: lifting a non-last favorite onto the empty tail landed it second-to-last instead of last. `FavoriteDropIndex` is now THE one landing computation and applies retail's rule exactly — the `-1` is gated on the lifted spell's pre-lift-numbered removal site (retail's `RemoveSpellFromMenu`-return-gated decrement @0x004C7157), which for a live-numbered empty-tail target is retail's `RemoveSpellFromMenu == -1` no-adjustment case (test `SpellFavoriteDrag_DroppedOnTheEmptyTail_AppendsAtTheEnd` fails against the double-correcting code). Narrowing: the mid-drag presentation now includes retail's authored drag-over Accept ring — `SpellCastSubMenu::OnItemListDragOver` @0x004C5990 setting the per-cell authored DragAccept child (element 0x1000045A, `UIElement_UIItem::PostInit` @0x004E1870) to `ItemSlot_DragOver_Accept` (UIStateId 0x10000040 → authored art 0x060011F9) on the hovered cell while a spell drag is live, cleared on leave/drop (`UiCatalogSlot.DragOverAcceptance` → `UiItemSlot.DrawDragAcceptOverlay`), with the ring and the drop sharing `FavoriteDropIndex` so the ring cannot promise a different landing. | `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`BeginFavoriteDrag`, `EndFavoriteDrag`, `DropFavorite`, `Tick` — the `_favoriteDragActive` gate) | `UiRoot`'s subtree-removal safety net (`ClearSubtreeOwnership`, `UiRoot.cs:240-247`) cancels any in-flight drag whose source widget is destroyed, and `Rebuild()` tears down and recreates every cell in the list (`UiItemList.Flush` → `RemoveChild` per cell) rather than incrementally diffing. Left unguarded, the press-time removal's `SpellbookChanged` event would let the very next per-frame `Tick()` (production drives this unconditionally via `RetailUiRuntime.Tick`) destroy the cell driving the gesture and silently cancel the reorder before the user could complete the drop — this was the reported bug. Deferring the rebuild for the gesture's duration is the minimal fix that does not touch the shared `UiRoot` drag machinery every other panel (toolbar/inventory/vendor/paperdoll) also depends on. | A future rewrite that makes `Rebuild()` an incremental per-cell diff (add/remove/reflow one cell) instead of flush-and-recreate-all would make this deferral unnecessary and should retire this row along with it — until then, a player watching their OWN spell bar mid-drag sees the vacated slot's icon linger and siblings snap into place only on release, rather than reflowing live as retail does — and one ring consequence of that frozen bar: when dragging rightward past the source, the Accept ring's SCREEN slot sits one cell right of where the icon finally lands (retail's live-reflowed bar makes them coincide); the ring is on the correct CELL in both — the spell lands immediately before that cell's spell, retail's exact insert-before semantic. No effect on final position, the wire pair sent, or any other panel; cross-window spellbook→favorite drops are live-count-relative and untouched (the empty-tail claim this sentence used to carry was corrected 2026-08-08 — see the Divergence column). | `gmSpellcastingUI::RecvNotice_ItemListBeginDrag` @0x004C7360 (`SpellCastSubMenu::RemoveSpellFromMenu`, immediate live-list removal at lift); `SpellCastSubMenu::AddFavorite` @0x004C7060 (`RemoveSpellFromMenu`'s return value gating the `-1`-if-lifted-before-target `m_numSpells` adjustment before `ItemList_InsertSpellShortcut`); `PlayerModule::AddSpellFavorite` @0x005D43E0 (`InsertPos`); `PlayerModule::RemoveSpellFavorite` @0x005D4910 | @@ -456,7 +459,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead withholds Hidden hosts from ordinary `GetObjectA` relationship creation and uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Physics/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` (`ResolvePhysicsHost`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` | | TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; shared frame drain in `src/AcDream.App/Update/LiveObjectFrameController.cs` (`LiveEffectFrameController`) | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order | | TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each ordinary object's ParticleManager then ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` instead advances that static owner's ScriptManager then ParticleManager and only then `process_hooks`, using its whole admitted elapsed interval. acdream's shared tail is Particle → Script after static hook capture. | `src/AcDream.App/Update/LiveObjectFrameController.cs` (`LiveObjectFrameController` + `LiveEffectFrameController` shared `_particles.Tick` / `_scripts.Tick` tail); `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs` | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and ordinary manager order faithful without pretending the shared tails have per-owner timing or static-tail order. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static hooks can route before their script/particle managers and static default scripts/particles use render elapsed in Particle → Script order rather than `animate_static_object` elapsed/discard and Script → Particle → hooks timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum/order | -| TS-52 | The terrain shader applies retail-authored base/overlay/road `TerrainTex.TexTiling` but omits the separate Environment Detail Textures pass and its viewer-distance fade (**#226**). | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | `bb5acab9` fixed the user-visible stretched/blurry regression by porting the distinct base-tiling contract. An earlier experimental detail array darkened the whole ground because its source/neutral blend contract was wrong, so it was correctly reverted rather than guessed into production. | With retail's Environment Detail Textures preference enabled, close terrain lacks the extra high-frequency modulation/fade even though authored base texture scale is correct. | `LScape::GenerateDetailSurfaces` / `SetDetailTexturing @ 0x00506B40`; `ACRender::landPolyDraw @ 0x006B6450..0x006B6525`; issue #226 | +| ~~TS-52~~ | **RETIRED 2026-08-21 (#226).** The row's landscape premise was wrong: the reachable Sept-2013 `ChangeRegion` caller passes zero LANDSCAPE detail surfaces and enables the building/environment categories. acdream now resolves those authored category detail textures and tiling, replays eligible building-shell and EnvCell subsets with retail's `DESTCOLOR + INVSRCALPHA` blend and 10–50 m viewer-depth fade, and consumes the existing Building Detail Textures preference. The earlier experimental landscape array remains correctly reverted; there is no missing user-visible landscape pass to track. | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs`; `src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs`; `src/AcDream.App/Rendering/Shaders/mesh_detail.vert`; `src/AcDream.App/Rendering/Shaders/mesh_detail.frag` | Retired on measured caller/category evidence and the connected on/off/restored visual gate. | None; disabling the preference submits no detail replay, while enabling it visibly changes nearby building/EnvCell surfaces. Landscape remains unchanged, matching the reachable retail caller. | `docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md`; `LScape::ChangeRegion`; `SmartBox::SetDetailTexturing`; issue #226 | | TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slices 6–7 preserve the accepted host order as ownership-only extractions. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/PrivatePresentationRenderer.cs` (`RetainedGameplayUiFrame.Render`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`; `docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison | | ~~TS-54~~ | **RETIRED 2026-08-08 (Campaign A slice A4).** The AdminEnvirons stingers now play. `UiSoundController.PlayEnvironCue` maps the change type through `EnvironSoundCueMap` — an EXPLICIT table read case-by-case out of `CPlayerSystem::Handle_Admin__Environs` @ `0x0055DE20` (`0x0055E0C6..0x0055E2C7`), not an offset: codes `0x65..0x72` sit 0x11 below their SoundType but `0x73`/`0x74` have no case at all, so `0x75` lands on `UI_Squeal` (0x84) where arithmetic would give 0x86, and the switch ends at `0x7B`/`UI_Thunder6` with no `0x7C` case. All 21 cases are pinned by conformance tests. The bank itself is no longer a blocker either: the UI sound table's DID is resolved by walking the dats' EnumIDMap chain (`UiSoundTableResolver`, master → slot-7 map → `0x2000004B`), which is how retail finds it — `GetUISoundTable` holds no literal. | retired | — | — | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20`; `SoundManager::PlaySoundFromCenter @ 0x00550950`; `ClientUISystem::GetUISoundTable @ 0x00563FB0`; `docs/research/2026-08-08-audio-retail-music-absence.md` §5 | | TS-55 | AdminEnvirons fog values remain a color-only `WeatherSystem.Override` approximation. Retail values 1..5 install authored ambient color/level plus fog color/max; value 6 also forces transition/min/max and blanks radar; Clear restores all override fields and radar; `0x270F` installs a separate authored override. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`); `src/AcDream.Core/World/WeatherState.cs` (`EnvironOverrideColor`) | Preserves the already accepted enum bridge while Slice 8 moves ownership; porting the complete environment/radar presentation is a separate behavior change requiring focused visual gates. | Forced-fog hue, density, scene ambient, and radar blanking differ from retail; `0x270F` is ignored. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055DE2B..0x0055E344`) | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index b74034db..8f475c92 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -2070,6 +2070,8 @@ Native macOS graphical support is not committed by this track. The current mandatory renderer requires modern OpenGL capabilities beyond Apple's native OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen. +**Future / unscheduled — Campaign AR:** the opt-in [Atmospheric Rendering / Shader Packs campaign](2026-08-21-atmospheric-rendering.md) makes moving authored sun-and-moon directional shadows from trees, monsters, players, and buildings its Tier-2 headline while preserving acdream's current retail-faithful renderer as the default and leaving physics, collision, gameplay, and network behavior unchanged; the project owner assigned Campaign AR on 2026-08-22 without displacing active M4 gameplay work. The [celestial source contract](../research/2026-08-22-dereth-celestial-shadow-sources.md) selects sun, dominant moon, then secondary moon by rendered direction while retaining AC's single authored directional-energy channel; sun rays and volumetrics remain sun-only. The previously referenced #268 + TS-8 package is complete and retired. Stage 1's automated correctness, performance, lifetime, locked-restore, Release, evidence, documentation, and project-owner live gates completed on 2026-08-22 after the opt-in exposure correction. Stage 2 connected, performance, lifetime, package-lifecycle, physical-hardware, and closeout evidence is active. + --- ## Cross-cutting work tracked in parallel diff --git a/docs/plans/2026-08-21-atmospheric-rendering.md b/docs/plans/2026-08-21-atmospheric-rendering.md new file mode 100644 index 00000000..e3220a6f --- /dev/null +++ b/docs/plans/2026-08-21-atmospheric-rendering.md @@ -0,0 +1,931 @@ +# Campaign AR — Atmospheric Rendering / Shader Packs + +**Date:** 2026-08-21 +**Status:** STAGE 2 ACTIVE — the approved authored sun-and-moon shadow-source +extension and every non-physical Stage 1 correctness, performance, lifetime, +Release, evidence, and documentation gate are complete. After the live +sun/moon, source-transition, temporal-stability, desktop-performance, and +exposure correction round, the project owner accepted Stage 1 on 2026-08-22. +Stage 2 connected, lifetime, package-lifecycle, physical-hardware, and final +closeout evidence is now active. +**Phase id:** **Campaign AR** — assigned by the project owner on 2026-08-22 +**Scheduling:** originally held for the post-M7 rendering-polish pass; the +project owner explicitly authorized implementation on 2026-08-21. This +owner-directed campaign is now Campaign AR and does not displace the active M4 +gameplay work. The previously referenced #268 + TS-8 stat-chain package is +already complete and retired, so it is no longer a scheduling dependency. + +## Goal + +Add an opt-in enhanced-graphics system whose headline feature is **real-time +directional shadows cast by trees, monsters, players, and buildings as +Dereth's authored sun and moons move across the sky**. The same system can add +bloom, filmic tonemapping, colour grading, vignette, sun rays, and later +volumetric shafts, with useful quality levels on weak through high-end +hardware. Sun rays and volumetric shafts remain sun-only effects; the approved +moon scope applies to Tier 2 directional shadows. + +Campaign AR executes in two stages. Stage 1 fixed the dense-scene transform +ceiling and shadow quality, completed authored sun/dominant-moon/secondary-moon +source selection, and finished every automated gate that did not require the +project owner's physical-display judgment. The owner accepted the subsequent +live visual/performance round on 2026-08-22 after the default Atmospheric +exposure was corrected from 1.0 to 0.80. Stage 2 is active and limited to the +remaining connected, lifetime, package-lifecycle, physical-hardware, evidence, +and final owner-acceptance rows; it does not add another renderer feature tier. + +Stage 1's dense-scene regression is pinned by the connected failures already +captured on 2026-08-22: Atmospheric fell back at 68,395, 67,581, and even +65,538 combined world matrices against the old 65,536-matrix binding ceiling. +The corrected connected launch must exceed that historical workload without +persisting acdream-default fallback or splitting the authoritative pose data. + +The enhancement is a shader pack, not a rewrite of acdream's renderer or AC's +art. **acdream's current retail-faithful renderer** remains the default, +authoritative path. References below to the “default” or “retail-faithful” path +always mean acdream—not the original retail executable. + +The evidence and constraints for this design are recorded in the +[terrain and atmospheric rendering findings](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md), +especially [the measured renderer baseline](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md#5-renderer-state-relevant-to-atmospheric-work) +and [the requested tier model](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md#6-wanted-work--atmospheric-rendering-user-stated). +The approved celestial identity, priority, transform, and direction-versus- +energy contract are pinned by the +[Dereth celestial shadow-source research](../research/2026-08-22-dereth-celestial-shadow-sources.md). + +## Opt-in contract + +1. **acdream's current renderer is the default.** With no pack selected, the current render graph, + shaders, render targets, submissions, lighting, colours, and screenshots + remain authoritative. No enhancement resource or pass is created. +2. **Selection is explicit.** Installing a pack does not enable it. The user + selects one pack and one quality preset in Display settings. `acdream + default (retail-faithful)` is always present and cannot be removed. +3. **One pack owns the enhancement graph.** Packs do not stack. This prevents + ambiguous pass ordering, incompatible HDR conventions, and unbounded GPU + cost. +4. **The renderer owns the RHI.** A pack declares assets, semantic pass hooks, + capabilities, resources, and quality variants. It never receives Vulkan + handles or mutates the authoritative scene, streaming, gameplay, or + physics owners. +5. **Failure returns to acdream's default renderer.** Unsupported capabilities, + malformed assets, shader/pipeline candidate-creation failure, or an invalid + pass graph disables the complete pack and restores acdream's default path + with a visible reason. A half-enabled pack is never rendered. A terminal + `VK_ERROR_DEVICE_LOST` cannot render either path on the lost device; it tears + down that renderer/device lifetime, and retail remains authoritative while a + fresh renderer/device is constructed and the pack is validated again. +6. **Divergence is honest.** Enhanced screenshots are intentionally not retail + parity evidence. The default path remains the comparison oracle and the + enhancement choice is recorded in diagnostics and screenshot metadata. +7. **No scheduling claim.** Rendering phases stay frozen until the M7 polish + pass unless the project owner explicitly reprioritizes this work. + +## Capability tiers + +The costs below are **planning estimates**, not measurements. They are +incremental GPU p50 targets for a representative discrete GPU at 1920x1080; +every slice must replace them with physical-hardware measurements. Tier-1 +pixel effects scale with output resolution, so 4K has roughly four times the +1080p fragment workload. Shadow-map cost depends more on caster count, map +resolution, and cascade count than on output resolution. + +| Tier | Contents | Prerequisite | Rough incremental GPU cost at 1080p | +|---|---|---|---:| +| acdream default | Current authoritative retail-faithful rendering | Current mandatory Vulkan/RHI capabilities | 0 ms | +| 1 | Bloom, ACES filmic tonemap, colour grade, vignette | Main-world colour intermediate and fullscreen passes | 0.35–0.80 ms | +| 1 | Screen-space sun rays (crepuscular) | Authored sun screen position plus an occlusion mask; **no shadow maps** | 0.20–0.50 ms | +| 2 | **Moving authored sun-and-moon cascaded directional shadows from trees, monsters, players, and houses/buildings** | A second scene pass, selected-celestial view/projection matrices, sampled depth maps, caster pipeline variants | 1.50–3.00 ms | +| 2+ | Sun-only volumetric light shafts | Reuse Tier-2 shadow infrastructure only while the selected source is the authored sun, plus authored weather | 0.15–0.40 ms | +| Later | SSAO and water reflections | Scene depth plus normal inputs and separate designs | Not budgeted here | +| Out | True PBR | AC lacks authored per-texture normal/roughness/metalness maps | Not planned | + +Tier numbers express prerequisites, not a forced bundle. A pack may offer +Tier 1 without shadows. Tier 2 always includes the complete shadow-caster +classes; weak-hardware presets reduce range, cascade count, and resolution +rather than silently dropping monsters, trees, or buildings. + +## Tier 2 headline: Dereth's moving authored sun-and-moon shadows + +The [measured renderer state](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md#5-renderer-state-relevant-to-atmospheric-work) +already supplies retail's single directional colour/energy channel from +`SkyStateProvider`. Tier 2 augments it with the visible authored celestial +positions documented in the +[Dereth celestial shadow-source research](../research/2026-08-22-dereth-celestial-shadow-sources.md). +The camera-relative cascaded map selects, in order, the visible above-horizon +sun (`0x01001348`), dominant haloed moon (`0x01001F6A`), or secondary moon +(`0x01001F67`). The selected mesh's exact rendered transform supplies shadow +direction; retail's one interpolated `DirColor * DirBright` channel supplies +colour/energy. Moon texture brightness and mesh luminosity never manufacture a +second world light. As those authored bodies move, tree branches, monsters, +players, houses, and other eligible world geometry cast correspondingly moving +shadows. + +This source selection is an explicit opt-in pack enhancement. It is not a +claim that the retail executable rendered real-time moon shadows, and it does +not alter acdream's default retail-faithful scene lighting. Screen-space sun +rays and volumetric shafts continue to use only the authored sun; they do not +switch to either moon. + +The required behavior is: + +- Terrain and opaque world geometry receive shadows. Terrain, buildings, + statics, procedural scenery, the local player, remote players, and creatures + cast them when resident and visible to the main outdoor world. +- Foliage and other cutout materials use an alpha-sampling shadow fragment + shader. An empty depth fragment shader would turn each tree plane into a + solid rectangular shadow. +- Animated casters reuse the exact per-part transforms already published in + the N.5 SSBO. The shadow pass must not create a second animation pose or + gameplay entity projection. +- Cascades follow the camera and are texel-stabilized. Their reach is clamped + to the resident two-tier streaming window; the pack does not extend world + streaming or issue speculative loads. +- The celestial directional-shadow pass is outdoor-only. Dungeon and EnvCell lighting remains + authored per-cell lighting. Entering an interior retires or idles outdoor + shadow work without leaving stale maps on screen. +- Shadow direction follows the selected visible above-horizon sun, dominant + moon, or secondary moon. The authored directional colour/energy remains + `DirColor * DirBright`; active day/weather pack policy may soften or reduce + it without inventing a second celestial clock, light-energy channel, or + weather system. A time with no eligible above-horizon body has no + directional shadow; night is not itself a disable condition. +- Transparent blend materials do not cast an opaque silhouette by default. + Only existing opaque and cutout classifications participate until a + material-specific transparent-shadow contract is designed. + +The pass reuses the retained resident scene. It must not run PView, portal +traversal, or per-object CPU visibility classification once per cascade. +Initially, each cascade draws the bounded resident caster set through the +existing batched/MDI ownership. If that is too expensive, the next permitted +step is GPU culling—not repeated CPU culling or per-object submissions. + +## Pack API surface sketch + +The public declarations belong in the BCL-only +`AcDream.Plugin.Abstractions` assembly. The graphical App supplies the +implementation and translates the declarations to the Vulkan RHI. Headless +hosts expose no render-pack registry and never load pack assets. + +This is an API shape, not code committed by this design: + +```csharp +public interface IRenderPackPlugin +{ + void Register(IRenderPackRegistry registry); +} + +public interface IRenderPackRegistry +{ + IDisposable Register(RenderPackDescriptor descriptor, IRenderPackAssets assets); +} + +public interface IRenderPackAssets +{ + Stream OpenRead(string assetKey); +} + +public sealed record RenderPackDescriptor( + string Id, + string DisplayName, + Version PackVersion, + int PackApiVersion, + RenderPackTier HighestTier, + IReadOnlyList RequiredCapabilities, + IReadOnlyList OptionalCapabilities, + IReadOnlyList Resources, + IReadOnlyList Passes, + IReadOnlyList SceneReplays, + IReadOnlyList PipelineVariants, + IReadOnlyList QualityPresets, + IReadOnlyList Settings, + AtmospherePolicyDeclaration? AtmospherePolicy); +``` + +A pack declares: + +- a stable ID, display name, pack version, and pack-API version; +- its highest tier and a human-readable feature summary; +- mandatory and optional GPU capabilities and per-preset limits; +- shader assets and fixed renderer semantic inputs, including world colour, + scene depth, optional normals, selected celestial shadow direction/energy, + sun direction/screen position for sun-only effects, active weather, camera + matrices, shadow-caster transforms, and frame time; +- intermediate images/buffers by relative or absolute extent, format class, + usage, lifetime, and estimated bytes; +- passes at renderer-owned hooks such as `ShadowDepthBeforeWorld`, + `AtmosphereBeforeToneMap`, `ToneMap`, and + `AfterToneMapBeforePrivateViewports`; +- renderer-owned scene replays such as `OutdoorDirectionalShadowCasters`, with + requested cascade views and existing caster/material classes + (`Terrain`, `OpaqueWorld`, `AlphaCutoutWorld`, `AnimatedOpaque`, and + `AnimatedAlphaCutout`); the renderer resolves those classes from its + retained scene and records their existing batched draws; +- fixed pipeline variants for shadow-caster depth and main-world shadow + receivers. A variant names its base semantic (`Terrain`, `WorldMesh`, or + `EnvCell`), shader asset, compatible material classes, and declared inputs + such as cascade matrices, directional depth maps, and sampler state; it does + not replace visibility, batching, mesh ownership, or draw submission code; +- quality presets, user-visible settings with bounded ranges, and declared + incremental GPU/VRAM budgets; and +- an atmosphere policy: explicit directional-source/sun-elevation response + curves and a mapping from AC's categorical `activeDayGroup` values to effect + multipliers. These values live in the visible pack declaration, not as hidden + renderer constants; AC remains the owner of celestial position, directional + energy, and weather state. + +The renderer—not the pack—defines descriptor layouts, validates SPIR-V and +resource declarations, resolves semantic scene-replay and pipeline-variant +requests, builds pipelines, schedules barriers, owns frame-flight and teardown, +and supplies immutable frame inputs. Packs cannot add arbitrary draw callbacks, +read gameplay owners, submit command buffers, retain borrowed frame views, or +address resources outside their registration. The built-in pack's Tier-2 +caster pass and receiver shaders must be expressible entirely through these +same public declarations. + +### Selection and fail-safe lifecycle + +1. Discover manifests and descriptors without creating GPU objects. +2. Show compatibility and estimated cost in Display settings. Unsupported + packs remain visible with the exact missing capability; they cannot be + selected. +3. On explicit selection, validate the whole descriptor, all assets, resource + ceilings, hooks, and shader interfaces; then build a complete candidate + pipeline set off to the side. +4. Atomically activate the candidate only after every required object exists. + Until then acdream's default path continues rendering. +5. Persist `pack id + pack version + preset`, never a positional index. If the + pack disappears or becomes incompatible, select `acdream default` and + retain the failure notice. +6. On runtime validation or post-recreation candidate failure, withdraw all + pack passes/resources at a frame boundary and resume acdream's default + renderer. Do not repeatedly retry a failing pack during the session. +7. Unload and reconnect use the normal render-generation and GPU-flight + retirement rules. No pack object may retain a world generation, scene + entity, or collectible plugin load context. + +In this campaign, **device recreation** means disposing the complete old +renderer, Vulkan context, and device, then constructing a fresh context/device, +re-probing capabilities, and validating selection again with retail active +until the candidate is complete. It does **not** mean live, in-process recovery +from `VK_ERROR_DEVICE_LOST`; device loss remains terminal to that renderer and +device lifetime. + +The built-in Atmospheric Rendering pack should be the first consumer of this +same API. It must not receive private renderer shortcuts that third-party packs +cannot express. + +The public v1 authoring surface, manifest schema, shader semantic bindings, +failure guidance, validator command, and external no-op sample are indexed by +the [render-pack SDK](../render-packs/README.md). + +## Frame-graph placement + +With the pack off, the frozen retail graph is unchanged. With a pack selected, +the renderer builds a separate enhancement graph: + +1. Update the existing immutable world frame, including authored sky objects, + the retail directional colour/energy channel, and weather. +2. Outdoors, select the visible above-horizon sun/dominant moon/secondary moon + direction and render Tier-2 cascaded shadow depth from the resident caster + set. +3. Render the main world to the pack's world-colour intermediate, using + pack-selected pipeline variants to sample the shadow map where requested. +4. Preserve the established PView, punch/seal depth discipline, shared-alpha + ordering, particle ordering, and world transparency boundaries. +5. Generate screen-space sun occlusion/rays or, only while the shadow source is + the sun, shadow-map volumetrics. +6. Composite rays/shafts **before tonemapping**, so bloom sees them and the + filmic curve rolls them off instead of clipping them. +7. Apply tonemap, colour grade, and vignette to the main world image. +8. Continue with private portal/paperdoll/appraisal viewports and retained UI + on their existing path. They are not accidentally post-processed with the + main world. + +## Delivery slices and acceptance + +The slice labels below are local to this document. They are not phase IDs. + +### Pre-moon checkpoint, Stage 1 acceptance, and Stage 2 start — 2026-08-22 + +Before the approved moon extension, all seven local slices (0–6) had production +implementations in the current worktree. A source-identical isolated clean +snapshot closed that sun-only reference-GPU physical matrix, and one physical +integrated-AMD Auto safe-fallback row was also present. Those artifacts remain +valid evidence for the exact binaries and sun-only scope they measured; they +are not moon-alignment, source-transition, current-worktree, or final user- +acceptance evidence. + +Stage 1's automated implementation and validation are complete. The authored +sun/dominant-moon/secondary-moon resolver and its direction-versus-energy +handoff are present and covered without inferring physical quality from unit +tests, screenshots, or historical sun-only rows. The subsequent live ACE round +covered the owner-reported shadow visibility/configuration, temporal +pixelation/shimmer, frame-pacing/desktop responsiveness, selection/fullscreen +regressions, and matched indoor/outdoor exposure. After the exposure correction +the owner accepted the live result. The exact evidence and limits are recorded +in the [Stage 1 live-gate report](../research/2026-08-22-atmospheric-stage1-live-gate.md). + +Current authored-celestial Stage 1 automated gate (2026-08-22): + +- The shader compiler reports **24/24** Vulkan shader pairs ready. Incremental + regeneration expands only pack includes and preserves all 18 pre-campaign + retail SPIR-V artifacts byte-for-byte; the exact SHA-256 oracle and complete + source-manifest checks pass. All selected-celestial binding-6 modules expose + the 336-byte ABI v1 layout, including source kind at offset 320. +- Focused Release validation passes **344/344** App renderer tests, **30/30** + standalone SDK/pack-validator tests, **14/14** Core sky-loader tests, and + **48/48** MossTank tests. Both external Tier-2 samples embed and validate the + current selected-celestial shader ABI without App or Vulkan dependencies. +- The repository's forced locked restore passes. The complete Release solution, + including all source, tests, tools, and SDK samples, builds with **0 warnings + and 0 errors** after that restore. +- The repository-owned fresh-process hermetic gate passes + **14,928/14,928** tests with zero skips or failures across 14 assemblies; + `AcDream.App.Tests` contributes **5,823/5,823**. Evidence is under + `artifacts/atmospheric-rendering/stage1-moon-release-gate/`. +- The App total includes the 9,500-caster 256-frame zero-managed-allocation + steady-state fixture, warmed CPU/GPU sampling allocation gates, the complete + 12-cycle Low/Medium/High/retail/resize/failure/recovery/frame-flight/ + generation convergence fixture, and independent renderer/context/device + recreation. These prove the non-physical performance and lifetime contracts; + they do not claim physical frame pacing or image quality. +- A final path audit finds no source changes under `src/AcDream.Runtime`, no + physics or collision changes, and no changes to the retail GLSL sources or + tracked retail SPIR-V binaries. Pack-off production integration remains the + strict authoritative-path oracle. + +The command-level record and evidence boundary are in the +[Stage 1 automated gate report](../research/2026-08-22-atmospheric-stage1-automated-gate.md). + +Recorded pre-moon automated checkpoint (not a current moon-scope completion +claim): + +- The repository-owned fresh-process Release test stage passes + **14,880/14,880** tests with zero skips or failures across 14 assemblies. + `AcDream.App.Tests` contributes **5,783/5,783**; campaign-focused App cases + cover descriptor/asset/SPIR-V + validation, pack-off/no-op invariants, atomic asynchronous candidate swaps, + runtime fallback, declared settings, Tier-1/Tier-2/Tier-2+ graph execution, + all headline caster classes, topology caching, exact animated transforms, + Low/Medium/High/Auto policy, diagnostics, and the pack UI. +- Headless plugin-session tests pass **6/6**, including rejection of a + render-pack-only request before its DLL is loaded. +- The SDK validator suite passes **26/26** and builds/validates the external + `AcDream.RenderPacks.NoOp`, `AcDream.RenderPacks.AtmosphericTier2`, and + `AcDream.RenderPacks.ShadowsOnlyTier2` samples without App or Vulkan + references. +- The production catalog is revisioned rather than frozen at startup. The same + composed controller/UI observes external registration, withdrawal, and + corrected re-registration; an active withdrawn pack retires at the next + frame boundary, persists retail fallback, releases its asset/context owners, + and does not retry the removed registration. Runtime package admission now + matches the SDK: exactly one public constructible render-pack entry point and + at least one live registration, with transactional rollback for malformed, + multiple, internal, zero-registration, and partially failing packages. +- The retained 9,500-caster warmed-frame fixture performs no second-frame + scene-index copy, topology rebuild, sort, or classification and allocates + zero managed bytes. Animated-static, live-dynamic, and equipped-child root + and part transforms refresh through cached IDs/slots with exact float bits. +- Render and screenshot diagnostics now publish exact accepted counts for + terrain commands, outdoor statics, buildings, animated statics, local + players, remote players, non-player creatures, other live dynamics, and + equipped children. These labels stop at the authoritative evidence boundary: + static DAT publication does not distinguish a tree from other outdoor + scenery, and create-object render metadata does not distinguish a hostile + monster from a non-hostile NPC creature. Diagnostics therefore report + `OutdoorStatics` and `NonPlayerCreatures`; they never infer tree or monster + identity from a mesh or ID. +- The recording-RHI long-cycle gate repeatedly crosses Low, Medium, High, and + retail selection; resize; injected candidate failure and explicit recovery; + both frame-flight slots; render-generation replacement; and final renderer + disposal. Pack resources, pipeline-format leases, texture slots, retained + transforms, receiver candidates, and registrations converge exactly. A + second fixture proves that device recreation is old-renderer/context/device + teardown followed by an independent fresh device and activation generation. +- Therefore the deterministic lifecycle implementation, recording-RHI + convergence, and fresh-device recreation definition are locally closed. The + executable connected route and its contract assertions are implemented for + select/disable/re-enable, exact resize, authored time/weather changes, and + fresh-process recreation, but a contract-tested route is not connected-world + evidence; its ACE-backed execution and artifacts remain open below. +- The complete Release solution, including all three SDK samples, the + validator, shader compiler/generated manifest, and repository tools, builds + with **0 warnings and 0 errors**. The current managed workspace could not + repeat the gate's locked-restore stage because it denies NuGet access to the + user-profile `NuGet.Config`; the explicit no-restore build and complete test + stage above are current, while locked-restore verification remains a + closeout-environment requirement rather than being reported as green here. + +The corresponding durable source/test entry points are: + +- public contracts and SDK: + `src/AcDream.Plugin.Abstractions/Rendering/`, `docs/render-packs/`, + `tools/RenderPackValidator/`, `samples/AcDream.RenderPacks.*`, and + `tests/AcDream.RenderPackValidator.Tests/`; +- activation, compatibility, Auto, diagnostics, and built-in graph: + `src/AcDream.App/Rendering/Packs/` and + `tests/AcDream.App.Tests/Rendering/Packs/`; +- moving authored-celestial cascades, casters, receivers, and retained topology: + `src/AcDream.App/Rendering/DirectionalShadow*.cs`, + `src/AcDream.App/Rendering/Packs/AuthoredCelestialShadowSource.cs`, + `src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs`, + `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs`, and + `tests/AcDream.App.Tests/Rendering/DirectionalShadow*` plus + `tests/AcDream.App.Tests/Rendering/Packs/AuthoredCelestialShadowSourceResolverTests.cs`; +- retained Display UI and headless boundary: + `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs`, + `tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs`, and + `tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs`. + +Machine-local offline/physical evidence currently present under +`artifacts/atmospheric-rendering/` is gate evidence, but it is not a substitute +for connected-world or project-owner acceptance: + +- `smoke-retail-720p/` records the pack-off `retail/off` path with zero pack + resources, casters, cascades, or classification calls; +- `accept-shadow-morning-200m/`, `accept-shadow-afternoon-200m/`, and + `accept-shadow-morning-close/` contain fixed-camera moving-sun shadow + screenshots plus metadata for 9,498 resident casters and three cascades; + these pre-moon captures do not prove moon alignment or source transitions; +- `volumetric-valid-camera-500m/` contains a High-preset volumetric diagnostic + capture; it is not a performance acceptance row; +- `matrix-clean-snapshot-dense-linear-v20/` is the current complete AMD Radeon + RX 9070 XT physical matrix: **30/30 rows pass** across + retail/Low/Medium/High/Auto, 1080p/1440p/4K, and capped/uncapped pacing. The + actual worktree's 4,397 source files were copied and hash-verified into an + isolated clean snapshot at commit `4876c970`; the Release App product version + names that exact commit, source and binary identities match, and tracked + status is empty. Its 18 active enhanced rows each own exact 2,048-sample + CPU/receiver/GPU windows, 9,498 casters, and zero warmed classification calls. + Six rows are retail and six 4K Low/Medium/Auto rows are accepted + resource-unavailable fail-safe outcomes; +- `matrix-final-v16-exact-auto/` and the two `current-low-1080p-*-v20/` + captures retain the optimization's predecessor/reference trail; the clean + snapshot matrix above supersedes them as current reference-adapter evidence; +- all six retail rows record zero pack resources, passes, casters, cascades, + draws, or dispatches. The six unavailable 4K Low/Medium/Auto rows likewise + record zero pack work and pass their strict paired-default framebuffer + comparisons instead of rendering a half-enabled graph; +- `igpu-auto-safe-fallback-v7-paired/` records physical Auto behavior on the + integrated **AMD Radeon(TM) Graphics** adapter (Vulkan 1.4.315, driver + 2.0.353). After 180 stable Low samples, Auto failed safe with the exact reason + `GPU p99 19.308 ms (budget 3.000 ms), CPU p99 0.534 ms (budget 0.500 ms), + resident GPU bytes 41648404 (budget 67108864)`. The published state is + `retail/off`, has zero pack resources or work, and retains that visible + reason. Its comparison against the paired time-matched retail artifact + `igpu-retail-current-v6-time-matched/`, using `sky-mask.png`, differs in only + **56 / 1,536,000 compared pixels**, a **0.003645833% (0.00365%) sky-masked + pixel difference**, below the 0.1% gate. This closes physical weak-adapter + safe fallback, not active Low performance on that adapter or the remaining + GPU matrix; +- `compare-retained-transform.json` records the retained-transform image + comparison used by the 9,500-caster steady-state gate. + +Stage 1 project-owner gate: + +- **PASS — accepted by the project owner on 2026-08-22.** The acceptance closes + Stage 1's physical-display and desktop-performance stop. It authorizes Stage + 2; it is not a substitute for Stage 2's connected scenario, long-lifetime, + package lifecycle, additional physical-GPU, or final pack-off/pack-on rows. + +Stage 2 and closeout gates, now active: + +1. Execute the already-implemented connected graphical route and complete its + remaining matrix: moving local/remote players, known monster encounters + reported under the authoritative `NonPlayerCreatures` category, and equipped + children; landblock publication/demotion; clear, overcast and rain; + outdoor/interior/dungeon transitions; portal travel and reconnect. Capture + the implemented select/disable/re-enable, resize, authored sun/moon source + transitions and weather, and + fresh-process renderer/context/device-recreation assertions against a real + ACE session, proving exact resource convergence and no stale maps/owners. + The local recording/device-recreation semantics are closed; this connected + execution and its user-visible evidence are not. +2. Repeat the now-current clean-source RX 9070 XT + pack-off/Low/Medium/High/Auto × 1080p/1440p/4K × capped/uncapped matrix on + every other supported physical GPU class. The integrated-AMD Auto-to-retail + artifact proves weak-adapter safe fallback only; it does not prove active Low + or the complete matrix on that adapter. Automated weak-GPU fixtures, one + fallback row, and one high-end reference adapter do not prove the remaining + physical rows. +3. Complete the visual matrix for Tier-1 neutral values and private-view/UI + isolation; rays at dawn/noon/dusk, behind-camera and occluded states; + foliage cutouts; moving animated shadows under sun and moon; indoor gating; + source-transition continuity; temporal pixelation/shimmer; bias/cascade seam + review; sun-only volumetric weather/occluder behavior; and pack-off + restoration. +4. Exercise external install/select/update/remove/fail/recover flows in the + graphical host, then complete the long lifetime run. Automated SDK and + lifecycle fixtures do not replace this connected evidence. +5. Obtain explicit project-owner acceptance of the final pack-off and pack-on + visual/performance matrix before changing this document to shipped. + +### Slice 0 — Contract, capability probe, and acdream-default no-op + +**Implementation:** complete. The BCL-only v1 ABI, live revisioned plugin +discovery/catalog, retained Display selection, compatibility/cost summaries, +strict SDK-equivalent entry admission, asynchronous candidate preparation, +frame-boundary activation/withdrawal/fallback, registration-scoped no-retry, +stable diagnostics, no-op sample, and headless exclusion are present and +automated. The checked-in default-path oracle and RX 9070 XT physical pack-off +rows pass; connected lifetime evidence and the remaining supported physical GPU +classes stay open. + +Define the versioned BCL-only descriptor/registry, manifest fields, semantic +bindings, pack discovery, Display selection, diagnostics, and atomic +activation/fallback transaction. Implement a no-op conformance pack only. + +**Acceptance:** `acdream default (retail-faithful)` remains selected on clean and upgraded +installs; pack discovery allocates no GPU resources; the disabled run has the +same pass list, pipeline set, draw/dispatch counts, deterministic framebuffer +digests, and resource ledger as the pre-campaign baseline; malformed, +unsupported, missing, and shader-invalid fixtures all report one precise +reason and render acdream's default path without partial resources or retry loops; headless +hosts load no render assemblies or pack assets. + +### Slice 1 — Tier-1 world-colour and filmic stack + +**Implementation:** complete. The pack-owned main-world target, bloom chain, +ACES filmic pass, colour grade, vignette, declared neutral settings, resize +recreation, and private-viewport/UI placement are implemented and automated. +The RX 9070 XT 1080p/1440p/4K physical budget rows pass. Project-owner visual +acceptance and the remaining supported physical GPU classes stay open. + +Add the main-world intermediate and implement bloom, ACES filmic tonemapping, +colour grade, and vignette through the pack API. Supply half/quarter-resolution +variants and preserve private viewports/UI. + +**Acceptance:** every effect can be independently set to its neutral value; +the preset is deterministic across resize/recreate; UI, paperdoll, portal, and +appraisal surfaces retain their accepted colours; 1080p/1440p/4K captures show +no clipping, haloing at the world/UI edge, stale frame, or resource leak; the +slice meets its preset GPU/VRAM budget. + +### Slice 2 — Tier-1 screen-space sun rays + +**Implementation:** complete. Authored sun projection, the screen-space +occlusion mask, declared sun/day-group/weather policy, pre-tonemap ray +composition, and deterministic disabled gates are implemented and automated. +The connected dawn/noon/dusk, weather, behind-camera, occlusion, and edge- +flicker visual matrix remains open. + +Project the existing authored sun position, build a screen-space occlusion +mask, and composite weather-driven crepuscular rays before tonemapping. This +slice deliberately has no shadow-map dependency. + +**Acceptance:** clear dawn/dusk produces visible raking rays, noon makes them +vanish, overcast/rain mutes them, the sun behind the camera or fully occluded +produces none, and camera edges do not streak or flicker. The pack descriptor's +sun-elevation and `activeDayGroup` policy deterministically produces those +states without a second weather/clock owner. The exact same scene with Tier 1 +disabled returns to the Slice-0 digest from acdream's default +retail-faithful renderer. + +### Slice 3 — Tier-2 moving authored sun-and-moon dynamic shadows + +**Implementation:** Stage 1 automated and project-owner live gates complete. +Camera-relative stabilized cascades, +outdoor gating, opaque and alpha-cutout casters, terrain/world receivers, +headline caster membership, exact current animated transforms, bounded +resident replay, GPU-flight ownership, and cached topology/dynamic-transform +refresh were present at the sun-only checkpoint. The approved authored +sun/dominant-moon/secondary-moon resolver and direction-versus-energy handoff +are now present, and focused plus complete fresh-process automated validation +pass. The project owner's 2026-08-22 live round accepted source alignment, +temporal stability, desktop responsiveness, and the final exposure correction. +Per-class diagnostics cover +terrain commands, outdoor +statics, buildings, animated statics, local/remote players, non-player +creatures, other live dynamics, and equipped children without inventing tree +or hostile-monster identity. Fixed-camera morning/afternoon artifacts are not +moon evidence; the live-gate result and its exact boundary are recorded in the +[Stage 1 live-gate report](../research/2026-08-22-atmospheric-stage1-live-gate.md). +Stage 2 connected and closeout acceptance is active. + +This is the campaign's headline slice. Add camera-relative cascades, opaque +and alpha-cutout caster variants, animated SSBO transforms, shadow receivers, +texel stabilization, outdoor gating, and weather/authored-directional-energy +control. Select the exact rendered direction of the visible above-horizon sun, +dominant haloed moon, or secondary moon according to the +[celestial source contract](../research/2026-08-22-dereth-celestial-shadow-sources.md), +while retaining retail's one `DirColor * DirBright` energy channel. Trees, +monsters, players, houses/buildings, terrain, and ordinary outdoor statics +participate through existing scene ownership. + +**Acceptance:** in fixed-camera and live dawn/noon/dusk/night captures, shadows +align with and change direction/length under Dereth's authored sun and selected +dominant/secondary moon; overlap and no-source transitions are stable and do +not snap to an unrelated body. Walking players and monsters cast and +self-shadow from their current animated poses; foliage casts leaf/branch +cutouts rather than rectangles; houses and procedural trees retain shadows +through landblock publication/demotion without popping outside the chosen +cascade transition tolerance; indoor/dungeon captures have no outdoor +celestial directional shadow; portal/reconnect/device recreation leaves zero +stale maps or owners. Acne, Peter-panning, cascade seams, distant depth-bias +leaks, temporal pixelation/shimmer, and desktop performance pass the live user +gate and subsequent Stage 2 matrix; the dense-Arwic CPU submission and GPU +budgets pass. + +### Slice 4 — Quality scaling and automatic compatibility + +**Implementation:** complete. Low/Medium/High declarations, capability and +memory admission, preset cost summaries, retained Display controls, the +built-in Automatic checkbox, hysteretic Auto, diagnostics, asynchronous +off-side candidate preparation, and atomic stable-boundary swaps are present +and automated. The source-identical clean-snapshot RX 9070 XT matrix passes all +30 current rows after the Low dense-pose CPU optimization. The integrated-AMD +physical row proves that persistently over-budget Low returns Auto atomically to +retail with a visible reason and a paired-retail framebuffer match. Active Low +on that adapter and additional supported-adapter matrices remain open. + +Land the Low/Medium/High presets, memory ceilings, capability-based preset +availability, stable cascade fitting, resize handling, and optional hysteretic +Auto selection. Auto may change resolution/range only at a stable frame +boundary and must expose its current choice. + +**Acceptance:** every supported preset retains all headline caster classes; +weak-hardware fixtures select a valid lower preset or fail safely to acdream's +default renderer; +changing preset cannot leak, stall the render thread, invalidate streaming, or +leave mixed-resolution resources; the quality/performance table is populated +with measured physical-hardware results. + +### Slice 5 — Tier-2+ volumetric shafts + +**Implementation:** complete for the recorded sun-only scope. The declared +volumetric pass reuses directional-shadow depth only when the selected source +is the authored sun, consumes authored sun/weather/indoor inputs, composites +before tonemapping, and has independent quality/step settings and automated +failure gates. The reference-GPU low-sun 2,048-sample enabled/neutral A/B passes +its incremental cost target. Moon selection does not enable moon shafts. The +connected occluder/weather visual matrix and additional physical GPU classes +remain open. + +Reuse the directional shadow map for world-space light shafts only while its +source is the authored sun. Drive density, strength, and colour from authored +sun/weather inputs and composite before tonemapping. A selected moon produces +directional shadows but no rays or volumetric shafts. + +**Acceptance:** shafts respect terrain, trees, houses, and moving creatures; +clear low sun is strongest, overcast and indoor scenes are muted/off; disabling +shafts leaves Tier-2 shadow output unchanged; the incremental cost stays within +the Tier-2+ budget. + +### Slice 6 — Pack SDK and campaign closeout + +**Implementation:** SDK deliverables complete and the Stage 1 project-owner +gate passed on 2026-08-22. The v1 +manifest schema, semantic binding table, compatibility/failure guide, +validator, built-in pack, and three buildable external samples are present and +automated. Connected graphical package lifecycle, long-run convergence, the +remaining physical GPU classes, and project-owner acceptance remain open; the +30-row RX 9070 XT reference matrix is complete. + +Publish the manifest/schema, semantic binding table, sample no-op pack, +Atmospheric pack, compatibility diagnostics, authoring/validation tool, and +failure-handling guidance. Run the full automated, connected, physical-display, +performance, lifetime, portal, and screenshot matrix. + +**Acceptance:** a clean external sample builds without App or Vulkan +references; install/select/update/remove/fail/recover flows work; pack-off +evidence from acdream's default retail-faithful renderer remains authoritative +and unchanged; all resource ledgers +converge after long play, reconnect, portal travel, pack disable, and device +recreation; the project owner accepts the visual matrix before the campaign is +declared shipped. + +## Performance budget and measurement + +The [measured pre-campaign baseline](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md#performance-baseline-and-the-binding-constraint) +is **519.7 FPS with CPU/GPU p50 of 1.869/1.096 ms**, and dense towns are +CPU-submission-bound. Fullscreen work may occupy currently idle GPU time, but +it is not treated as free. Shadow cascades must protect the CPU submission +path. + +| Preset | Incremental GPU p50 / p99 at 1080p | Incremental render-CPU p50 / p99 | Pack-owned resident GPU memory | +|---|---:|---:|---:| +| Low | ≤ 2.0 / 3.0 ms | ≤ 0.15 / 0.50 ms | ≤ 64 MiB | +| Medium | ≤ 3.25 / 4.50 ms | ≤ 0.25 / 0.75 ms | ≤ 128 MiB | +| High | ≤ 4.50 / 6.00 ms | ≤ 0.35 / 1.00 ms | ≤ 256 MiB | + +The current physical reference matrix is +`artifacts/atmospheric-rendering/matrix-clean-snapshot-dense-linear-v20/` on an +AMD Radeon RX 9070 XT (Vulkan 1.4.349, driver 2.0.395). The source-identical +isolated commit and Release binary both identify `4876c970`; tracked source +status is empty. All 30 rows pass. At capped 1080p, the exact 2,048-sample +windows are: + +| Selection | Incremental render-CPU p50 / p99 | Inclusive GPU p50 / p99 | Resident pack memory | +|---|---:|---:|---:| +| Low | 0.108 / 0.164 ms | 0.900 / 1.002 ms | 39.719 MiB | +| Medium | 0.116 / 0.144 ms | 1.001 / 1.023 ms | 73.590 MiB | +| High | 0.121 / 0.145 ms | 1.240 / 1.273 ms | 113.556 MiB | +| Auto (settled High) | 0.122 / 0.146 ms | 1.211 / 1.222 ms | 113.556 MiB | + +Every uncapped active row also passes. Across capped and uncapped active rows, +the maximum measured p50/p99 and resident memory are: Low 0.108/0.164 ms CPU, +1.070/1.080 ms GPU, 60.868 MiB; Medium 0.117/0.194 ms CPU, 1.152/1.163 ms GPU, +103.583 MiB; High 0.121/0.159 ms CPU, 2.237/2.252 ms GPU, 238.141 MiB; and Auto +0.122/0.166 ms CPU, 1.482/1.494 ms GPU, 145.856 MiB. All six retail rows record +zero pack resources/work. At 4K, Low needs 131,302,400 bytes and Medium/initial +Auto need 198,440,960 bytes, so those six capped/uncapped rows correctly report +`ResourceUnavailable`, create no pack resources/work, and pass their strict +paired-default framebuffer comparisons. This closes the current reference +adapter, not the connected receiver A/B route or other supported/weak physical +GPU classes. + +Tier 2+'s separate low-sun physical A/B is recorded in +`artifacts/atmospheric-rendering/volumetric-performance-ab-1080p.json`. Both +runs pin High, 1080p uncapped, clear weather, 16.667° sun elevation, the same +50 m / 180° / 10° camera, 9,498 casters, four cascades, and exact 2,048-sample +windows. Enabling one volumetric draw over the neutral-strength run adds +**0.189 ms GPU p50 / 0.219 ms p99**, **0.009 ms CPU p50 / 0.007 ms p99**, and +4,147,200 resident bytes. The measured GPU p50 passes the Tier-2+ ≤0.40 ms +reference target; connected weather/occluder behavior and other adapters still +require their own rows. + +acdream's default path has a stricter gate: zero new enhancement passes, +images, buffers, submissions, or shader variants, with CPU/GPU deltas within +the existing run-to-run noise envelope and deterministic reference captures +unchanged. `NoOpRenderPackProductionIntegrationTests` pins the pre-campaign +pass list, pipeline set, draw/dispatch tuple, framebuffer SHA-256, and complete +resource ledger; the matrix's six physical retail rows independently record +zero pack work at all three resolutions and both pacing modes. + +Measurement protocol: + +- Use existing asynchronous GPU timestamps and frame diagnostics. Never add a + `glFinish`/device-idle-style measurement fence to the frame loop. +- Runtime Auto compares the declared incremental CPU budget with pack-added + target-preparation, shadow, post, and volumetric recording only. The complete + enhanced main-world receiver recording is retained separately as an absolute + CPU diagnostic; it is not itself an incremental delta. GPU accounting remains + conservatively inclusive of the complete enhanced receiver pass and every + resolved pack pass, exactly once after asynchronous resolution. Identical + pack-off/on runs remain the authority for the final receiver CPU delta and + the complete physical incremental A/B result. +- Run capped and uncapped Release builds; record CPU/GPU p50, p95, and p99, + FPS, draw/dispatch submissions, shadow-caster count, cascade draw count, + transient/retained GPU bytes, and process working/private memory. +- Compare pack off, Low, Medium, and High with identical camera paths, render + resolution, active day group, authored celestial/time keyframe, entity set, + and warmed residency. +- Cover pinned dense Arwic, a foliage-heavy outdoor route, a building cluster, + moving-player/monster combat, dawn/noon/dusk/night plus sun/moon/no-source + transitions, clear/overcast/rain, a dungeon, portal travel, resize, reconnect, + and a long lifetime run. +- Measure 1920x1080, 2560x1440, and 3840x2160 on each supported physical GPU + class. Report—not hide—unavailable presets. +- No cascade may rerun CPU PView/portal traversal or issue per-object draws. + The pass records CPU classification calls and submission counts so this is + an enforced gate, not an architectural hope. +- Pipeline creation and pack validation occur before atomic activation. Normal + play may not hitch on first shadow, weather, caster, or quality use. + +## Quality scaling for weak hardware + +These are starting envelopes to validate, not asset or world guarantees. +Distances are metres and are always clamped to current resident world data. + +| Setting | Bloom/rays | Directional shadows | Volumetric shafts | Approx. depth-map memory at 32-bit depth | +|---|---|---|---|---:| +| Off / acdream default | Off | Off | Off | 0 MiB | +| Low | Quarter resolution | 2 × 768² cascades, about 72 m maximum reach | Off by default | 4.5 MiB | +| Medium | Half resolution | 3 × 1536² cascades, about 144 m maximum reach | Quarter resolution | 27 MiB | +| High | Half/full as measured | 4 × 2048² cascades, about 240 m maximum reach | Half resolution | 64 MiB | + +Additional scaling rules: + +- Prefer reducing cascade count, shadow resolution, reach, bloom/ray + resolution, and sample count before removing a feature's semantic + correctness. +- Preserve alpha-tested foliage and animated transforms at every shadow + quality. A cheaper preset may look softer or end sooner; it may not turn a + tree into a rectangle or freeze a monster's shadow. +- Clamp resource dimensions and bytes before allocation. A capability probe + that cannot support Low disables the pack and explains why. +- Preset availability uses the selected Vulkan adapter's probed 2-D image and + array-layer limits. Optional pack memory receives at most one eighth of its + device-local heap, capped at 256 MiB resident and 512 MiB transient; Auto + starts at Low when Medium is unavailable and acdream's default remains the fallback if + Low cannot fit. +- Optional Auto quality uses long hysteresis and stable frame-boundary swaps; + it never oscillates cascade layouts frame to frame. If Low stays over its + declared runtime GPU/CPU/resident budgets for 180 stable samples, Auto + retires the complete pack and returns to acdream's default with the measured + and declared limits in the visible failure reason. +- 4K defaults may choose lower post-process resolution because Tier 1 pays + approximately four times the 1080p pixel workload. + +## Constraints and traps + +This list carries forward every item in the findings' measured +[shadow-specific constraints](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md#shadow-specific-constraints) +and adds the current renderer's ownership and lifecycle boundaries. + +1. **The renderer is the shipped pass-based Vulkan RHI.** Design against + `IGpuDevice` / `IGpuFrame` / `IGpuPassEncoder` and explicit pass/pipeline + descriptions. Do not revive an OpenGL backend or build a parallel renderer. +2. **The current PView graph is authoritative.** Shadow and atmosphere passes + consume its retained scene; they do not introduce a competing visibility + owner or change punch/seal, shared-alpha, particle, or private-viewport + ordering. +3. **CPU submission is the limiting dimension.** Reusing the full bounded + resident caster set is preferable to CPU-reculling it per cascade. GPU + culling is the only planned escalation. +4. **Alpha-tested foliage needs sampling and discard.** Reusing the existing + empty `portal_depth` fragment shader would cast solid tree rectangles. +5. **Animated casters use the existing N.5 SSBO transforms.** A second pose, + animation tick, or entity owner is forbidden. +6. **Indoors has no outdoor celestial directional shadow.** Dungeon/EnvCell + authored ambient and local lighting wins; outdoor sun/moon directional + shadows, sun rays, and sun shafts are gated off. +7. **Cascades are camera-relative and streaming-bounded.** They may not use a + fixed Dereth-wide extent, request landblocks, retain retired generations, or + draw stale portal destinations. +8. **Depth bias is specified in meaningful eye/world units.** A constant NDC + bias spans approximately `b*d²/near` metres of eye depth at distance and can + recreate issue #129's door-shaped holes through hills. Bias, normal offset, + cascade projection, near/far fitting, and reversed-depth conventions must be + tested together at near and far ranges. +9. **“Shadow” is an overloaded project term.** Existing `shadow_objects` and + `CPhysicsObj::add_shadows_to_cells` are collision registration, not light + shadows. New names use `DirectionalShadowMap`, `ShadowCaster`, or + `CelestialDirectionalShadow`; never generic `ShadowObject`. +10. **Authored celestial position, directional energy, and weather are + inputs.** Do not invent another celestial clock, light-energy channel, + weather state, or hard-coded dawn/noon schedule. The directional map uses + the visible above-horizon sun/dominant moon/secondary moon's exact rendered + direction but retail's single interpolated `DirColor * DirBright` colour/ + energy channel, per the + [celestial source research](../research/2026-08-22-dereth-celestial-shadow-sources.md). + Rays and shafts remain sun-only and use the pack's declared sun-elevation + curve and categorical `activeDayGroup` mapping. The decomp evidence proves + the category reaches the frame, not an authored numeric ray intensity, so + the enhancement mapping must remain explicit pack policy. +11. **Atmosphere ordering is deliberate.** Rays/shafts composite before + tonemapping; retained UI and private viewports remain outside main-world + post-processing. +12. **Transparency remains ordered.** The pack cannot flatten the retail + world-alpha queue into an unordered shadow/post pass. Truly translucent + surfaces cast no opaque shadow until separately designed. +13. **Generation and GPU-flight lifetimes remain exact.** Pack images, + descriptors, and pipelines retire through existing fences and converge on + disable, resize, portal, reconnect, reset, failure, and device recreation. +14. **4K is a distinct performance row.** Tier-1 effects scale with pixels; + passing at 1080p is not evidence for 4K. +15. **Do not repeat closed investigations.** High-res DAT precedence is not + dropping overrides, AC detail textures are colour/alpha rather than normal + maps, and the engine's historical DOT3 capability does not turn those + assets into PBR inputs; these points are already falsified in the findings. +16. **Caster evidence must not exceed source identity.** Diagnostics separately + count terrain commands, outdoor statics, buildings, animated statics, + local/remote players, non-player creatures, other live dynamics, and + equipped children. Outdoor statics include trees but have no authoritative + tree discriminator; non-player creatures include monsters but have no + render-only hostile-monster-versus-NPC discriminator. Visual/connected + acceptance must name those limits instead of fabricating narrower counts. + +## What this does NOT do + +- It does **not** change acdream's default retail-faithful rendering path, its + expected output, or its authority in fidelity tests. +- The Atmospheric shader pack does **not** own or depend on #226 detail + texturing or the terrain-normal parity correction. Those remain separate + Track A ports even though the project owner authorized their implementation + in the same worktree. +- It does **not** add PBR or fabricate normal, roughness, metalness, or material + maps that AC's assets do not contain. +- It does **not** change terrain vertices, collision triangles, walkability, + slope response, physics shadow lists, movement, projectiles, or any Runtime + physics/collision owner. +- It does **not** change gameplay rules/state, network messages or ordering, or + any Runtime gameplay/network owner. +- It does **not** extend view distance, streaming radius, landblock residency, + or PView visibility to find more shadow casters. +- It does **not** add an indoor sun or replace authored EnvCell/local lights. +- It does **not** turn moon texture brightness or mesh luminosity into another + world-light energy channel, and it does not produce moon rays or moon shafts. +- It does **not** post-process retained UI or silently restyle private + paperdoll, appraisal, or portal viewports. +- It does **not** promise that every pack or quality preset runs on unsupported + hardware; compatibility failure is explicit and safely returns to acdream's default renderer. +- Campaign AR does **not** outrank active M4 gameplay work. The project owner's + explicit reprioritization authorizes this campaign without changing M4's + milestone priority; #268 + TS-8 are already complete and retired. + +## Completion gate + +The design, Stage 1 implementation, automated validation, and project-owner +live gate are complete. Stage 2 and closeout are active. The campaign becomes +**shipped** only after the subsequent +connected, full physical-hardware, lifetime, and visual gates above pass and +the project owner accepts both sides of the final matrix: + +- **pack off:** unchanged output, performance, ownership, and lifecycle from + acdream's authoritative default retail-faithful renderer; and +- **pack on:** moving authored sun-and-moon directional shadows from trees, + monsters, players, and buildings; sun-only rays/shafts; scalable atmosphere, + safe compatibility fallback, measured budgets, and clean long-lived resource + convergence. diff --git a/docs/release-gate.md b/docs/release-gate.md index 583ec863..4aba7774 100644 --- a/docs/release-gate.md +++ b/docs/release-gate.md @@ -14,9 +14,9 @@ in a fresh Release process. It does not retry failures. Tests carrying an explicit non-hermetic `Lane` trait (`InstalledDat`, `PreparedPackage`, `Live`, `Manual`, `Timing`, `Windows`, `Linux`, or `SystemFont`), `Purpose=Diagnostic`, or `Status=KnownFailure` are excluded from the hermetic total and run through -their owned lane instead. The graph currently contains 44 projects, -including all 13 maintained .NET tools; data-dependent tools are built but are -not executed as tests. +their owned lane instead. The graph currently contains 54 projects, +including all 17 maintained .NET tools and three render-pack SDK samples; +data-dependent tools and SDK samples are built but are not executed as tests. Build and dependency policy is repository-owned: diff --git a/docs/render-packs/README.md b/docs/render-packs/README.md new file mode 100644 index 00000000..5e9e4263 --- /dev/null +++ b/docs/render-packs/README.md @@ -0,0 +1,227 @@ +# Render-pack SDK v1 + +**Campaign:** Atmospheric Rendering / Shader Packs +**Phase id:** **Campaign AR** +**Contract version:** `RenderPackApi.Current == 1` + +Render packs are opt-in, declarative graphics extensions. acdream's current +retail-faithful renderer is always installed, remains the default and +authoritative comparison path, and is restored as one complete transaction +when a selected pack cannot run. A +pack cannot access Vulkan, renderer internals, gameplay state, world streaming, +or physics. + +The public dependency is only +`AcDream.Plugin.Abstractions`. Do not reference `AcDream.App`, Silk.NET, or a +Vulkan binding. Three buildable external samples cover the API: + +- [`AcDream.RenderPacks.NoOp`](../../samples/AcDream.RenderPacks.NoOp/) is the + smallest discovery and activation conformance pack. +- [`AcDream.RenderPacks.AtmosphericTier2`](../../samples/AcDream.RenderPacks.AtmosphericTier2/) + declares the complete semantic atmospheric executor with deliberately + renamed pack-owned IDs, embeds all referenced SPIR-V, and demonstrates + moving authored sun-and-moon shadows for terrain, trees, buildings, players, and monsters. +- [`AcDream.RenderPacks.ShadowsOnlyTier2`](../../samples/AcDream.RenderPacks.ShadowsOnlyTier2/) + demonstrates that Tier 2 is composable: it requests the same selected-celestial + caster/receiver semantics without Tier-1 post-processing or volumetric + shafts. + +## Quick start + +1. Target `.NET 10` and reference `AcDream.Plugin.Abstractions` with runtime + copy disabled. The acdream host supplies that assembly. +2. Add [`plugin.json`](plugin-manifest-v1.schema.json), include + `"kinds": ["renderPack"]`, and copy it beside the built entry DLL. +3. Expose exactly one public, parameterless `IRenderPackPlugin` entry point. +4. Construct immutable `RenderPackDescriptor` values and register them from + `Register`. Registration must only publish declarations; do not open assets, + compile shaders, start threads, or allocate native/GPU resources. +5. Supply shader bytes lazily through `IRenderPackAssets.OpenRead`. Asset keys + are forward-slash relative logical paths: never rooted, backslash-based, or + `.`/`..` traversals. +6. Build and run the SDK validator: + + ```powershell + dotnet build samples/AcDream.RenderPacks.NoOp/AcDream.RenderPacks.NoOp.csproj -c Release + dotnet run --project tools/RenderPackValidator/AcDream.Tools.RenderPackValidator.csproj -c Release -- samples/AcDream.RenderPacks.NoOp/bin/Release/net10.0 + ``` + + Substitute `AcDream.RenderPacks.AtmosphericTier2` in both paths to validate + the complete Tier 2/Tier 2+ example and its embedded shader interfaces. + +The validator executes the managed registration entry point. Use it only on a +pack you trust. It loads no App, RHI, or Vulkan assembly and creates no GPU +objects. It validates the manifest, v1 declarations, referenced asset keys, +SPIR-V stage/entry point and complete v1 binary interface, managed +registration, and duplicate pack IDs. Hardware and driver compatibility remain +client-side activation checks. + +To launch a visible offline preview of acdream with the built-in Atmospheric +pack and a disposable settings profile: + +```powershell +.\tools\launch-atmospheric-preview.ps1 -Preset High +``` + +The preview starts `AcDream.App` with audio disabled, clears inherited +`ACDREAM_*` live/automation/diagnostic state for that child, and leaves the +user's normal acdream settings untouched. It records the binary identity, +selected audio mode, and separate stdout/stderr logs beside the disposable +profile. To exercise OpenAL explicitly, add `-EnableAudio`. + +Vertex and fragment asset keys are independent opaque keys; they do not need +matching basenames or a host shader-directory stem. On explicit selection the +client opens each declared stream, validates it, copies the bytes into the +isolated candidate, and creates shader modules from those immutable blobs. +The pipeline retains neither the stream nor a path into the plugin directory. +Each stage must be little-endian, word-aligned SPIR-V no larger than 16 MiB. + +Shader-visible pack settings are deliberately capped at 64 declarations. The +public `PackSettings` binding and value encoding are documented in the +[`semantic binding table`](semantic-bindings-v1.md#packsettings-set-3-binding-8-256-bytes). +A persisted user override wins the selected preset override, which wins the +declaration default. Overrides are stored by stable pack ID plus setting ID; +the host validates the selected descriptor's kind, invariant numeric grammar, +range, step, and choice list before supplying the resolved scalars. No renderer +object is exposed to managed code. + +When a pack is selected, the retained Config page appends its declared +Boolean, bounded Float/Integer, and Choice controls under **Graphics +Enhancements**. Changing packs replaces only that optional tail; retail's 39 +authored Config rows remain unchanged. Numeric controls snap to declared bounds +and steps, preset changes retain explicit user overrides, and a pack change +starts with an empty valid override map for the new stable pack identity. + +## Manifest + +The authoritative machine-readable schema is +[`plugin-manifest-v1.schema.json`](plugin-manifest-v1.schema.json). + +| Field | Meaning | +|---|---| +| `id` | Stable lowercase logical plugin ID. It is persisted and must not be localized or reused. | +| `displayName` | User-visible plugin name. | +| `version` | Dotted `System.Version`-compatible package version. | +| `entryDll` | Safe path beneath the plugin directory to the managed entry DLL. | +| `apiVersion` | General `PluginApi` version. v1 is `1`; this is distinct from `RenderPackApi`. | +| `dependencies` | Optional plugin IDs that must load first. | +| `kinds` | Entry-point kinds. Include `renderPack`; omission means legacy `gameplay` only. A hybrid lists both. | + +Install one plugin directory containing this manifest, the entry DLL, its +private managed dependencies, and declared shader assets. Do not redistribute +`AcDream.Plugin.Abstractions.dll` in that directory: type identity is shared +from the host. + +## Declaration schema + +The C# records in `AcDream.Plugin.Abstractions.Rendering` are the public v1 +declaration schema. `RenderPackShaderAbi` publishes the corresponding numeric +SPIR-V set, binding, block-size, and capacity constants. They are intentionally +BCL-only and expose no Vulkan handle. + +| Declaration | What the pack supplies | What the host owns | +|---|---|---| +| `RenderPackDescriptor` | Identity/version, highest tier, capabilities, resources, passes, replays, variants, presets, settings, atmosphere policy | Validation, candidate creation, activation and fallback | +| `RenderResourceDeclaration` | Logical ID, portable format, extent, usage, lifetime, estimated bytes | Images/buffers, allocation, barriers, frame-flight retirement | +| `RenderPassDeclaration` | Fixed hook, shader asset keys, semantic inputs, logical resource reads/writes | Render graph order, descriptor layout, pipeline, command recording | +| `SceneReplayDeclaration` | One supported replay semantic, caster flags, 1–4 views | Resident caster selection and existing batched submissions | +| `PipelineVariantDeclaration` | Base pipeline semantic, shader assets, compatible material flags, inputs | Visibility, mesh/material ownership, fixed renderer state | +| `RenderQualityPreset` | Capability requirements, resource/setting overrides, optional execution hints, and p50/p99 CPU/GPU/VRAM ceilings | Availability, explicit selection and stable-boundary swaps | +| `RenderSettingDeclaration` | Stable ID, kind, default, bounds/choices | Persistence and conditional Display UI | +| `AtmospherePolicyDeclaration` | Ordered sun and selected-light elevation curves plus explicit `activeDayGroup` multipliers | Authored Dereth clock, celestial source, day group, weather and indoor state | + +IDs use `^[a-z][a-z0-9._-]*$`, are case-insensitively unique within each +declaration kind, and remain stable across updates. A pack must declare at +least one quality preset and at most 64 settings. The SDK ceiling is 256 MiB pack-owned resident GPU +memory, 16 MiB per SPIR-V asset, 16,384 pixels per absolute image dimension, +256 image layers, and four scene-replay views. A physical device may expose a +lower ceiling or reject a preset whose mandatory capabilities are absent. +For API v1 the host admits optional pack memory from one eighth of the selected +adapter's probed device-local heaps, capped at 256 MiB resident and 512 MiB +transient multisample storage. Presets remain listed with exact limit reasons. +Auto requires asynchronous timestamps and uses Low when Medium cannot fit. At +runtime, Auto alone watches the selected preset's declared inclusive-GPU p99, +pack-added CPU p99, and resident-GPU budgets. If Low remains over any of those +budgets for 180 stable samples, the whole pack fails safely to acdream's +default renderer with the measured and declared limits in the failure reason. +Explicit Low remains selectable and is not silently disabled by the Auto +performance policy. + +A Tier-2 directional-shadow elevation curve must resolve to exactly zero at +and below the authored 0-degree horizon. Every declared non-positive point +must therefore have multiplier `0`; if the curve omits an exact 0-degree +point, its first positive point must also be `0` so endpoint clamping or +interpolation cannot manufacture a below-horizon directional shadow. The host still +owns the independent no-selected-light-energy and indoor gates. + +The built-in Atmospheric Low preset preserves the complete directional-shadow +caster set (terrain, opaque and alpha-cutout world geometry, and both animated +classes). It reduces cost with two 768 x 768 shadow maps and the ordinary +six-pass, quarter-resolution separable post chain: sun occlusion, sun rays, +bloom downsample, horizontal blur, vertical blur, and filmic composition. It +does not remove a caster class or use the fused post-process hint. + +`FusedAtmosphericPostProcess` is an optional external-pack Low-preset execution +hint for the standard atmospheric graph; it is not built-in Low behavior. An +opting-in shader pack implements the PackPass ABI below: the host feeds scene +depth directly to sun rays and asks filmic to evaluate the declared bloom +extraction and separable filter while composing the final image. This reduces +command recording without disabling rays, bloom, or filmic composition. The +host never infers the hint from pack identity; unknown hints, non-Low use, and +incomplete standard graphs fail validation. + +`MultiviewDirectionalShadowCascades` is a separate explicit Low-preset +execution hint. The opting-in pack must implement three multiview caster +variants. The host records one layered directional-depth +pass with view mask `0b11`; `gl_ViewIndex` selects the exact two declared Low +cascade matrices. Terrain, opaque, and alpha-cutout commands retain their +ordinary pipeline, transform, cull, and cutout semantics. The preset must require +`MultiviewDirectionalShadowCascades`; unsupported hardware makes that Low preset +unavailable before allocation. A zero hint retains ordinary per-cascade passes. + +Resources are declared in execution order: a pass cannot read a pack resource +before an earlier pass writes it, and one pass cannot read and write the same +resource. `WorldColor`, `SceneDepth`, and other renderer semantics are not pack +resources and are named in `SemanticInputs` instead. API v1 exposes four +sampled pass-input slots; buffers, `StructuredData`, and storage resources are +reserved enum values and are rejected until a public binding contract exists. +Colour image arrays are likewise reserved; v1 arrays are directional-depth +maps. One declared pass writes at most one attachment. Only `ToneMap` and +`AfterToneMapBeforePrivateViewports` may write directly to the host surface +without naming a pack resource. + +Every semantic input implies its capability and the descriptor must list that +capability as required: world colour, scene depth/normals, authored sun/selected- +celestial/day/weather facts, animation transforms, and directional maps cannot +be treated as +optional after a pass unconditionally declares them. + +## Lifecycle and versioning + +- Discovery calls `Register` but does not open assets or allocate GPU objects. +- Installing a pack never selects it. The user selects a pack ID, version, and + preset; `acdream default (retail-faithful)` is always available. +- The client validates every declaration and selected asset, builds the full + candidate beside the active retail graph, then swaps at a frame boundary. +- Dispose the registration handle to withdraw the descriptor. The host also + withdraws every handle before unloading its collectible plugin context. +- `PluginApi` versions the general managed plugin ABI. `RenderPackApi` versions + these graphics declarations. Additive enum/record support stays compatible; + a breaking contract requires a new render-pack API version and explicit + compatibility path. +- Persisted identity is pack ID + pack version + preset ID, never list index. + User-authored setting strings are keyed by the same stable pack identity and + stable setting ID, never declaration or menu index. + +"Device recreation" in the v1 SDK means full teardown of the old renderer, +graphics context, and device, followed by construction and capability probing +of a fresh context/device. Retail is authoritative until a fresh pack candidate +validates and activates. The SDK does not promise live recovery of a pack or +renderer after `VK_ERROR_DEVICE_LOST`; that error is terminal to the old device +lifetime. + +The complete campaign contract, budgets, and non-goals remain in +[`2026-08-21-atmospheric-rendering.md`](../plans/2026-08-21-atmospheric-rendering.md). +The public shader-facing contracts are the +[`semantic binding table`](semantic-bindings-v1.md) and +[`compatibility/failure guide`](compatibility-and-failure-v1.md). diff --git a/docs/render-packs/compatibility-and-failure-v1.md b/docs/render-packs/compatibility-and-failure-v1.md new file mode 100644 index 00000000..b4a806d4 --- /dev/null +++ b/docs/render-packs/compatibility-and-failure-v1.md @@ -0,0 +1,134 @@ +# Render-pack compatibility and failure handling v1 + +**Campaign phase id:** **TBD** + +Compatibility is a declaration and activation result, not a promise inferred +from a GPU brand. The client keeps unsupported packs visible with one exact +reason, refuses to select an unavailable preset, and continues rendering the +authoritative acdream default (retail-faithful) path. + +## Author responsibilities + +- Declare every mandatory facility in `RequiredCapabilities`. Use + `OptionalCapabilities` only when the pack has a deterministic path that does + not need it. +- Gate each preset independently. Low must remain semantically correct; lower + shadow resolution or reach rather than silently removing trees, monsters, + players, buildings, alpha cutouts, or animated transforms. +- Keep resource estimates conservative and below the preset and 256 MiB SDK + ceilings. The host clamps dimensions and bytes before allocation. Its + optional-pack memory policy admits at most one eighth of the selected + adapter's probed device-local heap, capped at 256 MiB resident and 512 MiB + transient multisample storage; the lower value wins and is printed in an + unavailable-preset reason. +- Use only declared hooks, semantic inputs, resources, scene replays and base + pipeline variants. Pack code receives no arbitrary per-frame callback, + command buffer, gameplay owner, RHI object, or Vulkan handle. +- Treat registration as pure declaration publication. `OpenRead` must return a + new readable stream for the exact requested key and must not retain a world + generation or borrowed frame state. +- Ship SPIR-V words little-endian, four-byte aligned, no larger than 16 MiB per + asset, and compatible with the published v1 semantic binding ABI. Both the + SDK and client validate the binary stage, `main` entry point, descriptor + allowlist, exact uniform/push layouts, and read-only storage contract before + pipeline creation. Vertex and fragment keys are independent logical keys; + the selected candidate copies their blobs and never resolves them through + the host shader directory. +- Declare no more than 64 settings and keep their descriptor order stable. The + set-3/binding-8 shader mapping is positional: a persisted user override wins + the selected-preset override, which wins the declaration default. Boolean + becomes 0/1, Choice becomes its zero-based choice index, numeric strings use + invariant culture, and unused or defensively invalid slots are zero. The + selected descriptor validates every user string against kind, range, step, + and choices before activation. + +## Client transaction + +1. Discover the manifest and descriptor without opening assets or constructing + GPU objects. +2. Compare required capabilities and preset ceilings with the active physical + device's probed `maxImageDimension2D`, `maxImageArrayLayers`, device-local + heap bytes, and format/timestamp support. An unsupported pack remains + installed and its individual presets remain visible with exact + needed-versus-provided reasons. +3. After explicit selection, validate every referenced asset and shader + interface, then build every resource and pipeline in an isolated candidate. +4. Activate the complete candidate at a stable frame boundary. Until that + point retail keeps rendering. +5. If any step fails, retire the candidate through normal GPU-flight fences, + record one stable diagnostic, select `acdream default`, and do not retry + that pack again during the session. + +No half-enabled graph is valid. A missing bloom shader does not leave shadows +active; a failed shadow pipeline does not leave a world-colour intermediate or +stale descriptor alive. + +Auto is a logical selector rather than an allocated preset. It requires +asynchronous GPU timestamps, starts at Medium when Medium fits, otherwise +starts and stays at Low, and never promotes beyond the highest contiguous +compatible preset. If Low itself cannot fit, Auto fails safely to Retail and +reports the Low limit that failed. + +Runtime Auto decisions use the active preset's declared inclusive-GPU p99, +pack-added CPU p99, and resident-GPU budgets. An over-budget Medium selection +can step down to Low; if Low then remains over any declared limit for 180 +stable samples, the host atomically deactivates the complete pack, reports the +measured and budget values, and enters `FailedToRetail` without a retry loop. +This performance fallback is Auto-only. Explicit Low remains selectable when +only timestamp support is missing and is never silently reduced by removing +terrain, trees, buildings, monsters, players, alpha cutouts, or animated +casters. The built-in Low preset instead uses two 768 x 768 shadow maps and an +unfused six-pass, quarter-resolution separable post chain. An ordinary explicit +Low validation, candidate-build, or runtime failure still follows the complete +transactional fallback rules above. + +## Diagnostic categories + +| Category | Example user-facing reason | Recovery | +|---|---|---| +| Manifest | `plugin.json does not declare the renderPack kind` | Correct/reinstall the package | +| Managed ABI | `apiVersion 2 is unsupported; this SDK supports 1..1` | Use a compatible client or rebuild the pack | +| Pack ABI | `requires render-pack API 2; this client supports 1..1` | Same as above | +| Capability | `requires unsupported capability DirectionalShadowMaps` | Select a supported preset/device or retail | +| Declaration | `Pass 'blur' reads resource 'bloom-a' before it is written` | Correct the descriptor | +| User setting | `user override 'exposure' has invalid Float value '1,5'` | Correct/remove that stable setting-ID override; retail remains active | +| Asset | `asset 'bloom.frag.spv' is not valid SPIR-V` | Rebuild/reinstall the pack | +| Shader interface | `AtmosphericFrame block does not match v1` | Recompile against the v1 binding table | +| Resource ceiling | `preset 'high' exceeds the pack memory ceiling` | Reduce the preset declaration | +| Auto performance | `Low remained over its declared performance budget for 180 stable samples` | Complete pack falls back to Retail; select explicit Low only after reviewing the measured limits | +| Candidate build | `pipeline creation failed for 'directional-shadow-world-cutout'` | Driver/asset diagnosis; retail for this session | +| Runtime/device | `selected pack failed validation on the fresh device` | Retail on the fresh renderer for this session; no retry loop | +| Removal/update | `selected pack is no longer installed` | Retail, while retaining the notice | + +Diagnostics and screenshot metadata record pack ID, pack version, preset ID, +compatibility result and fallback reason. Enhanced screenshots are not retail +parity evidence. + +## Update and removal + +Pack IDs remain stable across compatible updates; increment `PackVersion` and +manifest `version` together. A preset or setting ID that persists must keep its +meaning. User values are persisted as invariant strings under the selected +pack ID and setting ID, so declaration reordering cannot retarget a value. If +an update removes or changes a persisted setting incompatibly, selection fails +atomically to retail with the unknown/invalid override reason instead of +silently applying it elsewhere. If an update removes the selected preset, the client falls back to a +compatible declared preset only after explicit policy permits it; otherwise it +selects retail. Removing or unloading a pack first withdraws registrations, +then retires GPU-flight resources, then releases the collectible load context. + +The built-in atmospheric pack's `sun-shadow-*` setting IDs predate the +selected-celestial source contract. They remain stable persisted identifiers; +their current labels and semantics apply to directional shadows from whichever +authored celestial source the renderer selects. + +Reconnect, portal travel, resize and world-generation replacement do not +re-register managed packs. Renderer-owned resources are recreated or retired +within the same generation/fence rules; pack assets never own gameplay, +streaming, collision, or physics lifetime. + +For v1, device recreation is not an in-place `VK_ERROR_DEVICE_LOST` recovery +path. The host tears down the complete old renderer, context, and device, then +constructs and probes a new context/device. The default retail renderer remains +authoritative while the selected pack is validated as a fresh candidate; a +failed candidate stays on retail without an automatic retry loop. diff --git a/docs/render-packs/plugin-manifest-v1.schema.json b/docs/render-packs/plugin-manifest-v1.schema.json new file mode 100644 index 00000000..258bfa57 --- /dev/null +++ b/docs/render-packs/plugin-manifest-v1.schema.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:acdream:render-pack:plugin-manifest:v1", + "title": "acdream plugin manifest v1", + "description": "Manifest shared by gameplay plugins and declarative render packs. A render pack includes renderPack in kinds.", + "type": "object", + "required": [ + "id", + "displayName", + "version", + "entryDll", + "apiVersion" + ], + "properties": { + "$schema": { + "type": "string" + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z][a-z0-9._-]*$", + "description": "Stable plugin identity. It is persisted; do not reuse or localize it." + }, + "displayName": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "pattern": "^[0-9]+(?:\\.[0-9]+){1,3}$", + "description": "Dotted System.Version-compatible package version." + }, + "entryDll": { + "type": "string", + "minLength": 5, + "maxLength": 512, + "pattern": "^(?![A-Za-z]:)(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[^\\\\]+\\.[dD][lL][lL]$", + "description": "Safe forward-slash relative path to the managed entry assembly." + }, + "apiVersion": { + "type": "integer", + "const": 1, + "description": "AcDream.Plugin.Abstractions PluginApi version, not RenderPackApi." + }, + "dependencies": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9._-]*$" + }, + "default": [] + }, + "kinds": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": ["gameplay", "renderPack"] + }, + "default": ["gameplay"], + "description": "Omitting kinds preserves legacy gameplay-plugin behavior. A render pack must explicitly include renderPack." + } + }, + "additionalProperties": true +} diff --git a/docs/render-packs/semantic-bindings-v1.md b/docs/render-packs/semantic-bindings-v1.md new file mode 100644 index 00000000..2d0b1dd6 --- /dev/null +++ b/docs/render-packs/semantic-bindings-v1.md @@ -0,0 +1,381 @@ +# Render-pack shader ABI and semantic bindings v1 + +**Campaign phase id:** **Campaign AR** +**Render-pack API:** `1` + +This is a SPIR-V binary contract over renderer-owned descriptors. It does not +expose Vulkan descriptor sets, descriptor handles, images, buffers, samplers, +command buffers, devices, queues, or fences to managed pack code. A pack only +declares semantic inputs and supplies SPIR-V; the renderer validates the +interface and binds immutable frame data. + +## Semantic execution and stable identity + +`RenderResourceSemantic`, `RenderPassSemantic`, +`RenderPipelineVariantSemantic`, `RenderQualitySemantic`, and +`RenderSettingSemantic` select renderer-owned execution roles. Pack-owned IDs +remain stable persistence, UI, graph-edge, and diagnostic keys; the executor +never recognizes a role by comparing an ID or asset-name string. Custom +fullscreen declarations retain `Custom` semantics and are executed from their +declared hooks and edges. + +Every non-custom semantic is unique within its declaration kind. The complete +Tier-2+ atmospheric executor requires its exact v1 pass/resource/pipeline- +variant roles, hook order, scene replay, and graph edges. A Tier-2 pack may +instead declare the directional-shadow component plus the technical custom +`WorldColor` tone-map copy needed to present the HDR world. That profile still +requires the exact shadow depth resource/pass, five caster/receiver variants, +headline-caster replay, capabilities, settings, and elevation policy; it does +not require bloom, rays, grading, vignette, or volumetric shafts. Any other +partial or malformed semantic graph fails validation even when all pack-owned +IDs remain syntactically valid. + +Directional-shadow declarations use +`SelectedCelestialDirectionalLight` together with the required +`AuthoredCelestialDirectionalLight` capability and the descriptor's +`DirectionalShadowLightElevationResponse`. `SunDirection`, +`SunElevationResponse`, and `VolumetricShaftSunElevationResponse` remain the +separate sun-specific atmosphere contract for rays and shafts. +The public v1 point record remains named `SunElevationResponsePoint` for ABI +compatibility; points stored in `DirectionalShadowLightElevationResponse` are +interpreted against the selected celestial light's elevation. + +## Fixed descriptor ownership + +| Set | Binding | Shader declaration | Owner and use | +|---:|---:|---|---| +| 3 | 5 | `AtmosphericFrame` std140 uniform block | Renderer-owned camera/reconstruction, authored sun/day/weather and frame facts | +| 3 | 6 | `DirectionalShadow` std140 uniform block | Renderer-owned cascade matrices, splits, shadow texture slot, shadow policy, and selected authored celestial direction | +| 3 | 7 | `PackPass` std140 uniform block | Renderer-resolved pass resource slots, output facts and pass-local parameters | +| 3 | 8 | `PackSettings` std140 uniform block | Renderer-resolved declaration-order scalar values for the selected preset | +| 2 | 0 | `sampler2DArray uTextures[]` combined-image-sampler array | Existing global sampled-texture table; index with host-supplied slot IDs and `nonuniformEXT` | + +Set 0 remains the renderer's existing storage-buffer set. A declared base +pipeline variant inherits the exact renderer pipeline ABI it specializes; it +does not gain arbitrary set-0 storage access. In particular, +`ShadowCasterTransforms` reuses the renderer's existing per-instance transform +publication rather than publishing a second animation pose. + +Set 1 remains the current retail uniform layout at bindings 1–4. Pack shaders +must not redeclare or alias it. Set 3 is strictly opt-in: retail pipeline +layouts contain only sets 0–2, and the host creates no set-3 Vulkan object +until a validated pack pipeline is activated. Bindings other than those in the +table are reserved and validation rejects them. + +## SPIR-V interface validation + +Candidate activation and the standalone authoring validator inspect the +actual SPIR-V binary before any shader module or pipeline is created. Each +asset must expose exactly the declared vertex or fragment stage with entry +point `main`. A fullscreen pass may declare only the sampled table at set 2, +binding 0 when its declaration supplies a sampled semantic/resource input, +plus the role-appropriate set-3 blocks. It may not access renderer-private set +0 or retail set 1. A retained-scene pipeline variant may use only the base +set-0/set-1 bindings documented for that exact semantic role, plus its allowed +set-2/set-3 bindings. + +Validation checks descriptor type and count, all set-3 uniform-block member +types, offsets, strides, and total shapes, and any declared push block against +the exact 96-byte retail layout below. Renderer storage buffers inherited by a +variant must be read-only; storage images, arbitrary storage descriptors, and +`OpImageWrite` are forbidden. An absent, malformed, aliased, writable, or +undeclared interface rejects the whole candidate atomically to retail with a +specific reason. Validation never exposes or accepts a Vulkan handle. + +The binary member layout is validated against matching host structs. The +checked-in shared render-pack GLSL includes are the byte-offset SSOT; authors +include those definitions rather than maintaining a private copy. The tables +below state the same values for review and tool diagnostics. + +### `AtmosphericFrame` — set 3, binding 5, 160 bytes + +```glsl +layout(std140, set = 3, binding = 5) uniform AtmosphericFrame { + vec4 uAtmosphereSunScreen; // @0: uv.xy, resolved ray strength, elevation degrees + vec4 uAtmosphereSunColor; // @16: linear rgb, combined ray-policy multiplier + vec4 uAtmosphereViewport; // @32: width, height, 1/width, 1/height + vec4 uAtmosphereWeather; // @48: WeatherKind numeric, intensity, delta seconds, outdoor 0/1 + vec4 uAtmosphereSunDirection; // @64: surface-to-sun xyz, authored direction brightness + vec4 uAtmospherePolicy; // @80: day group, group factor, shadow factor, shaft factor + mat4 uAtmosphereInverseViewProjection; // @96 +}; +``` + +`uAtmosphereSunScreen.xy` uses normalized main-world viewport coordinates. The two +strength fields are host-evaluated authored/policy facts; they do not create a +second sky or weather owner. `uAtmosphereWeather.x` is numerically integral and must be +interpreted with this v1 table, not guessed from colour or time: + +| Numeric value | Weather kind | +|---:|---| +| 0 | Clear | +| 1 | Overcast | +| 2 | Rain | +| 3 | Snow | +| 4 | Storm | + +All other values are reserved. `uAtmosphereWeather.y` is the transition +intensity in the inclusive range 0–1. + +`uAtmosphereSunDirection.xyz` is normalized and points from a lit surface +toward the authored sun. `uAtmosphereSunScreen.z` is the resolved visible ray +strength; `uAtmosphereSunColor.w` is the combined ray elevation/day-group/ +weather policy multiplier before a pass's own declared setting. In +`uAtmospherePolicy`, `.x` is the numerically integral active day group, `.y` is +that group's declared multiplier, `.z` is the declared directional-shadow +elevation factor, and `.w` is the declared volumetric-shaft elevation factor. +Shadow curves interpolate in sine-of-elevation space; shaft curves use +smoothstep interpolation in elevation-degree space. These are exact values +from the selected pack's `AtmospherePolicyDeclaration`, not built-in fallback +curves. An accepted directional-shadow curve resolves to exactly zero at and +below the authored 0-degree horizon; non-positive points must be zero, and a +curve without an exact 0-degree point must make its first positive point zero. +`uAtmosphereInverseViewProjection` reconstructs main-world positions +from scene depth and the normalized viewport coordinates. Matrix convention +and depth range match the shared push-block `viewProjection`. + +### `PackPass` — set 3, binding 7, 64 bytes + +```glsl +layout(std140, set = 3, binding = 7) uniform PackPass { + vec4 uPackParams0; // @0 + vec4 uPackParams1; // @16 + vec4 uPackParams2; // @32 + vec4 uPackParams3; // @48 +}; +``` + +The active semantic pass defines these sixteen scalar meanings. Unused values +are zero. A pass cannot reinterpret values owned by a different pass. Sampled +pass inputs use logical `textureIndexA` through `textureIndexD` in the shared push block; +binding 7 carries scalar/vector policy and filter parameters, not descriptors. + +The optional external-pack Low-preset `FusedAtmosphericPostProcess` execution +hint uses these fixed values. The built-in Low preset does not declare it: + +| Semantic pass | Values | +|---|---| +| `SunRays` | `uPackParams1 = (1, logicalMaskWidth, logicalMaskHeight, 0)`; input A is scene depth and the shader reconstructs the declared RGBA8 sun mask before radial integration | +| `BloomDownsample` / both `BloomBlur` passes | Declared for the standard graph but not recorded for this preset; their threshold, knee, strength, offsets, and weights remain authoritative inputs to filmic | +| `FilmicComposite` | `uPackParams1.z = 1`; `uPackParams2 = (bloomStrength, threshold, knee, hasVolumetric)`, `uPackParams3.xy = logicalBloomTexelStep`; inputs A/B/C are world color, sun rays, and optional volumetric shafts, and filmic evaluates the full separable bloom kernel before composition | + +Zero flags retain the ordinary six-pass atmospheric graph. The built-in Low +preset uses that zero-flag path; Medium, High, and external packs that do not +opt in never use this fused ABI. + +### Multiview directional-shadow cascades + +The optional Low-preset `MultiviewDirectionalShadowCascades` execution +hint requires the three `*MultiviewDirectionalShadowCaster` variants and the +matching capability in the Low preset. The host begins one layered depth pass +with `viewMask = 0b11`; each vertex shader indexes `uShadowWorldToClip` with +`gl_ViewIndex`. Commands retain exact order, `BaseInstance`, the shared N.5 +world-transform arena, texture index/layer, fixed-function culling, alpha cutoff +`0.05`, and both fitted cascade matrices. With no hint the host records the +ordinary one-pass-per-cascade path. Unsupported hardware makes the hinted preset +unavailable rather than silently selecting an over-budget execution form. + +### `DirectionalShadow` — set 3, binding 6, 336 bytes + +```glsl +layout(std140, set = 3, binding = 6) uniform DirectionalShadow { + mat4 uShadowWorldToClip[4]; // @0, @64, @128, @192 + vec4 uShadowSplitFarMeters; // @256 + vec4 uShadowControl; // @272 + vec4 uShadowBiasMeters; // @288 + uvec4 uShadowTextureAndFlags; // @304 + vec4 uShadowLightDirectionAndSource; // @320 +} directionalShadow; +``` + +Field meanings are fixed: + +| Field/component | Meaning | +|---|---| +| `uShadowWorldToClip[0..3]` | Texel-stabilized world-to-shadow-clip matrices; only the first `cascadeCount` entries are active | +| `uShadowSplitFarMeters` | Far distance of cascades 0–3 in camera-eye metres | +| `uShadowControl.x` | Directional shadow strength | +| `uShadowControl.y` | Filter softness | +| `uShadowControl.z` | Maximum shadow reach in metres, clamped to resident data | +| `uShadowControl.w` | Cascade blend width in metres | +| `uShadowBiasMeters.x` | Constant receiver/caster bias in world metres | +| `uShadowBiasMeters.y` | Slope-scaled bias in world metres | +| `uShadowBiasMeters.z` | Normal offset in world metres | +| `uShadowBiasMeters.w` | Caster depth padding in world metres | +| `uShadowTextureAndFlags.x` | Directional-depth array slot in set 2 | +| `uShadowTextureAndFlags.y` | Active cascade count, 1–4 | +| `uShadowTextureAndFlags.z` | Square shadow-map resolution in pixels | +| `uShadowTextureAndFlags.w` | Flags; bit 0 means directional shadows are valid/enabled; bits 8–11 carry the fixed receiver PCF radius; remaining v1 bits are reserved and zero | +| `uShadowLightDirectionAndSource.xyz` | Normalized direction from a lit surface toward the one authored celestial body selected for this shadow frame | +| `uShadowLightDirectionAndSource.w` | Numerically integral selected-source kind from the table below | + +Selected-source kinds are stable ABI values: + +| Numeric value | Selected celestial source | +|---:|---| +| 0 | None / unavailable; the enabled flag must be clear | +| 1 | Authored sun | +| 2 | Dominant authored Dereth moon | +| 3 | Secondary authored Dereth moon | + +All other values are reserved. The selected source is a renderer-owned fact +resolved from the current immutable Dereth sky frame. A pack does not identify +sky objects by private index or create a second celestial clock. The host still +publishes only one directional-depth array: sun and moons are alternative +sources for the same bounded cascade work, not simultaneous shadow maps. + +When producing a cascade, its zero-based cascade index uses the existing +`uRenderPass` push-constant member. Consumer shaders choose a cascade from the +eye-space distance and split values. No available selected celestial source, a +selected source at or below its accepted horizon, no authored directional +energy, indoors, and portal/login cover clear the enabled bit; shaders must not +sample stale maps when it is zero. Binding 5 remains sun-specific for sun rays +and volumetric shafts. Such passes must not substitute the selected moon +direction for `uAtmosphereSunDirection`; when binding 6 selects a moon they +treat its shadow map as unrelated to sun-shaft occlusion. + +## Shared push constants + +Every pipeline retains retail's exact shared 96-byte push-constant range. API +v1 does not enlarge it: + +```glsl +layout(push_constant) uniform AcdreamPushBlock { + mat4 viewProjection; // byte 0 + int drawIdOffset; // byte 64 + int lightingMode; // byte 68 + int renderPass; // byte 72 + int lightDebug; // byte 76 + uint textureIndexA; // byte 80 + uint textureIndexB; // byte 84 + float paramA; // byte 88 + float paramB; // byte 92 +} acdreamPush; +``` + +Pack shader source may use the logical aliases `uTextureIndexC` and +`uTextureIndexD`. The host stores their uint slot bits in the existing +`paramA` and `paramB` words, and the shared Vulkan preamble exposes them as +`floatBitsToUint(acdreamPush.paramA)` and +`floatBitsToUint(acdreamPush.paramB)`. This is an exact bit reinterpretation, +not numeric float conversion. Pack pass scalar/vector parameters belong in +binding 7, so the two spare retail words are available for these input slots. + +Do not reshape existing fields. A future additive growth requires matching +host/shader layout tests and must remain inside the 128-byte Vulkan guarantee. +A pack pass receives scalar/vector values through binding 7. Base pipeline +variants use `viewProjection`, draw offset, texture slots and existing mode +fields according to that base pipeline's contract. + +## PackSettings (set 3, binding 8, 256 bytes) + +`RenderSettingDeclaration` values use one fixed declaration-order block: + +```glsl +layout(std140, set = 3, binding = 8) uniform PackSettings { + vec4 uPackSettings[16]; +}; +``` + +The descriptor may declare at most 64 settings, which the authoring validator +and graphical host both enforce. Setting index `i` is its zero-based position in +`RenderPackDescriptor.Settings`; it maps to +`uPackSettings[i / 4][i % 4]`. Declaration order is therefore shader ABI and +must remain stable within a compatible pack version. The resolved value is one +IEEE-754 float: Boolean is `0.0` or `1.0`, Choice is its zero-based index in +`Choices`, and Integer/Float parse with invariant culture before float +conversion. Integer values are limited to the exactly representable inclusive +range -16,777,216..16,777,216; Float values must remain finite in float32. A +matching persisted user override wins a selected-preset `SettingOverride`, +which wins the declaration default. User values remain invariant strings keyed +by stable pack ID plus setting ID; before candidate activation the host rejects +unknown IDs and values that fail kind, range, step, or choice validation. That +failure retires the complete candidate to retail with an exact reason. The host +zero-initializes the complete block, so unused slots and any value that fails +defensive parsing are `0.0`; ordinary descriptor/selection validation prevents +invalid values from reaching the bind. + +Binding 7 remains pass-local host dynamics and filter parameters. It must not +be overloaded with pack settings: doing so would make the same setting occupy +different components in different passes and would prevent one stable public +mapping. This fixed block is the complete v1 contract because it adds no +descriptor handles, storage buffers, per-frame managed callbacks, or +pass-specific setting schemas. + +## Logical semantic table + +The descriptor must list every semantic the shader reads. Listing a semantic +does not guarantee device support; the corresponding `RenderCapability` must +also be required when the table says so. + +| `RenderSemanticInput` | Logical shader value | Source / lifetime | Capability prerequisite | +|---|---|---|---| +| `WorldColor` | Sampled main-world colour slot, excluding retained UI and private viewports | `textureIndexA-D` into set 2; current main-world frame | `MainWorldColorIntermediate` | +| `SceneDepth` | Sampled main-world depth slot plus reconstruction matrix | `textureIndexA-D` and binding 5; current main-world frame | `SceneDepthSampling` | +| `SceneNormals` | Sampled main-world normal slot | `textureIndexA-D` into set 2; current main-world frame | `SceneNormalSampling` | +| `SunDirection` | Normalized authored surface-to-sun direction; no second clock | Binding 5 `uAtmosphereSunDirection`; current immutable world frame | `AuthoredSunDirection` | +| `SelectedCelestialDirectionalLight` | Normalized direction and stable source kind for the one authored sun/moon selected to cast this frame's directional shadows | Binding 6 `uShadowLightDirectionAndSource`; current immutable world/sky frame | `AuthoredCelestialDirectionalLight` | +| `SunScreenPosition` | Authored sun projected for the main-world viewport, plus valid/in-front state | Binding 5; current camera/world frame | `AuthoredSunScreenPosition` | +| `ActiveDayGroup` | AC's categorical group plus descriptor-declared group/elevation multipliers | Binding 5 `uAtmospherePolicy`; current Runtime environment frame | `AuthoredWeather` | +| `Weather` | Numeric `WeatherKind`, intensity and outdoor state | Binding 5 `uAtmosphereWeather`; current Runtime environment frame | `AuthoredWeather` | +| `CameraMatrices` | Main-world view-projection and inverse, or directional cascade transforms required by the hook | Push `viewProjection` + binding 5 inverse; binding 6 for cascades | None beyond the hook's feature capability | +| `ShadowCasterTransforms` | Exact existing per-instance/per-part transforms for retained eligible casters | Inherited base-pipeline set-0 ABI; current retained scene | `AnimatedCasterTransforms` | +| `DirectionalShadowMaps` | Directional-depth table slot, active cascade count, matrices, splits and valid state | Binding 6 plus set 2; current outdoor shadow frame | `DirectionalShadowMaps` | +| `FrameTime` | Monotonic frame delta in seconds; never a gameplay clock | Binding 5 `uAtmosphereWeather.z`; current frame | None | + +Pack-declared `ResourceReads` are resolved deterministically to the pass input +slots supplied by the host; v1 exposes up to four sampled inputs through +`textureIndexA-D`. `DirectionalShadowMaps` uses the binding-6 texture slot and +does not consume A-D. Slot assignment first walks sampled-image entries in +`SemanticInputs` declaration order (`WorldColor`, `SceneDepth`, and +`SceneNormals` when present), then sampled `ResourceReads` declaration order. +The first input receives A, then B, C, and D. Duplicate inputs are invalid. The +pack never chooses a global texture-table index. Resource IDs describe graph +edges, not binding numbers. `ResourceWrites` are render targets chosen by the +host and are not simultaneously sampled by the same pass. + +`RenderResourceKind.Buffer`, `RenderFormatClass.StructuredData`, and +`RenderResourceUsage.Storage` are reserved for an additive future contract. +They have no public v1 descriptor binding and the v1 authoring validator +rejects them instead of accepting an unbindable graph. V1 image arrays are +reserved for `DirectionalDepth`; ordinary colour intermediates are `Image2D`. +Each pass writes at most one declared attachment. A zero-write pass is valid +only at `ToneMap` or `AfterToneMapBeforePrivateViewports`, where the host-owned +main-world target is implicit. + +## Texture-table sampling + +Vulkan pack SPIR-V targets the same global table as retail shaders: + +```glsl +#extension GL_EXT_nonuniform_qualifier : require +layout(set = 2, binding = 0) uniform sampler2DArray uTextures[]; + +vec4 sample2D(uint slot, vec2 uv) { + return texture(uTextures[nonuniformEXT(slot)], vec3(uv, 0.0)); +} +``` + +An ordinary 2-D texture is a one-layer array at layer zero. Array resources use +their declared layer. Directional depth may be sampled as ordinary depth and +compared/filtered in shader according to the declared shadow policy; the pack +does not create a private sampler or descriptor. `0xFFFFFFFFu` is the +unassigned texture-slot sentinel and must be checked before sampling an +optional input. + +## Hooks and availability + +| `RenderPassHook` | Inputs valid at the hook | Output boundary | +|---|---|---| +| `ShadowDepthBeforeWorld` | Camera/selected celestial source/environment, cascade block, retained caster transforms | Declared directional-depth resources only; outdoor gating applies | +| `AtmosphereBeforeToneMap` | HDR world colour when required, depth/normals when required, authored atmosphere and earlier declared resources | HDR pack intermediates; rays/shafts composite here | +| `ToneMap` | HDR world colour and earlier atmosphere resources | Main-world display colour | +| `AfterToneMapBeforePrivateViewports` | Tonemapped main-world colour and declared resources | Main world only; private viewports and retained UI remain outside | + +Pass order is the descriptor order within a hook and never moves backward +through this table. Discovery does not bind any of these blocks. Bindings exist +only in the fully validated candidate and retire through normal frame-flight +fences on fallback, resize, portal, reconnect, unload, or the teardown phase of +device recreation. Recreation means a complete renderer/context/device +teardown followed by a fresh context/device; it is not live recovery from +`VK_ERROR_DEVICE_LOST`. diff --git a/docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md b/docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md new file mode 100644 index 00000000..b8f1cb98 --- /dev/null +++ b/docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md @@ -0,0 +1,210 @@ +# Retail building and environment detail texturing — #226 port note + +**Date:** 2026-08-21 +**Status:** IMPLEMENTED + CONNECTED-VISUAL-VERIFIED + +This note is the implementation handoff requested by #226. The measurements +below come from the already-completed +[`2026-08-21 terrain and atmospheric rendering findings`](2026-08-21-terrain-and-atmospheric-rendering-findings.md), +especially §§1–2. They are cited here rather than re-derived. The reachable +preference/caller chain is also recorded in +[`2026-07-10 detail texturing`](2026-07-10-detail-texturing.md). +The A2 terrain-normal verdict and A3 subdivision disposition are recorded in +the companion [`Terrain fidelity Track A report`](2026-08-21-terrain-fidelity-track-a-report.md). + +## User-visible target and reachable caller trace + +The issue title used to say “landscape,” but the Sept-2013 retail client does +not expose live landscape detail through this option: + +1. The Options checkbox writes `RenderPrefs.EnvironmentDetailTextures`. +2. `Render::UpdateFromPreferences` (`0x0054d850`) explicitly changes + `Current_Render_LandscapeDetailTextures` to `0` and calls + `SmartBox::SetDetailTexturing(smartbox, 0, environmentEnabled)` at + `0x0054d9f3`. +3. `SmartBox::SetDetailTexturing` (`0x00451df0`) forwards + `LScape::SetDetailTexturing(lscape, landscape, enabled, enabled, 0)`. +4. `LScape::ChangeRegion` (`0x00506cb0`) independently installs the same + category state: `(0, EnvDetail, EnvDetail, 0)`. + +The four positions are landscape (0), building (1), environment/EnvCell (2), +and ordinary object (3). The only reachable named-retail preference caller +forces categories 0 and 3 off. `DrawPartCell` also clears ordinary-object +detail. Therefore #226's scene target is **building shells and interior +EnvCell geometry**, not outdoor terrain, scenery, creatures, or players. This +also explains why acdream's existing checkbox is labelled “Building Detail +Textures.” + +## Authored source, size, and sampling + +Detail data is reached through +`Region(0x13000000).TerrainInfo.LandSurfaces.TexMerge.TerrainDesc[category]`: + +```text +SurfaceTextureId = TerrainDesc[category].TerrainTex.DetailTextureId +tiling = TerrainDesc[category].TerrainTex.DetailTexTiling +RenderSurfaceId = SurfaceTexture(SurfaceTextureId).Textures[0] +rgba = decode(RenderSurface(RenderSurfaceId), level 0) +``` + +For Dereth, enabled categories 1 and 2 both resolve +`0x05001787 -> 0x06006D58`, a **256 x 256 A8R8G8B8** texture, with tiling +**4**. The complete measured Dereth population is three textures across 33 +entries: `0x050012AF -> 0x060037D2` (64 x 64, 29 entries), +`0x05001786 -> 0x06006D57` (256 x 256, two), and the enabled-category texture +above (256 x 256, two). See the findings §2 table. + +Retail uses wrap addressing in U and V and linear minification, +magnification, and mip filtering. Detail UV is `baseUv * tiling`. The port +therefore uploads each live category as a one-layer RGBA8 texture array with a +full mip chain and the existing repeat/linear world sampler. + +## Exact two-pass pseudocode + +Retail has both a single-pass multitexture route and a two-pass fallback. The +Vulkan port uses the fallback because it preserves the already-accepted base +pass byte-for-byte and expresses the retail framebuffer blend directly. + +```text +enabled = DisplaySettings.BuildingDetailTextures // existing setting; no new option + +buildingDetail = load_category(TerrainDesc[1]) +environmentDetail = load_category(TerrainDesc[2]) + +for each retail built-mesh material subset: + draw_existing_base_subset_unchanged() + + if enabled and subset belongs to a building or EnvCell: + draw the same subset with its category detail texture + // transparent/additive/inverse-alpha: detail follows its base + // immediately, before the next delayed-alpha subset + +for each replayed fragment: + reject ordinary objects / landscape / scenery + accept opaque, ClipMap, alpha, additive and inverse-alpha subsets + + zMetres = positive_view_space_depth_in_metres + fade = clamp((50 m - zMetres) / (50 m - 10 m), 0, 1) + // full through 10 m; linear 10–50 m; exactly zero at/after 50 m + + detail = sample(categoryTexture, baseUv * categoryTiling) + src.rgb = detail.rgb * fade + src.a = detail.a * fade + + depth test = EQUAL opaque; LESS_OR_EQUAL transparent + depth write = preserve base class // ON opaque; OFF transparent + alpha-to-coverage = OFF // detail alpha is blend input + blend op = ADD + source = DEST_COLOR + destination = ONE_MINUS_SRC_ALPHA +``` + +Scaling **both** RGB and alpha by the fade is load-bearing. The resulting +framebuffer multiplier is: + +```text +factor = 1 + fade * (detail.rgb - detail.a) +``` + +Thus fade zero is an exact no-op and the full-strength neutral point is +`detail.rgb == detail.a` channel-by-channel. It is not 0.5 gray. + +### Built-mesh material coverage and order + +The land-polygon `SurfaceType & 4` exclusion does **not** narrow this built-mesh +port. Named-retail `RenderDeviceD3D::DrawEnvCell` (`0x0059f170`) and +`DrawBuilding` (`0x0059f2a0`) install `curr_detail_surface` before calling +`D3DPolyRender::DrawMesh`. `DrawMesh` (`0x0059d4a0`) bypasses delayed-alpha +queuing while that surface is installed and passes detail enabled to +`RenderMeshSubset` (`0x0059ca10`) for each material subset. The fallback then +redraws that exact subset with the detail surface before proceeding. Therefore +ClipMap, straight-alpha, additive, and inverse-alpha built-mesh subsets are +included alongside plain opaque ones. + +The Vulkan port first filters the opaque object command stream to coalesced +runs containing at least one category-1 building instance; nonbuilding-only +commands never reach the detail pipeline. A mixed instanced command remains in +the replay and `mesh_detail` rejects its ordinary instances individually. The +accepted opaque path stays batched, while transparent subsets preserve +immediate base/detail adjacency. Their separate detail pipeline +keeps depth writes disabled, matching the base subset's accepted depth +contract. This prevents another shell/object contribution from being +composited between the base and its detail contribution. + +Opaque detail uses depth compare **EQUAL** against the exact geometry just +written by the base pass. Vulkan depth is per sample, so on MSAA ClipMap edges +the detail affects only samples whose base alpha-to-coverage mask wrote depth. +The detail pipeline itself deliberately keeps alpha-to-coverage off: detail +alpha controls `ONE_MINUS_SRC_ALPHA` in the retail blend and is not the base +coverage mask. Transparent bases do not write depth, so their adjacent detail +uses `LESS_OR_EQUAL` with depth writes still off. + +One bounded ordering seam is explicit: retail bypasses its delayed-alpha queue +while `curr_detail_surface` is installed, whereas acdream retains its already- +authoritative shared alpha-queue order and inserts the detail draw immediately +after the corresponding base draw. This does not narrow material coverage or +change base coverage/blend/depth behavior; it avoids making the checkbox +reorder the default transparent scene. The connected acceptance matrix must +still exercise overlapping transparent building/EnvCell surfaces. + +## Brightening decision + +The port keeps retail's `DEST_COLOR + ONE_MINUS_SRC_ALPHA` verbatim. The +findings measured factors **1.177**, **1.204**, and **1.033** for the three +Dereth textures; the live Dereth building/environment category uses the +1.033-factor texture. That slight brightening is intentional retail parity, +not an acceptance failure. + +Changing the destination factor to `ZERO` would be a visual correction rather +than a port. Exposing both behaviors behind one retail checkbox would also +make the option ambiguous. If a roughening-corrected material is wanted later, +it belongs as an explicitly named opt-in enhancement/shader-pack policy with a +registered divergence. It is not part of #226. + +## What the reverted experiment got wrong + +The experiment described by `c25d6186` was never committed as renderer code; +it was reverted from the worktree with `git checkout`. Its useful failure +record remains in that issue commit. It differed from the verified contract in +five material ways: + +- It targeted outdoor landscape, while the live setting enables building and + environment categories and forces landscape off. +- It built a per-terrain-type texture array, while retail selects one + category-scoped surface and scalar tiling for each draw path. +- It used `base * detail * 2` (`MODULATE2X`) instead of retail's framebuffer + blend. +- It assumed 128 gray was neutral; retail neutral is RGB equal to alpha. +- Its acceptance prohibited an overall brightness change, although retail's + measured blend intentionally brightens these textures. + +The old OpenGL-specific array/bindless wiring is also not reusable in the +current Vulkan-only RHI. + +## Corrected acceptance + +- With `BuildingDetailTextures=false`, no detail replay is submitted and the + current base rendering remains unchanged. +- With it `true`, toggling the existing Options checkbox **visibly changes + buildings and interior/EnvCell surfaces** without a restart. The connected + 2026-08-21 Facility Hub A/B/A gate applied the real Config checkbox on -> + off -> restored-on and captured the same nearby walls/floor after each + transition. Static right-wall mean absolute RGB error was 2.132 for on/off + versus 0.007 for original-on/restored-on; the floor row was 3.385 versus + 0.013. The persisted setting was observed false during B, restored true, + and the session ended with ACE-confirmed graceful logout. +- Outdoor terrain, ordinary scenery/objects, creatures, and players do not + gain this overlay. +- Every built building/EnvCell material subset is eligible: opaque, ClipMap, + straight alpha, additive, and inverse alpha. Transparent base/detail draws + remain adjacent in acdream's authoritative shared alpha order. +- Opaque object replay submits only command runs containing a building; mixed + commands are filtered per instance. Depth equality inherits the base pass's + per-sample ClipMap coverage without applying A2C to detail alpha. +- Detail is full through positive view depth 10 m, fades linearly over 10–50 + m, and is an exact no-op at and beyond 50 m. +- Category source, 256 x 256 size, tiling 4, repeat addressing, and linear mip + sampling match the measured Dereth data. +- The retail 1.033 live-category brightening is expected. There is no + `dst=ZERO` correction mode hidden behind the retail checkbox. +- Physics, collision, walkability, and geometry are untouched. diff --git a/docs/research/2026-08-21-terrain-fidelity-track-a-report.md b/docs/research/2026-08-21-terrain-fidelity-track-a-report.md new file mode 100644 index 00000000..51c4b095 --- /dev/null +++ b/docs/research/2026-08-21-terrain-fidelity-track-a-report.md @@ -0,0 +1,75 @@ +# Terrain fidelity Track A report + +**Date:** 2026-08-21 +**Status:** REPORT ACCEPTED BY OWNER DIRECTION; A1/A2 IMPLEMENTED; A3 REJECTED + +This report answers Track A from the measured +[`terrain and atmospheric rendering findings`](2026-08-21-terrain-and-atmospheric-rendering-findings.md). +It cites that evidence rather than repeating its measurements, and it does not +reopen the findings' three refuted claims. The project owner subsequently +authorized implementation. No physics or collision behavior changed. + +## A1 — #226 detail-texture overlay + +The complete source/size/tiling, blend, neutral point, distance units, setting +gate, material-ordering contract, reverted-experiment analysis, and connected +A/B/A evidence are in the +[`#226 retail building/EnvCell detail-texturing port note`](2026-08-21-retail-building-detail-texturing-pseudocode.md). + +The report conclusions are: + +- The reachable user-visible target is **building shells and interior EnvCell + geometry**, not outdoor terrain. The Options preference caller and + `LScape::ChangeRegion` both install category state `(landscape=0, + building=enabled, environment=enabled, ordinary=0)` through + `SmartBox::SetDetailTexturing`. +- The existing **Building Detail Textures** checkbox is the sole setting gate. + No second option was added. Toggling it now visibly changes the connected + Facility Hub scene without a restart. +- The port keeps retail's `DEST_COLOR + ONE_MINUS_SRC_ALPHA` blend verbatim, + including the measured slight brightening. `dst=ZERO` would be an opt-in + visual correction, not parity; exposing both meanings behind the one retail + checkbox would make that preference ambiguous. +- The reverted experiment targeted landscape, built the wrong texture-array + shape, used `base * detail * 2`, assumed 128 gray was neutral, and rejected + the brightness change that the measured retail blend actually produces. + +## A2 — terrain vertex normals + +**Verdict: parity gap. Retail smooths shared terrain vertices.** + +The decisive named-retail function is +`CLandBlockStruct::calc_lighting` at `0x00531700` in +[`acclient_2013_pseudo_c.txt`](named-retail/acclient_2013_pseudo_c.txt): + +1. It zeroes one three-float accumulator for every shared landblock vertex. +2. From `0x00531774` through `0x005317F6`, it walks every terrain polygon and + adds that polygon's plane normal (`CPolygon + 0x20..0x28`) to the + accumulator of each of its three vertex IDs. +3. From `0x00531817` through `0x00531886`, it normalizes every accumulated + vector, falling back to `(0, 0, 1)` only for a degenerate sum. +4. The following sunlight/ambient loop dots those normalized shared-vertex + normals with `LScape::sunlight` and writes per-vertex lighting. + +That is incident-face normal averaging, not flat per-face shading. The +WorldBuilder-derived `TerrainUtils.GetNormal` identified in the findings §4 +is therefore a simplified tool path and not the retail oracle. + +The approved port is in `LandblockMesh.BuildRetailVertexNormals`. It uses the +same split hash and exact emitted triangle topology, accumulates each +normalized incident face normal at the shared 9 x 9 height-sample vertex, and +normalizes the sum. Tests independently reconstruct the average from emitted +positions/indices and prove every position and index is unchanged. + +This is lighting-only parity: the 81 height samples, 128 triangles, split +directions, terrain surface, collision triangles, walkability, and physics +owners are byte-for-byte/topology-equivalent to the prior path. + +## A3 — subdivision + +**Agree: the standing “not worth doing” recommendation survives.** The +findings §4 already establishes that the 9 x 9 samples are height-table +quantized, so subdivision cannot recover missing terrain detail; changing the +surface would create physics divergence, while coplanar subdivision would only +interpolate a surface whose retail-correct shared-vertex smoothing is now +already present. No subdivision work is scheduled. diff --git a/docs/research/2026-08-22-atmospheric-stage1-automated-gate.md b/docs/research/2026-08-22-atmospheric-stage1-automated-gate.md new file mode 100644 index 00000000..42edc755 --- /dev/null +++ b/docs/research/2026-08-22-atmospheric-stage1-automated-gate.md @@ -0,0 +1,95 @@ +# Campaign AR Stage 1 automated gate + +**Date:** 2026-08-22 +**Verdict:** PASS — every Stage 1 gate that does not require physical visual or +desktop-performance judgment is complete. Those judgments were deliberately +outside this automated report and subsequently passed in the +[Stage 1 live-gate report](2026-08-22-atmospheric-stage1-live-gate.md). + +## Scope + +This report covers the current authored-celestial implementation: the visible +above-horizon sun, dominant haloed moon, secondary moon, and no-source states; +the selected source's direction-versus-energy handoff; the 336-byte render-pack +shadow ABI; the unchanged authoritative retail path; and local deterministic +performance and lifetime contracts. + +It does not itself claim that a physical display proves shadow alignment, +source-transition continuity, temporal pixelation/shimmer quality, or desktop +frame pacing. It did not itself begin Campaign AR Stage 2; the subsequent +project-owner live approval did. + +## Results + +| Gate | Result | +|---|---| +| Shader compilation | 24/24 Vulkan shader pairs ready | +| Retail shader preservation | all 18 pre-campaign SPIR-V SHA-256 oracles exact; no tracked retail SPIR-V change | +| Focused App renderer validation | 344/344 passed | +| Core sky loader | 14/14 passed | +| SDK and standalone pack validator | 30/30 passed | +| MossTank plugin regression | 48/48 passed | +| Forced locked restore | passed for the complete solution graph | +| Complete Release build after locked restore | passed, 0 warnings, 0 errors | +| Fresh-process hermetic Release gate | 14,928/14,928 passed, 0 skipped, 0 failed, 14 assemblies | +| App assembly inside the complete gate | 5,823/5,823 passed | + +The release evidence bundle is +[`artifacts/atmospheric-rendering/stage1-moon-release-gate/`](../../artifacts/atmospheric-rendering/stage1-moon-release-gate/). +Its `release-gate-summary.json`, TRX files, logs, environment inventory, and +`SHA256SUMS.txt` are the machine-readable authority for the fresh-process total. + +## Performance and lifetime coverage + +The complete App gate includes these deterministic contracts: + +- `DirectionalShadowCasterFrameTests.WarmStableFrame_AllocatesZero` builds a + 9,500-static-caster scene, warms it, then performs 256 stable frames with + zero managed bytes, no additional scene-index copy, no classification, and + no topology rebuild. +- `DirectionalShadowCasterFrameTests.WarmDenseChangedFrames_AllocateZeroAndReadNoSceneRecords` + proves dense animated-transform refresh stays allocation-free and does not + reread scene records. +- `AtmosphericCpuStageProfilerTests.WarmedObservationAllocatesNothing` and + `AtmosphericGpuTimerSamplingTests.WarmSamplingDecisionsAllocateZero` keep the + measurement path allocation-free after warmup. +- `RenderPackLongCycleConvergenceTests.RepeatedPackResizeFailureGenerationAndFlightCyclesConvergeExactly` + repeatedly crosses Low, Medium, High, retail selection, resize, injected + failure/recovery, both frame-flight slots, render-generation replacement, + and terminal disposal for 12 cycles. Every pack resource, registration, + receiver candidate, transform owner, texture slot, and pipeline-format lease + returns to its exact baseline. +- `RenderPackLongCycleConvergenceTests.DeviceRecreationIsFullRendererTeardownThenANewContextAndDevice` + proves recreation is complete old-renderer/context/device teardown followed + by an independent activation generation on a fresh device. + +These are CPU-side and recording-RHI gates. The historical physical AMD rows +remain valid for their exact pre-moon binaries and stated scope, but they are +not reused as current sun-and-moon image-quality or desktop-performance proof. + +## Authoritative-path and scope audit + +- Shader regeneration expands includes and injects pack-only definitions only + for pack shaders. Unchanged retail sources retain their existing committed + binaries; the source manifest still forces a recompile after a real source + edit. +- The exact pre-campaign retail SPIR-V oracle passes after ordinary shader + regeneration. +- No source file under `src/AcDream.Runtime` changed for this campaign gate. +- No physics or collision source changed. +- No retail GLSL source changed. +- Pack-off production integration continues to require zero enhancement passes, + resources, casters, cascades, draws, or dispatches and its pinned framebuffer + and resource ledger remain exact. + +## Pending project-owner gate + +When the desktop is healthy, launch the corrected Release client against ACE +and stop for the project owner to judge: + +- sun, dominant-moon, and secondary-moon shadow alignment; +- sun-to-moon, moon-to-moon, and no-source transitions; +- temporal pixelation/shimmer during camera and celestial motion; and +- desktop smoothness, frame pacing, and FPS behavior. + +Stage 2 and campaign closeout remain gated on that explicit approval. diff --git a/docs/research/2026-08-22-atmospheric-stage1-live-gate.md b/docs/research/2026-08-22-atmospheric-stage1-live-gate.md new file mode 100644 index 00000000..fb60f52c --- /dev/null +++ b/docs/research/2026-08-22-atmospheric-stage1-live-gate.md @@ -0,0 +1,46 @@ +# Campaign AR Stage 1 live gate + +**Date:** 2026-08-22 +**Verdict:** PASS — project-owner accepted; Stage 2 authorized + +## Scope + +This is the physical-display and desktop-performance stop that followed the +[Stage 1 automated gate](2026-08-22-atmospheric-stage1-automated-gate.md). It +records the project owner's live acceptance of the opt-in Atmospheric pack; it +is not final Campaign AR acceptance. + +The owner exercised the Vulkan client against the local ACE server through the +Stage 1 correction rounds: visible authored sun and moon shadows, selection and +configuration persistence, temporal texture/shadow shimmer, frame pacing and +desktop responsiveness, world selection, fullscreen, and final exposure. After +the exposure correction the owner reported **“Looks good!”** and directed the +campaign to synchronize with main and proceed autonomously through Stage 2. + +The opt-in Atmospheric exposure changed from `1.00` to `0.80`. The retail +renderer remains the default and authoritative path. Physics, collision, +gameplay, and network behavior are unchanged. + +## Matched exposure evidence + +The final comparison pinned time, day group, sky, weather, MSAA, route, and +camera framing. Its five screenshots and machine-readable metadata are under +[`artifacts/atmospheric-rendering/live-exposure-comparison-exposure080-20260822-125033/`](../../artifacts/atmospheric-rendering/live-exposure-comparison-exposure080-20260822-125033/). + +| Scene/preset | Retail mean luminance | Atmospheric mean luminance | Delta | Atmospheric p95 delta | Saturation delta | Clipped pixels | +|---|---:|---:|---:|---:|---:|---:| +| Outdoor / High | 0.1090 | 0.1003 | -8.0% | +6.8% | +29.8% | 0% | +| Interior / High | 0.2711 | 0.2813 | +3.8% | -3.5% | +5.2% | 0% | +| Outdoor / Low | 0.1090 | 0.1011 | -7.2% | +8.4% | +30.1% | 0% | + +Before the correction, Atmospheric High measured +19.6% outdoors and +24.5% +indoors. The `0.80` correction removes that overexposure without clipping. +The final outdoor Atmospheric High capture reports 6,613 shadow casters, four +cascades, 95,260,912 resident GPU bytes, and 285 performance samples. + +## Acceptance boundary + +This gate does not fabricate evidence for a second connected remote player, +portal/reconnect or device-recreation lifecycle, external package flows, +long-run convergence, or unavailable physical GPU classes. Those remain Stage +2/closeout rows, followed by the final project-owner acceptance gate. diff --git a/docs/research/2026-08-22-dereth-celestial-shadow-sources.md b/docs/research/2026-08-22-dereth-celestial-shadow-sources.md new file mode 100644 index 00000000..f96bf7a5 --- /dev/null +++ b/docs/research/2026-08-22-dereth-celestial-shadow-sources.md @@ -0,0 +1,200 @@ +# Dereth celestial shadow sources + +**Date:** 2026-08-22 +**Status:** measured retail-DAT and named-retail finding; implementation input +for Campaign AR +**Scope:** identify the Dereth sun/moons and define the opt-in pack's dominant +directional-shadow source. This note does not change the retail rendering path. + +## Conclusion + +Dereth's Region `0x13000000` consistently authors three moving celestial +meshes across all 20 day groups: + +1. `0x01001348` is the sun disk. +2. `0x01001F6A` is the large, haloed moon and is the dominant lunar source. +3. `0x01001F67` is the smaller secondary moon. + +Retail does **not** provide a separate lighting colour or intensity for each +mesh. `SkyDesc::GetLighting` produces one interpolated directional vector, +colour, and brightness from `SkyTimeOfDay.DirHeading`, `DirPitch`, `DirColor`, +and `DirBright`. The opt-in atmospheric pack therefore uses the selected +visible celestial mesh only for shadow **direction**. Colour and energy remain +the single AC-authored directional-light values. + +The deterministic priority is: + +1. visible sun whose transformed centre is above the horizon; +2. visible large/haloed moon whose transformed centre is above the horizon; +3. visible secondary moon whose transformed centre is above the horizon; +4. no directional shadow source. + +This is a pack enhancement, not a claim that retail cast real-time moon +shadows. + +## Evidence and provenance + +The investigation followed the project rendering inventory and used the +already-loaded retail structures rather than inventing another sky model. +Evidence came from: + +- `artifacts/atmospheric-rendering/sky-heading-dump/client.log`, especially + lines 40-88 for Sunny day group 0 and the corresponding repeated entries for + all later day groups. The dump records the three IDs, visibility windows, + angular sweeps, keyframe directional lighting, and the sun surface. +- A read-only `DatCollection.Get`/`Get` probe against the + installed Asheron's Call DATs, using the same inspection path implemented by + `tools/SkyObjectInspect/Program.cs`, for all three `GfxObj` sort centres, + polygon geometry, surfaces, and texture chains. +- `tools/RainMeshProbe/Program.cs` lines 37-49, which names and audits the + celestial surface set independently of the shadow implementation. +- `docs/research/named-retail/acclient_2013_pseudo_c.txt`: + `SkyDesc::GetLighting` at `0x00500a80` (around line 261291), + `SkyDesc::GetSky` at `0x00501ec0` (around line 262761), + `GameSky::CalcFrame` at `0x00506f80` (around line 268650), and + `GameSky::UseTime` at `0x005075b0` (around line 269090). +- `docs/research/2026-04-23-sky-retail-verbatim.md`, especially its recorded + directional-light interpolation and `GameSky::UseTime` material updates. + +No fresh decompilation was required. The named-retail corpus already answered +the only question the current code and DAT dump could not answer on their own: +whether a moon mesh contributes a second retail world light. It does not. + +## Installed-DAT characterization + +The following values were read from the installed Dereth Region and the three +referenced `GfxObj`/surface/texture chains. The same three object IDs, windows, +and sweeps occur in every one of the 20 day groups; only their object index +changes between seven-object and weather-heavy groups. + +| Role | GfxObj | Day window | Angular sweep | Authored `SortCenter` | +|---|---:|---:|---:|---:| +| Sun disk | `0x01001348` | `0.1600..0.9400` | `-23 deg..203 deg` | `(1050, 0, 0)` | +| Secondary moon | `0x01001F67` | `0.0400..0.2100` | `-20 deg..190 deg` | `(1909.46, 1874.78, -0.0000157485)` | +| Dominant moon + halo | `0x01001F6A` | `0.0000..0.2300` | `-20 deg..190 deg` | `(2066.82, 552.99, 0)` | + +The asset chain establishes the visual identities and the dominant-moon +choice: + +| GfxObj | Surface | Surface flags | SurfaceTexture | RenderSurface | Image | +|---:|---:|---|---:|---:|---| +| `0x01001348` | `0x080000D1` | Base1Image, Alpha, Additive | `0x050014CD` | `0x0600388D` | 128x128 `PFID_R8G8B8` sun disk | +| `0x01001F67` | `0x080000D2` | Base1ClipMap | `0x05001A6C` | `0x06003894` | 256x256 `PFID_INDEX16`, palette `0x0400103F` | +| `0x01001F6A` | `0x080000D6` | Base1ClipMap | `0x05001A6D` | `0x06003898` | 256x256 `PFID_INDEX16`, palette `0x0400103F` | +| `0x01001F6A` | `0x080000D7` | Base1Image, Alpha, Additive | `0x05001A6E` | `0x06003899` | 128x128 `PFID_R8G8B8` halo | + +Every listed surface has authored `Luminosity=1`, `Diffuse=1`, and +`Translucency=0`. The large moon's primary quad has roughly 2.3 times the +polygon area of the secondary moon before its still larger additive halo is +counted. That makes `0x01001F6A` the unambiguous dominant lunar visual when +both moons are above the horizon. + +These installed-DAT facts are characterization evidence, not an ordinary test +dependency. Unit tests use hand-built `DayGroupData` so clean CI and machines +without retail DATs remain deterministic. + +## Direction and visibility contract + +`SkyObjectData.IsVisible(dayFraction)` owns the normal, always-visible, and +midnight-wrapping window cases. `CurrentAngle(dayFraction)` owns the authored +arc interpolation, including progress through a wrapping window. + +The selected direction must match the sky renderer exactly: + +```text +heading = active SkyObjectReplace.Rotate +arc = SkyObjectData.CurrentAngle(dayFraction) +model = RotationZ(-heading) * RotationY(-arc) +anchor = effective GfxObj.SortCenter +direction = normalize(TransformNormal(anchor, model)) +``` + +“Effective” means that an active non-zero replacement `GfxObjId` also supplies +its own `SortCenter`. A replacement with `Transparent >= 1` makes the object +ineligible. The replacement lookup follows the renderer's discrete active +keyframe rule; it does not interpolate replacement fields. A zero, non-finite, +or below/on-horizon transformed direction is ineligible. + +This deliberately does not substitute `SkyTimeOfDay.DirHeading/DirPitch` for +moon direction. Those values are the one retail world-light direction. The +moon meshes have separate authored arcs, and the enhancement is specifically +intended to align moon shadows with the moon the player can see. + +## Authored light contribution + +Named retail `SkyDesc::GetLighting` interpolates the two surrounding +`SkyTimeOfDay` records and produces: + +```text +sunVector = DirBright * ( + cos(DirPitch) * sin(DirHeading), + cos(DirPitch) * cos(DirHeading), + sin(DirPitch)) +directionalColor = DirColor * length(sunVector) +``` + +`length(sunVector)` is `DirBright`. acdream exposes the resulting colour as +`SkyKeyframe.SunColor`. The pack's scalar authored energy is therefore +`clamp(max(SunColor.r, SunColor.g, SunColor.b), 0, 1)`. + +By contrast, named retail `GameSky::UseTime` sends a celestial replacement's +`Luminosity`, `MaxBright`, and `Transparent` to the mesh material through +`SetLuminosity`, `SetDiffusion`, and `SetTranslucency`. It does not install a +second directional light. Texture brightness and moon surface luminosity must +not manufacture extra world-light energy. + +Weather/day-group reductions, softness, and elevation ramps remain explicit +render-pack policy. They are not mislabelled as measured retail intensities. + +## Parity and safety registration + +### Retail behavior + +- One interpolated directional world-light channel comes from + `SkyTimeOfDay.Dir*`. +- Celestial meshes follow their own visibility windows and transformed arcs. +- Replacement luminosity/diffusion/transparency changes mesh material state, + not the number of world-directional lights. +- Retail does not render the Campaign AR cascaded real-time object shadows. + +### Opt-in pack enhancement + +- The pack chooses the visible sun or dominant visible moon direction for its + directional shadow map. +- Moon direction follows the rendered moon; energy remains the single + AC-authored directional channel. +- Sun wins any overlap when its transformed centre is above the horizon; + otherwise the haloed moon wins before the secondary moon. +- This deviation belongs in the atmospheric render-pack entry of + `docs/architecture/retail-divergence-register.md`. + +### Unchanged boundaries + +- The retail rendering path remains the default and authoritative output. +- Pack-off frames do not resolve or render celestial shadow work. +- Existing retail scene lighting remains driven by `SkyStateProvider`; this + policy does not replace it. +- Physics, collision, containment, selection, movement, and DAT geometry are + untouched. The selected source is an immutable one-frame rendering fact. + +## Deterministic acceptance coverage + +`tests/AcDream.App.Tests/Rendering/Packs/AuthoredCelestialShadowSourceResolverTests.cs` +locks: + +- the three verified IDs and priority independent of object-list order; +- sun overlap, dominant-moon fallback, and secondary-moon fallback; +- fully transparent and effective replacement behavior; +- replacement rotation and the exact renderer transform direction; +- no-visible/no-above-horizon suppression; +- midnight-wrapping visibility and angle progress; and +- directional colour-times-brightness energy, including preservation when no + celestial source is available. + +The test fixture is entirely hand-built. It neither requires nor silently +substitutes installed retail DAT content. + +The complete non-physical verification result, including shader ABI, exact +retail-binary preservation, performance/lifetime fixtures, locked restore, +Release build, and fresh-process totals, is recorded in the +[Campaign AR Stage 1 automated gate report](2026-08-22-atmospheric-stage1-automated-gate.md). diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/AcDream.RenderPacks.AtmosphericTier2.csproj b/samples/AcDream.RenderPacks.AtmosphericTier2/AcDream.RenderPacks.AtmosphericTier2.csproj new file mode 100644 index 00000000..a9483d83 --- /dev/null +++ b/samples/AcDream.RenderPacks.AtmosphericTier2/AcDream.RenderPacks.AtmosphericTier2.csproj @@ -0,0 +1,22 @@ + + + net10.0 + enable + enable + latest + true + + + + + false + runtime + + + + + + AcDream.RenderPacks.AtmosphericTier2.Shaders.%(Filename)%(Extension) + + + diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/AtmosphericTier2RenderPack.cs b/samples/AcDream.RenderPacks.AtmosphericTier2/AtmosphericTier2RenderPack.cs new file mode 100644 index 00000000..0af05b85 --- /dev/null +++ b/samples/AcDream.RenderPacks.AtmosphericTier2/AtmosphericTier2RenderPack.cs @@ -0,0 +1,591 @@ +using System.Globalization; +using System.Reflection; +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.RenderPacks.AtmosphericTier2; + +/// +/// Complete external atmospheric-pack example. Every logical ID deliberately +/// differs from acdream's built-in pack: renderer-owned semantic enums, not +/// magic ID strings, bind the fixed atmospheric executor. +/// +public sealed class AtmosphericTier2RenderPack : IRenderPackPlugin, IRenderPackAssets +{ + private const string ShaderPrefix = "shaders/"; + private const string EmbeddedShaderPrefix = + "AcDream.RenderPacks.AtmosphericTier2.Shaders."; + + private static RenderPackDescriptor Descriptor { get; } = new RenderPackDescriptor( + "sample.atmospheric-tier2", + "Atmospheric Tier 2 SDK Sample", + new Version(1, 0, 0), + RenderPackApi.Current, + RenderPackTier.Tier2Plus, + [ + RenderCapability.MainWorldColorIntermediate, + RenderCapability.FullscreenPasses, + RenderCapability.SceneDepthSampling, + RenderCapability.AuthoredSunDirection, + RenderCapability.AuthoredSunScreenPosition, + RenderCapability.AuthoredWeather, + RenderCapability.DirectionalShadowMaps, + RenderCapability.OutdoorDirectionalShadowCasterReplay, + RenderCapability.AnimatedCasterTransforms, + RenderCapability.AlphaCutoutShadowCasters, + RenderCapability.AuthoredCelestialDirectionalLight, + ], + [RenderCapability.GpuTimestampQueries], + Resources(), + Passes(), + SceneReplays(), + PipelineVariants(), + QualityPresets(), + Settings(), + AtmospherePolicy()) + { + FeatureSummary = "Filmic HDR atmosphere and moving sun-and-moon cascaded shadows " + + "from terrain, trees, buildings, players, and monsters, with " + + "optional volumetric shafts.", + }; + + public void Register(IRenderPackRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + registry.Register(Descriptor, this); + } + + public Stream OpenRead(string assetKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(assetKey); + if (!assetKey.StartsWith(ShaderPrefix, StringComparison.Ordinal) + || assetKey.Length == ShaderPrefix.Length + || assetKey.Contains('\\') + || assetKey.Contains("..", StringComparison.Ordinal)) + { + throw new FileNotFoundException( + "The atmospheric sample exposes only declared embedded shader assets.", + assetKey); + } + + string fileName = assetKey[ShaderPrefix.Length..]; + if (fileName.Contains('/')) + throw new FileNotFoundException("Nested shader paths are not declared.", assetKey); + + string resourceName = EmbeddedShaderPrefix + fileName; + return Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName) + ?? throw new FileNotFoundException("The declared shader asset is missing.", assetKey); + } + + private static IReadOnlyList Resources() => + [ + Image("cinematic-world-buffer", RenderResourceSemantic.MainWorldHdr, + RenderFormatClass.HdrColor, 1.0, 32L * 1024 * 1024), + Image("glow-stage-one", RenderResourceSemantic.BloomPing, + RenderFormatClass.HdrColor, 0.5, 8L * 1024 * 1024), + Image("glow-stage-two", RenderResourceSemantic.BloomPong, + RenderFormatClass.HdrColor, 0.5, 8L * 1024 * 1024), + Image("solar-visibility", RenderResourceSemantic.SunOcclusionMask, + RenderFormatClass.SingleChannel, 0.25, 2L * 1024 * 1024), + Image("scattered-sunlight", RenderResourceSemantic.SunRays, + RenderFormatClass.HdrColor, 0.25, 2L * 1024 * 1024), + new RenderResourceDeclaration( + "directional-shadow-depth", + RenderResourceKind.Image2DArray, + RenderFormatClass.DirectionalDepth, + new RenderExtentDeclaration( + RenderExtentMode.AbsolutePixels, + 1024, + 1024, + Layers: 2), + SizeBytes: 0, + RenderResourceUsage.Sampled | RenderResourceUsage.DepthAttachment, + RenderResourceLifetime.ActivePack, + EstimatedResidentBytes: 8L * 1024 * 1024) + { Semantic = RenderResourceSemantic.DirectionalShadowDepth }, + Image("participating-air", RenderResourceSemantic.VolumetricShafts, + RenderFormatClass.HdrColor, 0.25, 2L * 1024 * 1024), + ]; + + private static IReadOnlyList Passes() => + [ + Pass( + "record-directional-shadow-casters", + RenderPassSemantic.DirectionalShadowDepth, + RenderPassHook.ShadowDepthBeforeWorld, + "shadow-pass.vert.spv", + "shadow-pass.frag.spv", + [ + RenderSemanticInput.CameraMatrices, + RenderSemanticInput.SelectedCelestialDirectionalLight, + RenderSemanticInput.ShadowCasterTransforms, + RenderSemanticInput.ActiveDayGroup, + RenderSemanticInput.Weather, + ], + [], + ["directional-shadow-depth"]), + Pass( + "measure-solar-visibility", + RenderPassSemantic.SunOcclusion, + RenderPassHook.AtmosphereBeforeToneMap, + "solar-visibility.vert.spv", + "solar-visibility.frag.spv", + [ + RenderSemanticInput.SceneDepth, + RenderSemanticInput.SunScreenPosition, + RenderSemanticInput.ActiveDayGroup, + RenderSemanticInput.Weather, + ], + [], + ["solar-visibility"]), + Pass( + "scatter-visible-sunlight", + RenderPassSemantic.SunRays, + RenderPassHook.AtmosphereBeforeToneMap, + "sun-scatter.vert.spv", + "sun-scatter.frag.spv", + [RenderSemanticInput.SunScreenPosition, RenderSemanticInput.FrameTime], + ["solar-visibility"], + ["scattered-sunlight"]), + Pass( + "integrate-lit-air", + RenderPassSemantic.VolumetricShafts, + RenderPassHook.AtmosphereBeforeToneMap, + "lit-air.vert.spv", + "lit-air.frag.spv", + [ + RenderSemanticInput.SceneDepth, + RenderSemanticInput.CameraMatrices, + RenderSemanticInput.SunDirection, + RenderSemanticInput.DirectionalShadowMaps, + RenderSemanticInput.ActiveDayGroup, + RenderSemanticInput.Weather, + ], + ["directional-shadow-depth"], + ["participating-air"]), + Pass( + "extract-highlight-energy", + RenderPassSemantic.BloomDownsample, + RenderPassHook.AtmosphereBeforeToneMap, + "highlight-extract.vert.spv", + "highlight-extract.frag.spv", + [RenderSemanticInput.WorldColor], + ["scattered-sunlight", "participating-air"], + ["glow-stage-one"]), + Pass( + "spread-glow-sideways", + RenderPassSemantic.BloomBlurHorizontal, + RenderPassHook.AtmosphereBeforeToneMap, + "glow-filter.vert.spv", + "glow-filter.frag.spv", + [RenderSemanticInput.FrameTime], + ["glow-stage-one"], + ["glow-stage-two"]), + Pass( + "spread-glow-upwards", + RenderPassSemantic.BloomBlurVertical, + RenderPassHook.AtmosphereBeforeToneMap, + "glow-filter.vert.spv", + "glow-filter.frag.spv", + [RenderSemanticInput.FrameTime], + ["glow-stage-two"], + ["glow-stage-one"]), + Pass( + "compose-cinematic-colour", + RenderPassSemantic.FilmicComposite, + RenderPassHook.ToneMap, + "cinematic-composite.vert.spv", + "cinematic-composite.frag.spv", + [RenderSemanticInput.WorldColor, RenderSemanticInput.FrameTime], + ["glow-stage-one", "scattered-sunlight", "participating-air"], + []), + ]; + + private static IReadOnlyList SceneReplays() => + [ + new SceneReplayDeclaration( + "replay-every-outdoor-shadow-caster", + RenderSceneReplaySemantic.OutdoorDirectionalShadowCasters, + RenderCasterClass.Terrain + | RenderCasterClass.OpaqueWorld + | RenderCasterClass.AlphaCutoutWorld + | RenderCasterClass.AnimatedOpaque + | RenderCasterClass.AnimatedAlphaCutout, + ViewCount: 4), + ]; + + private static IReadOnlyList PipelineVariants() => + [ + Variant( + "landscape-shadow-writer", + RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster, + RenderPipelineBaseSemantic.Terrain, + "landscape-shadow.vert.spv", + "landscape-shadow.frag.spv", + RenderMaterialClass.Opaque, + [RenderSemanticInput.CameraMatrices]), + Variant( + "solid-object-shadow-writer", + RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster, + RenderPipelineBaseSemantic.WorldMesh, + "solid-shadow.vert.spv", + "solid-shadow.frag.spv", + RenderMaterialClass.Opaque | RenderMaterialClass.AnimatedOpaque, + [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]), + Variant( + "cutout-object-shadow-writer", + RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster, + RenderPipelineBaseSemantic.WorldMesh, + "cutout-shadow.vert.spv", + "cutout-shadow.frag.spv", + RenderMaterialClass.AlphaCutout | RenderMaterialClass.AnimatedAlphaCutout, + [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]), + Variant( + "landscape-shadow-reader", + RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver, + RenderPipelineBaseSemantic.Terrain, + "landscape-lit.vert.spv", + "landscape-lit.frag.spv", + RenderMaterialClass.Opaque, + [RenderSemanticInput.DirectionalShadowMaps, + RenderSemanticInput.SelectedCelestialDirectionalLight]), + Variant( + "object-shadow-reader", + RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver, + RenderPipelineBaseSemantic.WorldMesh, + "object-lit.vert.spv", + "object-lit.frag.spv", + RenderMaterialClass.Opaque + | RenderMaterialClass.AlphaCutout + | RenderMaterialClass.AnimatedOpaque + | RenderMaterialClass.AnimatedAlphaCutout, + [RenderSemanticInput.DirectionalShadowMaps, + RenderSemanticInput.SelectedCelestialDirectionalLight]), + ]; + + private static IReadOnlyList QualityPresets() => + [ + Preset( + "economy", + "Economy", + RenderQualitySemantic.Low, + maxMiB: 64, + gpuP50: 2.0, + gpuP99: 3.0, + cpuP50: 0.15, + cpuP99: 0.50, + shadowResolution: 1024, + cascades: 2, + shadowReachMetres: 72, + postScale: 0.25), + Preset( + "balanced", + "Balanced", + RenderQualitySemantic.Medium, + maxMiB: 128, + gpuP50: 3.25, + gpuP99: 4.50, + cpuP50: 0.25, + cpuP99: 0.75, + shadowResolution: 1536, + cascades: 3, + shadowReachMetres: 144, + postScale: 0.5), + Preset( + "cinematic", + "Cinematic", + RenderQualitySemantic.High, + maxMiB: 256, + gpuP50: 4.50, + gpuP99: 6.00, + cpuP50: 0.35, + cpuP99: 1.00, + shadowResolution: 2048, + cascades: 4, + shadowReachMetres: 240, + postScale: 0.5), + Preset( + "adaptive", + "Adaptive", + RenderQualitySemantic.Automatic, + maxMiB: 128, + gpuP50: 3.25, + gpuP99: 4.50, + cpuP50: 0.25, + cpuP99: 0.75, + shadowResolution: 1536, + cascades: 3, + shadowReachMetres: 144, + postScale: 0.5) + with + { + SettingOverrides = + [ + new RenderQualitySettingOverride("quality-governor", "true"), + new RenderQualitySettingOverride("air-shaft-strength", "0.35"), + new RenderQualitySettingOverride("air-march-steps", "40"), + new RenderQualitySettingOverride("moving-shadow-opacity", "0.72"), + new RenderQualitySettingOverride("moving-shadow-range", "144"), + new RenderQualitySettingOverride("shadow-filter-samples", "9"), + new RenderQualitySettingOverride("sun-scatter-strength", "0.55"), + ], + AutoEligible = false, + }, + ]; + + private static IReadOnlyList Settings() => + [ + Float("highlight-glow", "Highlight glow", RenderSettingSemantic.BloomStrength, + 0.65, 0, 2, 0.05), + Float("filmic-mix", "Filmic tone-map mix", RenderSettingSemantic.FilmicStrength, + 1.0, 0, 1, 0.05), + Float("scene-exposure", "Scene exposure", RenderSettingSemantic.Exposure, + 1.0, 0.25, 4, 0.05), + Float("colour-saturation", "Colour saturation", RenderSettingSemantic.GradeSaturation, + 1.0, 0, 2, 0.05), + Float("colour-contrast", "Colour contrast", RenderSettingSemantic.GradeContrast, + 1.0, 0.5, 2, 0.05), + Float("frame-vignette", "Frame vignette", RenderSettingSemantic.VignetteStrength, + 0.12, 0, 1, 0.01), + Float("sun-scatter-strength", "Sun-scatter strength", RenderSettingSemantic.SunRayStrength, + 0.55, 0, 2, 0.05), + Float("moving-shadow-opacity", "Moving-shadow opacity", + RenderSettingSemantic.DirectionalShadowStrength, 0.72, 0, 1, 0.02), + Integer("moving-shadow-range", "Moving-shadow range (metres)", + RenderSettingSemantic.DirectionalShadowReachMetres, 240, 16, 240, 1), + Choice("shadow-filter-samples", "Shadow filter samples", + RenderSettingSemantic.DirectionalShadowPcfTaps, "9", ["1", "9", "25"]), + Float("air-shaft-strength", "Volumetric-air strength", + RenderSettingSemantic.VolumetricStrength, 0.35, 0, 1, 0.01), + Integer("air-march-steps", "Volumetric ray-march steps", + RenderSettingSemantic.VolumetricRayMarchSteps, 40, 8, 64, 8), + new RenderSettingDeclaration( + "quality-governor", + "Automatic quality", + RenderSettingKind.Boolean, + "false", + Minimum: null, + Maximum: null, + Step: null, + Choices: []) + { Semantic = RenderSettingSemantic.AutomaticQuality }, + ]; + + private static AtmospherePolicyDeclaration AtmospherePolicy() => new( + [ + new SunElevationResponsePoint(-90, 0), + new SunElevationResponsePoint(-3, 0), + new SunElevationResponsePoint(4, 1), + new SunElevationResponsePoint(22, 0.75), + new SunElevationResponsePoint(55, 0), + new SunElevationResponsePoint(90, 0), + ], + [ + new ActiveDayGroupMultiplier(0, 1.0), + new ActiveDayGroupMultiplier(1, 0.35), + new ActiveDayGroupMultiplier(2, 0.20), + ]) + { + DirectionalShadowLightElevationResponse = + [ + new SunElevationResponsePoint(-90, 0), + new SunElevationResponsePoint(1, 0), + new SunElevationResponsePoint(12, 1), + new SunElevationResponsePoint(90, 1), + ], + VolumetricShaftSunElevationResponse = + [ + new SunElevationResponsePoint(-90, 0), + new SunElevationResponsePoint(0, 0), + new SunElevationResponsePoint(6, 1), + new SunElevationResponsePoint(18, 1), + new SunElevationResponsePoint(70, 0), + new SunElevationResponsePoint(90, 0), + ], + }; + + private static RenderResourceDeclaration Image( + string id, + RenderResourceSemantic semantic, + RenderFormatClass format, + double scale, + long estimatedBytes) => new( + id, + RenderResourceKind.Image2D, + format, + new RenderExtentDeclaration(RenderExtentMode.RelativeToMainWorld, scale, scale), + SizeBytes: 0, + RenderResourceUsage.Sampled | RenderResourceUsage.ColorAttachment, + RenderResourceLifetime.ActivePack, + estimatedBytes) + { Semantic = semantic }; + + private static RenderPassDeclaration Pass( + string id, + RenderPassSemantic semantic, + RenderPassHook hook, + string vertex, + string fragment, + IReadOnlyList semanticInputs, + IReadOnlyList reads, + IReadOnlyList writes) => new( + id, + hook, + ShaderPrefix + vertex, + ShaderPrefix + fragment, + semanticInputs, + reads, + writes) + { Semantic = semantic }; + + private static PipelineVariantDeclaration Variant( + string id, + RenderPipelineVariantSemantic semantic, + RenderPipelineBaseSemantic baseSemantic, + string vertex, + string fragment, + RenderMaterialClass materials, + IReadOnlyList inputs) => new( + id, + baseSemantic, + ShaderPrefix + vertex, + ShaderPrefix + fragment, + materials, + inputs) + { Semantic = semantic }; + + private static RenderQualityPreset Preset( + string id, + string displayName, + RenderQualitySemantic semantic, + long maxMiB, + double gpuP50, + double gpuP99, + double cpuP50, + double cpuP99, + int shadowResolution, + int cascades, + int shadowReachMetres, + double postScale) => new( + id, + displayName, + [RenderCapability.DirectionalShadowMaps], + [ + AbsoluteOverride( + "directional-shadow-depth", + shadowResolution, + cascades, + 4L * shadowResolution * shadowResolution * cascades), + RelativeOverride("glow-stage-one", postScale), + RelativeOverride("glow-stage-two", postScale), + RelativeOverride("solar-visibility", id == "economy" ? 0.25 : 0.5), + RelativeOverride("scattered-sunlight", id == "economy" ? 0.25 : 0.5), + RelativeOverride("participating-air", id == "cinematic" ? 0.5 : 0.25), + ], + [ + new RenderQualitySettingOverride("quality-governor", "false"), + new RenderQualitySettingOverride( + "air-shaft-strength", + id == "economy" ? "0" : "0.35"), + new RenderQualitySettingOverride( + "air-march-steps", + semantic switch + { + RenderQualitySemantic.Low => "24", + RenderQualitySemantic.High => "56", + _ => "40", + }), + new RenderQualitySettingOverride("moving-shadow-opacity", "0.72"), + new RenderQualitySettingOverride( + "moving-shadow-range", + shadowReachMetres.ToString(CultureInfo.InvariantCulture)), + new RenderQualitySettingOverride( + "shadow-filter-samples", + semantic switch + { + RenderQualitySemantic.Low => "1", + RenderQualitySemantic.High => "25", + _ => "9", + }), + new RenderQualitySettingOverride( + "sun-scatter-strength", + id == "economy" ? "0.4" : "0.55"), + ], + maxMiB * 1024 * 1024, + gpuP50, + gpuP99, + cpuP50, + cpuP99) + { Semantic = semantic }; + + private static RenderQualityResourceOverride AbsoluteOverride( + string id, + int resolution, + int layers, + long bytes) => new( + id, + new RenderExtentDeclaration( + RenderExtentMode.AbsolutePixels, + resolution, + resolution, + layers), + SizeBytes: 0, + EstimatedResidentBytes: bytes); + + private static RenderQualityResourceOverride RelativeOverride(string id, double scale) => new( + id, + new RenderExtentDeclaration(RenderExtentMode.RelativeToMainWorld, scale, scale), + SizeBytes: 0, + EstimatedResidentBytes: 0); + + private static RenderSettingDeclaration Float( + string id, + string displayName, + RenderSettingSemantic semantic, + double defaultValue, + double min, + double max, + double step) => new( + id, + displayName, + RenderSettingKind.Float, + defaultValue.ToString(CultureInfo.InvariantCulture), + min, + max, + step, + []) + { Semantic = semantic }; + + private static RenderSettingDeclaration Integer( + string id, + string displayName, + RenderSettingSemantic semantic, + int defaultValue, + int min, + int max, + int step) => new( + id, + displayName, + RenderSettingKind.Integer, + defaultValue.ToString(CultureInfo.InvariantCulture), + min, + max, + step, + []) + { Semantic = semantic }; + + private static RenderSettingDeclaration Choice( + string id, + string displayName, + RenderSettingSemantic semantic, + string defaultValue, + IReadOnlyList choices) => new( + id, + displayName, + RenderSettingKind.Choice, + defaultValue, + null, + null, + null, + choices) + { Semantic = semantic }; +} diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cinematic-composite.frag.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cinematic-composite.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..8950e78ae7f6615e23aa15c972b2814fbed6aff4 GIT binary patch literal 7016 zcmZ9QeUR5x6~=$dBFKxpgn)(M0x=`9txzfp%Pz}8K}qZqj!IEZlT(?3Falz!gE1tI z46DKxD$T@zl2%jX>y)JlQT?{l7W?!D)} zo%`Kw95QowZCFEXRPDUlV?%27GQKvX)&R18>imKF>8k!Q^{Cpgx_$Y|H7i?g*?fJ= zWtY#j;h0)uy}fP6)<)KbQY-5}d&33|ZAcfQ(vS4vIF2^@3`drO^qG!WqR#*u+H!n5 zE4%_|O1;0C0DE2iDDY=;%DB+AzuCw5VRNS(Hb4L2DC^+MlTUl_%AlFlH!z%8o zf-^S#t}VE8D{dXNbG(V#Sd3L{v5h5M;?F$OM!%-QuMsTg2DzTDz3ZFxR-b|Br@fzg z8e%b)W(InaJr{qVz%2z{RN(dkcNDn0z&!Fz|R!; z*#iHfz`rc;uL}GJ@CDVJ|5)IY1^!ckFJgnTE|(N|L4g+*xUIlj3w&#Vw^euoyANca z8teNao&@qdH6fGPC-+a?eN#WL()FE#u5Bvv`$J8g(VNhFUp&1i`YiN|c1{_zUyR;9 za`NKXo6%>l8`}}xGh~TAudZ!h{NCD#+I+BMvPAWAc0GkiTe0Xj6}*62y@^;CQd{KO zD&0I_ac+yK?b~~I0l(X+<aw|F+*$*^Dduvl&cTd_KKw0i>VXz@8u5eVN+$GoRg+t?Z{4?zc~@9Xtp3p|71h zfAE~#kG}up-u7mCvEK&}+vumwagDbRX(;pu!I_`q9Y7{O^HOW#Ka5W4O#9~s^jgL_ zhVGe+WPT?qKj-%{*fG5yuB&=~#iid@(A}R4vA;@fEOLqCHSk}KuX-W+>*(X!jy@m# z&jb44dYl^Y?`*D}Tkr1;bo;QKvFfjn@x4jynmMlhdA?2~+1E2**U0md@rE#=XHWIE zM{h(Q_xi!X{TYgW{I)N4Wc+i`ZKIzyYxRsb0nE>!ekF=;Qu@DJW9?gm^V?O|w|5x- zKCrPGpX;mcyDpb~I>ZFb6_N0V;hW$i;U9rF7e&H93f~O>e;GJ$N8ml{k!E(&{r)z5 z#=HU!l?Vrl@x2A(zPIt)eeex%FWj$iz6EDsfDS zbkEilbZtLkT<_(DU^(OeCo4A%YuUM?ypUmY+2b#gXFdGpk)>pA$Gl%+?^f_hu=h#cyCk;nm#JMF>-616 z-^b{~@eYC~All5G`@knE+r-Ltlr{C9y@J1X@5dR|a8hMQt?!5SR}p!~6dS{9)aurW z!Eud4pE1;9k;JecYz*4QSGL4(3Ql`scu!?d4BFp75SAe^y%RDk4PCj|06)cxL;@>cG_Td}h z-!bM9`OL|`XXKMdI`u)yIW0k#S1f1GX(_z(($6!P_^or#9;$pim-;)m^1L<_z7K=V zJDzFV+0Q=aU|wDY*6z1ya(Fj-a(ExSbJMp6UR(TD7k=eC{t0+(quC+btpPhnzgx5& zpx#l9e>J)>*tdJC|FvLw+j*|#J_$ZdEw`)UJ_XiS+i2cT{nmjUYYw8_GwQca`ne8H zA0_d94xIQL*EaG0Ji2WhH}QP|Y#VLq;|6g0a6Zm4ao&h-8-4Z=yXSTT*mc*I^}HFJ zb$(nvV@%y$&ncJdi-G}tk< zxdz5R27D0F=D8b7?H#k;j*oB7c(C#Kou^OU3v;;o1Zvw@r_DsL@uf}Pxk+#t`(par zi{6LW&op%VnN96n{pPy_+(lh}^UZ;i&wXwN%h|^J?Ei7R&+@s?Ens@3YIJ5U4|~NSgZ^^@qQS>nDxuO`v};3=b0<>-Ewqo`DR@KmdiKm z$G{ff8f_m%v1Kub1_eS94w#pSOu2LdUS*3vK~EP*F!$*u^KF&^|%@= zSFXo3=<gfZu{T(=wHwdL)*3CvHuA8mR2l7rU6=@ZHL8{qh>_YTQ?0dk8eItI!554f(($9Theg@76AbT78{gvH4^n1r~ZIik01v^*$veplR z`KkA*J?Ad(*h6soSi(JAanj)qR2)me{tmDI&GJn++v~60nEV^I53KE7Ms7v^2lRh} AF#rGn literal 0 HcmV?d00001 diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cinematic-composite.vert.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cinematic-composite.vert.spv new file mode 100644 index 0000000000000000000000000000000000000000..c493caf5fdc55435e07a6281e1c59996895e1d82 GIT binary patch literal 956 zcmZ9J$x1^(5JfwqVoYKj;uPgMYa${zAcz`Qx^SWR1%ewF>Owz2aPLOAYfS$?9*n^AL~ zUr}pC-I+df&UcIh^;9!SZm+*JQ*iZb`gpfC@SOTb)Rv;25#OV>O0M1qH)#5;19ex@ zHptZq;2H<#vw0LnZ8lvDHI9wYz_? z=Z0#Cl11*A36r4!i<`^qTMJcC2W`*+HTtcWz!1-R4`^B3$ImBml+4;=%{|M&e+}8w zfFIGQkHkNJ{?9KY|8+*QPYu~VeQW-!!B_6ls{yKPn8x7{zx`BTfCX)T$hi305nF)Bnj9O=bI?vPC zH#2uamzsSy&u!ir_4|J2jn%sd)SB&F!dL4Zb#--txifYeuK;JXEEnUO^L(dt`R+Yn zZw%aV!MtzW2WqgM&99|-&-g;0=idb8y_XM8zYS~O=(4s2%y}pJg=Q{%;b%Gd2SrEV E7jF(ItpET3 literal 0 HcmV?d00001 diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cutout-shadow.vert.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cutout-shadow.vert.spv new file mode 100644 index 0000000000000000000000000000000000000000..6e077a144a53d584cf5aacbb465229f7533b31ce GIT binary patch literal 2668 zcmZA2S#wlX5C`y^OcoXe680@l5)maD76YObOA2&i5-}_aRKbH)yzn3>;5%QzZ|jRy z{(pCF!!1u$Pj~pG zM)ZT>)^unizopMcKkz?nIl3jzq41f|tyUj2hr=gBVv?<6Au&t;;6G^VW*18jiy^D@ zrLo%T`a{+zXYxh(IM;M5!}Vmz$L`Evcwu~CPPH&H%(=?_Rd~Oz^U

vQ=kQdjG5n zt6t@SsiVar6ece0Ko3(7Y}mup2D{M1)Bw8`Ox^B&F{Jj=mm-Vj%r1pjA7rcgD81iD zVRBe%h9%uNZx5?J?({Hu`EK;E>SMcyRUbP&Ob>j`Z{<}R>~Rm<)nQLN>~^?ipT!qe zHOpUE)htF~gUEb83OPglsKq;QhTn(R8IrA<@zN`|{iDt<7TNu%rSzr97JVz+9B$^B z)Ajyg=BL_sOqXA5Z+c%VPqi}JIQF1@>t*xq%kN;k`7g#_ujIqYrN?22|4OItj%*F1 z?+ItK?^>s;6?P<%*y4lf{wqRJfdaVm}=|5rXrl*ZJtL>TQ~LH}(Hn?8A`7PCpwO$Czq9kIVrZ=mDmW)o{7TXSJ*SZrCGNkv(Mj{7JaO^C9`@ zz5}p@Ow*-s@!eaW+WAhw)npaS0DefzmBZFEBS8W=D-r; zpCHrW*MrW4^YsSTo7wg~oQUj=xI;GF-udv8A$Gbq@kz*gxR;2|WU<4--eXH0rr+AP z++k|lNnUsRX^6l5?8DICiOY5-B)2{5!OYAzA#-Jxs+X%BzMjs^(RU$ptOvI0<6304 zihn&aOdj#g+|7_T$M;4~j&5J&|1q-sW~cIRMuwNZ9oa0sOH60|Q^+2;8qL6*^G%tHedYy%~W0u{RiYO Ba4i4; literal 0 HcmV?d00001 diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/glow-filter.frag.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/glow-filter.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..081ba8ddf06c52df3aec31d105bd1ba3712cadc0 GIT binary patch literal 2496 zcmZ9M*=m$Q5QRI*7?-$2z}l>_Rdc2EvX|dNbgeJ7N^$Kg6sNaX?^{g*YB(y zslApT9iJNSd-&*j-+_aB+_^Bd)edm)qBJj6SjD|7ckY{50M>vi*bb&aCwr`!4My=h zSX;qZnpN|0Q?Gxp;5=Whv*6q#S1mZz$SH1=TfDyq)bFat8;GqluQu~q#O6I)ZQa=) zqgG#=1=JO>XNRuf)SyLO=pQM#(0{z(LjPF7`Bt7cUT|uVn=H8Y%$+SbwOMze;O1oR zBC9=aV*SjF^|uuM)`nlH%`SSguD_9MOD7B8GvllW4Zl*Gd+hJxb?v=}bL>}tIiC8f zUWW5z!}Hu9HJ-+7_;JoGCL{Hc8tuKn~$Du;>!!obIO_ACbs4l zh?(5Qw=Z+i(*yi+CJ%|5^*@4>*Fyhee7REp6Jl%4mHM9&dyo0h{|sMVXwCYc!&y`6 ze?e>y=0e8|zPuLPYkWD+4DLOwz2aPLOAYfS$?9*n^AL~ zUr}pC-I+df&UcIh^;9!SZm+*JQ*iZb`gpfC@SOTb)Rv;25#OV>O0M1qH)#5;19ex@ zHptZq;2H<#v#d>Y>$mHOSpMMq&OERCpSMF#A6a0qlUW;aFeb|ohGe@K8HkRZD(Na5W#^ck(@ZK)j=NFVN5YYRr+JwG4jX z3$GOY5O-Qarsmp1tb52iVb-(?x;WFbv|V4G=FxF=+dDXB&nxk4Mb~c?yp37A%3ceY z9csI>Z7(`JtDV`ieEVhmS|2s-4t^IiSAl10f0b>f)XgdOrZM`Jp`UQ?`rRstr{^}Q zh%py!zZ`*_|3 z(DmP&q1g8_He)R8;*T%gD$X_jZn0d?`vKg$TSx1qJ(IQVysqciUk?4=qxk0I{QBn0 zKnLgq!#UsOSwE6(|4Y=2u`m2Tv9HyQu}A!0QP;L7dO#H%CdNDV0X0WxtKn)5U@F&e z4P0N>dcg_ez1z3IGh9EE<`(r{n|0S5?&E#qZtLOR&AyAfZGfxC-TL8baX0_>yqkL5 z&Hq33$YnEJt(?miY<1y?TyB%Uy|ERTuYTj))jr(?>}~tm9`-zI^*wOyC9(GFXx8lG z|9Qo6X2(bTT^j>xzHOa17yY~!&HL{LvG+c>`tQWrOZ$OZ)T5TQsK*4h^-zy`9Du7w zJr2Uv%Jn#etu7q1>v0&(n&|g|yd!_-cT~)pJpuYO1O3OqKUmgi8o2H^7xg?1k9zve z#huTmwq+U*M6S=Nc3Kzrv02+{o`YxG}E99)G}N z5AWk0BhQ;~WAu4M?!L(#@C3LP^}GjOwz2aPLOAYfS$?9*n^AL~ zUr}pC-I+df&UcIh^;9!SZm+*JQ*iZb`gpfC@SOTb)Rv;25#OV>O0M1qH)#5;19ex@ zHptZq;2H<#vyOeJP=h!EbH=#jXflZ= z#w2dzB*L(xKtLlJaRC7rP(eiuvZ#Qdvdr`Q_xmt)`j|RhUp@D^w{G34x9Yx2_tz^d zzx67!m6w~XFKe0oS!cHVY`GxqW$Q0(FQ3}ard?yU@?3xL;foL7<-eVM$}TV1 zZC4$iHCticU*BiXHk_@PR(;*cuX#Pi;?$c`PhNgD`$p=uF~2|cvD6h~xO(ausT-zl zo_bX3(W$RYJt_5^)U`84n%OF;2L~OMcIDK4=kf}(W75X;?2zbSWu??Z^0TtOYcIoX z9a*WJIn23$a{S`f9=L=)=e8U;@v+-=;KYF2H=MYw#a5|eue>mrKeBbAS1Qwsf&9t< zea++G^m#=ZBssUN?v!d@xYu?$eIW34@Ky6d~;z;%6g4QG7kXRFxoS$W|z@(S~_ z^{h$foGl1meJ-ysdpPo?Im`CWBLjZ)8NA~BTp%F8t_*KeD8p7 z%|&Ybw+;CA0k575SDQ5kyyk%S9q@hw-haTK$OW&TPY(D~1ODECzdzs~40ye4-uhX8 zz#B|>z3e1=X{+o7dj{Sx$o||ib%X4|gf++Z*hZ1pO-s*~yy+zCPu^vcbLnRrN0ys# zWd4pwyJf1f<0Ky$Ouu=4x8wW5*f zHr@qu@&EUKcjU&+{-V?mUU&QR+y9cmp8nFgeC{*;u8OSxPRT&7+YPD4R5wXh`BGZ_ z%t2YU-7I_I%i-B=5&Umy$>#Hwvw-KHy7$`) z;vTDgpo&v9?$=|RV?FO+qwIb<%I>FR;~zO;`-(rZ`)lpV?ysZl{yNGhO>+0=>5-km*6xWj!~gYP zZSu_idqd>eY~Ov_?~Re!sC44{leAfmXXa0K|Hkc*pG(rdEAsXezA$*XV0&S^yqDDX zDZ%63dtr8oYZUW&;qUP<_Rd~m?U4sUx>nbQHO_;7(WkIeiM7Thf;C;!26}2 zJ@|`Y`_$g#+kX6IIQ-&C_FRS2N6zqUC6D0sk#9U>;mP*Pm!@*-()i4+b_|d z9{F!0JD2h{UFY?YV7m8YEPkFyAHML%gfsW2BIA{EPfwg{e{`(unG8L*r8D-4>2I$t z7hb&dzs`DeU9^u*IbpS26M4a!v+tTYBP&I=&*kpMy>euHug5CExL%Ldf)&?;Zq-zr z>j8J&)(W<6WU)Rwvau?!6YNY?UN6|0>N;E&tWQFpU76^Yq`f>cUM#h{Dl%WA-PMut zqun);jWOC?8yU~;x@nB-BXxIeUH98f>EjyUyH;P1j91EiW8%jBc5^tk zbjI#}yCqnB^zDCZuk~Kl)e$w%u3S+CC7y^Sf#|{rKr#y)U@?++JNL*IKd8bU#bQ zS!cNGe1Gt=d-Z{E_^z|}EL_+5phfH7~j2mSjG}h_v+!1yI1MDSC0s$>t1~{ zee6|y*Z1+rc%|I0CvLoEzX`{d&e*+XzYP{2efvKdELV(s^{L4C?$xyugJKQoo=(MC zL%3@wp)R{um(w@3Yv?@`*ERGmY7OySL+_;c#<*Ir7?pCXPuw`~HNvr_6MyHuW@J9; z+y7a?_;IhEoxOy&PTi}Wr?&g=(b%!EfAp~i_G&W-(Y^aSJw+}uC-V9 zh^$y=y7g0W)*0?PZxFofUfnPpzU%B=9@lmDK5w1zU1#t0`0mwXg2mIldTiwGRl4rg z#ldvls|$kdReaZXt6;oRZsEj@*KF%>Z0U^MYqm{f@zJ;cw!!XK<6hk^9KL(?_k$H{ zNVg~zXAR-5;r7AH?$sT_;k$-A2IIPhI|W-qeAm!-4}4>MUa%OIa?hW*ao#To$Cgg~ zo%e3ReA2i7i-PgvUOgv!32&XcS36H__umt-V`Km5V-4)p?kjC=AB)~zwSV>F$G$u@ ztzu0*Z=4+Y)v4AL=8p{--?&c+ma7|IZM4R8kA5WulC$gfQ` z25d|=WPHawBiNbin6=S%%%`R?`C+fWG5;uS$MhWZ#>i)-8Ur>a8#2CQo*iuMI%aLO z9rNjFOn%tw|E?rPYw(s}>maUj=Y+%S_s+@gt&y!2-I~+-y*=2zT9iuf{kQh#PWF74 zJ8$BQRqnq}oUx9|6TRo_3&Oc}s$z_|o}YR83!`UajK=)#U}G7he%=%8dZ@~>e%>2B z8-3rAdGh_fVE*aq`vbvZ7HfU~MX+^Om1TYZW%O+Ht?&O8%s*Y2WCeE52J-v5)G@SnI>0K{-s_zQgp2dx)cNFg}`q@kJkav`g zVz*)H_>Qu1WPIOIHVMWxFK!mBI8St&rsC{nSU&Skwjg+XN7*v6vFJU|Ee!S^-+6vI zaj`Maa-Rvu4%d6#CE?gMo?izm;-R}V6(=6pT)o3y7QF1e?(%T>=IJYfar$_Ny)syy z#y3x26^!q_?x|q$^j`OLaPM_=`Z=3^(Dhz-ZLl@McfQw$!z<-(m^gc)`|R`K*wPuh zIsXg6;-hc>F9uszIi=V1#&Gzq;p4%IHKhAeD$W|hUBfR2FMF^1w{ZBb;a7rjUBjD# zts%Z^_*GnLW4t9;j7qt0PTV-}Tf?!X6MyG@TQHyW?fc2eLJ$eN7wg?3v%Z1 z){brW*&UJXzpl|;!HPAayE7GMjo^;^{a|}`T<;%*!}H~wwx8BHT@wE8V1BJPdp_Mu z9!skjm(36Hsm6u7ruPPqYx=Xujia&MKXLq(dtlIzaoT=+bniGc z9KP`#7K~Gs&U4KqS8H>4c-(=hY@HeWF~LWt*6!HBj;?mc1s|2#m`)7#jr_RyU?ZlN zgu^$cKMcmHN@H@S&zRp3^6nSx?}|2#>^y7TNBrH@Cc)-yjJr~e5`8M8yU_}n0+aeWbOxT_m6B%!hTA!_g&D~tAQN-E8{4kgOCw=dG z+k_YIyHm&KiRXl;yLyUk+h9KMVq26}5gXlhsW`E~V{CYR-knzI`kg)5YrB4b6x{W* zHeJ6z4o~;ijOUy4n}Tu1d2?FhYMp!f1=)|jKl6QI>iMY`Pjh)|Fg+i?iogFc>0>^h z5B=Lx@%+MFuXBUNcUmeR;tMrf-_Ctu^!^UQda&i6&K~8blL3d$VI`RF_w2JuHxK=phYmJ2? zYdh9fu{ECY&q{x`UFV&G>0Jk7>=Icq2Hno7I6h%xoH5CGea>5DX`k+yizdDH16h+a zvrFhHWyqLz3b@jK${CL4~K6qxFQ&*D$NDwP1oW*iOn85 zEjsUGuFreJvD+b)-rVgSfB*dc$i~E37vmlt{O956YWIP`j=$P{FqrO`_!H7!2J72W z{;wup97_2QO}u-Y(&y3-hv!Q_Hq9A-6U>L4!KQvb65Mg=%YSs@aU=f^6K`C6$Nk6f zeCfxg<9;ldopIT?51H@BgT+MOd*LU7&!2RS<5S_RRZIC#PdwjB`G1=DW!L4O!}Fyd zo5uH<;Ks+Mel8B~xb)>OnRwjDUpDc}uFK`&`O=R~$GsxB*n7xc-OVI*F@HbP0!G^!R(zO`ktXaKU|%T&ImhWxc7WEa>trQCHBr%#`;t`Fy|I>VjM4Z&>bI`7X1^GR2KHwGKSnl{caMV24v^cClqgWV(P zYIjqx9Kvtyz8cJyu4CL1j91EibK=aS+^rKQmVN`cEu8-B>CN-~iOI9b?U9`;HqAlb z4#ukr|NM1~?@l_;p4}&RgkzVf%;1>==o zpSvUD&kxt@^TWt^T>JhwvcBSL-+LnC&kxr<@RLcVJ38y*+r+)WeA3n5zX$VYO?vOW zKQcS}^^;iz_q_)qrrKzGKYciuU-u*S`ghDn zg6WO<>$Hk7@sFnBj0rdP$AisJ?8dB(wqyP#nBRFk_|%U1+hBTQ=19(qG4W5N;*1G* zo=*oGv-7Ntwqq_A%&$1u>))7Hh|YBo^T%_otoMrH^r7oGD^GUDsog5!*wTr4wX}+u z=~hj}i5a#go(ahK8#12f={18lO$`3+OrNpW3WxXak^0>DtjKddcWd|SwIkb$JEzjQ zH+vTD^XNL^7DqlVwa>@KTQ@x28xm)Rovjy)>sebrm|tg&&YYWTH<)BLeXiUv*f_@S zb1%Oeg{Q0Ejf3l#j^DmNY#P~o`nJ6PUxuD%xRY>CJD!+kv%e?zdm zg1b4gvGXcF*LRI>3EwrkAUe-0*65qzm+F?vw?@Y8moa>&yDj*pw0M2|UH|RD_%o+< z{Z6oFrRJ$SBR2=oos`(*&hG{5OK<+ow?Bw%Z;sVk>T`E==Cf-mJ3g9MeiTffQ}hgX z&%}-S_ug>UMd^L>r;+(J20!|-tMB`Q`Buu^KXLli&qLwrM;|t$pNAv&nzYX&LmzSQ z!EQV^_CWW{!eD#mw2bTgy)9U=XXv(0Jv7yxf%(~HlC|=KeY$V1k@L^yfZ&4$-QI)l z4+fp{_n#xXUiux9s^7)gA9Bcc!Sv3A_p}#J`iS%R=oh8p#RrRT`$^U|J~4@r&3>sn z4>~c4k#3(sC%*l{i;wPrN!R#xicH`54xIFjkKQy8=*WT-%7nu#d zXY%>McxSR_celuRd7<2kg7F)qx)(dUdjvb3i;1 z4gOu{ezovRbxY5^f0vExx%Y3goqK%Gy?>*P?|M8t*tzfdSUa*3>#QHs_?iWzyxc z&Gw8=4x-y5c+~y=pxb@YT{7jFBV*66xa66)PJScK^9=n_sd(|h;yZehwaqie6$hKM zQ;!>T#uW$M8wZ_vy)e9a(Y=^NjhCw=3iHx6EWuzNPYCj{Fg`RqOS(Qg+g zhL^YKd*)sejBnqU2Dh*EY2TNH*O$J1UmlF_GyN-q`NP-dRl(*_`Q*s_Hs`%MG8=qz z)RJJle?wmG)X4aaQ_KCAVEmZ#UK?4?qpRH>Srq*Gsm-f$>;g{-`=Dd9)te?`nb6jM8JrnVxAG`WK zA((Ha+)E}-zxsJuxcbqD&FJSbdg7w2J3Sx;;~I#)JDFv{$h6by_N)&Pu4+-uXdSyM2P$$wPOg)^6YMbe;cx z!JR*y-#zI!h65t=%ck}R2G`!bgiZI+A(QU8;o0fCFtvY2a%eCc{ml7Du?NgZZ<5jp;*? zedDClS4;AGN{1=1y zm6O=(-!X3trZ?tIX%%DQzm$qICfwM+5^R2AH)d_L9rLTf{ECCU{vGpc!Su%bW?IFV z_^+qpj0tz1w*(v0UNmNHv>o%-V1C8HUjN2?JDe)!e!KiuWPRv5&Ub>@8K-vNjm(x# z%y*_$#7uWbDo)I>_}mxB_r<2U4_w0WUHjc5!Z=Cmqr>oze z1=lYfzx`hIKxFeV2W$CouwpIg9!$kqJGjsNAI*9?TjJ?+|KEqh-;nBr~!{~Ot!8>_XHZ!e9`d_2#y? zOTW$1N56}+KjhZmgrj#Re0$h@(np-fNB`SYym(;oJ&~5IZ46=(Bb)VO?-!J-6O$O} z)*W;aXUB#YAKgZiuJJt`j=u42Jn3UTkB{Csc=5p9*UJa%TY1IE=GuGRN|D*%dnQ*7 z#ygWeyQ@aV%M0aJkBpaR+>4#vwIVy?bhZ0dFn&Du-;RtQ&;55J+lS6Qeb4AWabjzJxF;N2IzE~o zeiCd9`Y}KJH1e1m;O2+>qT^eBV8=(tzCW0L%nuJv+?XGJ9?tqHJ)^&f+;h#3e(dV| z;b6X%a*s}&e)aQsxcbqD&FJTe$h~&$^W@M+9DK0rGn@VIJg_V0yf{DZHGQvj7VKYn zV?1*khbKG7_Pn-zoNs%)eVRu$pZdr>`fM@D3np3Xtme|p4oZLD0@)mydibQvR+$|h zog74WSn#MjbkMzc()AnSRBJ7Cb6*TLbBNbxId8LYZ0Y!D&f7fL81!S# zTM+p_%6VHx$G4ovj*pJLFqnSKdD~3fnDe#`XZ@7so$Vs?>zRlj{n*v__Q8BB<#wDn z{px3zaP^}Po6*nnBKO+0&+~^q;^2c__v-#xV>b3|bDs0*ywa6(o}F)Y&1ueR&*qpD zoJF!6XI#%Oa-}@Swz?z9lTLo2>-)muVC%Igm5=c~ z2K~g=^*uhxx7WlsQGI;tpN$^TPz8-#_SsBDZ!1i??<6#r{)J;eV-O=+#M&KvH9vg zJw19h#%Q1aI`x^q&&1Yxv9q3Mq;);DkIwv0kBKXKKDUUV-EL}kPRp0Iw~oG%y=uj! z|6gO}lOvy$DwonP3C8j1jNnd*tRJ2A#Jwiiyq+`Z{Vs$#(Y-dG6NAOZzTY$cFc`02_vK52`LLJi zyO;ONoc#{W%fp$+_;1W#5f0yPpRWwYsS1DYGrjMf6y7+k#M8tLoW^d9uK@bpT#w@;ig%KhoYEsbyK_l0xA>Cc|tdD}bttcGfCXM4Nw|$yo;x<}z(Yh^6L~3H^<(OA=i~ zadHcB+=&xPx+vXrP>~bmT5`WEUEZ(X|MUF&o_&tT=Xu`G`*}Xk=lOg--`~u@m>J`H z3aG=*88}`QZ){15x%sjC-u5Zs@u*ZVg%a2|-DsV+RL6E zT|VnQnb{Cy6qfLj8ai0jHQ2%A5$jUO|5WB~pPr6A(xl^|6Z=&eC{aUy3KZ9*>wlqU3T@_ zJ!aQh?ljxIu$KGHp3rP}on5uK_w050{(;_svDKwVbitJv*4%VEc41{etZu z(Z`RcII-TEGy6pB>J#0-H`ERT!$xAp_P;CBVvn5rh&??v-fM!wrzQ75<7}LDtvMJm zUK@|P1|yrq=gipR*v^El7hC1u^vRK4?}VX^!twdo5rC&8;-M`-VNg6vSPfmw)?~?H5@X@y*;JO#9nE z0{>As>;oF+JNnaxEo%5s=HeaHcuP9GrP1MC-eHY*Xoq)rbU0qsc0_cw&5TrSM@ENN z9m}G_y7@oWux`D}gTcGJPc&Y)-eZEnYfXJVA>tdoDtl)SM_|6wmju)AlOtmF@x^rd zGntLm=_%i*Bj-o-pI&jFi#{A#84>q|%!W9=&qr28#L-{QjKhXko-aioj`(em=gXN5 zdH7C^oE8yBmj{OpuRLc&AC8_if4SD#!8u@xe99p-t}Ze}&g-;I^y`Fua|p2+3bSNX-r+pSmK z#eF>xbBC&o}an)dSUe82)>?u zH?yH2J7j)yfn95%f2TpN8jQg`9{=!QIe*F|oKh@;Dc!-iL$8>0_Lx_4o>oGV<7br&ku zT<7pqdaobyi;=fmueyu-&v50kCvoz0d!$#zeX`}%M{)At*14FA`;Yin#&&k%@w+?jk~n#)54S|;UzyoG`C(>5KlyHr+#b#a@~>HnqsWg z-GjXp_nB~i9Q)RYIC*x=Ui_Jp&+o?F(f<_rWAokH?DG9N_WhAbsT22sNPbd#?}_lq zcVA|>-=;@1KNfj7@<>FUiJ2ddz<15Bu{=*S3~tDol1A70d&8`QJ1_f}`?<)ok(EvK zwbpn(I)AP4LUb50YK?iR;l?}opJ(U9Z@qDuC&q>!)%HCpI_&1`X-fR-M~6+0?!Ma7o>>|`7@f~r{u~UiwKj=u zt!=}pwKk0nFV5>5XQ;!O91LFdoEKeO*%w5|sroL8j?=C01E~Y9KEC2!8oeJMy!NtJ zxNuy)db?LdUlh!_`3C$tw$cB_N53+1L&Te>@80gf)zQ7LOudsDc1?^~vH6O3UE|5= z+u(g)9}JhzySAqM)~UK~jIJ(yRoAa#8|vb_DRNsx9KF{2L3DK)RoBhI)Wui4TN^La zDDQ`j$5*xfD7tt)wW`CK)~;G_kFHkdtJXVX8*1hINdzX2UN!tIdiAidI~pdw>i4W@g#kY2M5B5^rm&3DHy%Z-;_iUU|abJqx8M+VR7oO8w*e-^#mV;PSc0?x*+Vza#nT?Q9l(dZhc7H;)Z3&K8a14%Zu;84OHg^A%6=d^PQ97GM91mAuf0%ug2r6d;Cgt@qB7khc&HTwf;A{T8FHWJ2Jjg zD_`h+Oq_qJhB48rhlP!8SfWPjT05Bf`KqpQ(Z%zr%bN0=>m5wWnai(Md~0|2U@yg; z7_PnQr8s%IXXA{DyKel>(0vdmPt`v$I=@ladJPMG^nLmN1h{5Tw za`z1vj>}hfW3%Xo2dnor6x;B=_%@Hsia0xZ^=ymibvNoeI})t!RNN`?J45$DoIF+kd!q9jh0Sl6 zUf1u}F2Qj5+++9C`Dx^HH`)C3>2m(0ao7+rjqyMMTFT)w&+i=rkMcg!czjjsQPIWosZ|};w070HJi1zkBHq_0V;gGaJ30arN3R+_ z9=&>4*f9+gU-hpDrhdMv>)7bx`P5bU&GioE>%tgV%dnm7Lth@42H%;Hb0h8sy?S<5^tu~$56%we9`F_K+l?n@-HmgC;qo~{Yszn(s_Q$^ z)m887y4Z%g_|A)58WBgY^)86M=KH!Zn7a6i_ua-zi%0MA#f`^TwO$fkJfB+CVNGjS zt(QettMm1~u8nP|mGANhOdP#xxFUM>u&}EdCcgT5bujhwRbAIa7tg1z%5ScBuv5-l zezoFTySoQ_Dek;*?Nu+u$XH?v|@jFBJL7Y5Q|M#Qw8-?A}Fukt#b#pLWKKI!D z^uFeY#OLko6@BlBzc;J*H6i*w4X^&}8yn{T!PT1kMgLQ{Mq&FmEK@)K!BGy0`2S@q zgL#_|X8usatJWi8%O~Di&f>Jx=kMH(jE#?@=8ZBhi!6@FX-{lC{X8nOdtlbj72zz% z-f{K#v%xtO- zhJ(rP@5bb?hJBqFjIZpIqT5IP9o)&$ap3dv_f;Ea{&d8?E0?{)aEDW?+U?IeINg4z zjlC+f8qL+8_qDy@d@b_DNbUVA(fLoyZ11Nuf718&&W{h5mtOUr5xwe_L%lHPTlHFx Ruj;M( literal 0 HcmV?d00001 diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/landscape-shadow.vert.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/landscape-shadow.vert.spv new file mode 100644 index 0000000000000000000000000000000000000000..c79fd88cdaa785195a28609ef3bb1c3a7a88c0b7 GIT binary patch literal 1436 zcmYk5PfwFU5XP6b7PazEEQn%#OF`5s;h=_uppcl9Lk}1|c<^H4#e-hp1NasEwq8j5 z{r24(1IJLThQ%~k_g0rsNYQ?EXuBkY+$sJal8stvl z)a@OOf!ZV9!MC1wwuy~7^j$GW#PuA(*~6t~Ioh>-D=y~vSaJ6C?0Ln-9P^5cIW8*B z89e)?;?yShwc>6U?i<#b-5&whI@79a%aw9z7P1JBtH00gG+SC2x68nzj?6Zt*fOYL-yaHSgiG54fRafX(E3rC!qvpst z$2wN+=8y1vgXUNxV2-}}hFI^zJoOp-e&j}|_II|vx@y=%cn9r0_R!njhi9y1?0?le zirF6FJG;Ffm%a85dinRVul<~5jJ0;;@8Ek+^42$=6wdnQ?tzs4e6ao$&$G_@jyG#< z@5Lqm55_ll##b%dK)q@_AyZvXfczQNeNRt;_v3Hmo_XqRfP2R3+XZU-L~V6FD;(yA y+b^6mMc(tmsr3ToYJUMdZ@&A^_?Nowy#x&~$2pw&74VIz@ja6on|ICa9fAMVRV|7D literal 0 HcmV?d00001 diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/lit-air.frag.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/lit-air.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..d782ffe1091c0e44645ef1db7153a1a1190a9d13 GIT binary patch literal 6276 zcmZvgYmA;%5yzkHzU?mTwwBnUrLcL+5~0v8)!2d}c9*uh6`R<)1SRo>R#H*< z2F9pOjF*-#Bqk)@K`52tR4hojwN((1p#FZ(d;Xgv@#LM(%zx&b znK`%TZ2M=84m5N6nxSTHGd8Pf+bPYgrVmMe`+^?7qwqW7L(QDlKDA+b!`LUU-ZJ*~ zl`HHxujz05+xHF4aMOWHTQ^_1O~W#19kdrZ06h#n1N{S$x3Y%T9jV;z` zZRM9b*H>8T+)!buv$yWO7+KTHDnD~Gmk(ChV8K2Fx5h*8eVh^H2P^rZ8M)K$`w?`T zen;WvZ+*(Px@NJ|oPC!*`gNi7P0cI7TqDnyYi91zT@(G(J$rR7#|V`Ck5}@EBEOML z&222q`JKo=x!?;cV;@I1$J6zE}Qm!J+bvqP4|J-bGskim?L~0y^mZ=_ef3VESaa|*!ROv zg6v~#^#cXdez3^)Q~xvEIL@2?>V8MX%*}C+K*#=b?dlPeE^fBK@3kI9P9xIq>Zp6!b@GzvWHs|33^mi`*0vY2l zlrbMi7PsAvpY!zuBz^?CiCn~&TIN!2_|0m{TRBqeK(T7bN_L4$JAz>+T080!?%Jh zgO5SZk>`3FnD|b7?0*%qnB%;!8En^iJF>WBlew3(wpSOMdwQ6eckVw0re6orK1_b% zpDy@0#mDbR_S0vuX%FonC->PkVCJAbb$$^!b-IVFGc|k(n>FZ@8orDyF4<&t4PPy` zx`wZT>1PevQ^U@JTZ2Az4ZF~_hf>4!VCJAb`?eoB`}Ri0%D(M@Yx`{3BeRjkcHpy( zY`lm0Gp7+BW{uPbk@?No_9G7fxEF`u?nSx#+;vz^@$L`z=h@)%D%)GId1q-Gg~w)` zMd+S0ZA(kPACRYK=M;49#{DOG&MkIK?YYrD42heM@}AOn0kVCq!}E43JpHsSgv9Kp zY+V^g{1e2puG5emCv&$LUCgkDEqC}gz|B{lHUB2^I(#Of`mXvGxVD_1ZzJ<-N!wQH^S=KMvbI~wynPp0tc%SY zZbG(sPigxeBxWAU?S3Fnb#e2$4F8*v&1)L!LheQ5eIHz#^{d~4%&)yiv~`ikA@3O9 zQ@4VpKF9D**$pP1vv?b_m}HYVb=K}2?*R~V9DOr)KSUPW4Ec@BdHE5zww%v>$ov}5 zr#AQbSIEz?Z%220ecU5rcObj2+MJ&s!<`?w=f%2DBSB+2X9LKyp}l1d?gZ2B+_>)t ziyilN9kkyCi94>cHF+oBjciSxDaWuT@8zF@Ys(#b53+gMg56s%;b1>27(>n6l|Kiw zzy8|myYd(4#?mM2^GjrL$tH8W#Q0UQ)pzBu!SwU4)Sk8YO~G9Yed@dNx9HkKxhwaB znS=Jk{2j8mE#Ji7qlhKJ^?4Q&Gp=&(wdasM zU*2oEGd$1FgKNvX`53a8_n7bI;{{~TY`z&Up=YmX%X|1` zWczB*e7u4zZVUD*`s?TDH864gZzig7|BWo}yt!t%A1S@9^**rFs(+tz1$l?7&qAg< ze`ED*j^S9b_ZPeGL~-NU$2H0w=3J|L4?5@a>zF&|*89Cht8)swl zO{A~m%tLn^xpn#WS&Ho2B6STFpSke7EtVq>LU~(^Av>;k)@cQD-hK5ty%k*BwPk<3 z4Oz_l$?w!kGBb(sv5&3Im HaUt|yqVPlJ literal 0 HcmV?d00001 diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/lit-air.vert.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/lit-air.vert.spv new file mode 100644 index 0000000000000000000000000000000000000000..c493caf5fdc55435e07a6281e1c59996895e1d82 GIT binary patch literal 956 zcmZ9J$x1^(5JfwqVoYKj;uPgMYa${zAcz`Qx^SWR1%ewF>Owz2aPLOAYfS$?9*n^AL~ zUr}pC-I+df&UcIh^;9!SZm+*JQ*iZb`gpfC@SOTb)Rv;25#OV>O0M1qH)#5;19ex@ zHptZq;2H<#vCr)meboN~`2`_nqdv>CLa1v-#Xj zI9O?F#>5XFD^IO9wRBqbu>B7?oMNlg(^BU@VQT6vsqaetx76cOKc9L^>Q__GNWZabmzthZDCocy_ATD?cZgKeF|qS1Qvlp4$_rUJRu0sfZC5 z_Y%&gOXL}obF$K)b7s~Kwx&B|#oiYAyE&Ke+ei2vOYqW@Gp5gC|1&1%t^S-ny2X1j zW9t2p^Izt)^aKY^#xh4bWqgLpjn@a)^_h-+`|8f_#UrlkGk?T&eRdjgJwv;UI5G3L z$A}XfZm$vNYUB1vwNCC?=MS6zlP0>QC-;vmR(s(tk`=OhNftNR9VLsIZ}+vnVLIcp zb&vn%YsTbG*Vg@}TYR@?Ox+xZ-Pc=2_|_4AW)`J3Yme}=MtJuT-eZJcKEh{bA?xRy z5k7Z>?;7E|NBEu*UN6Dd&-x=gYrs#*s%Dxw>oX?n2R}6^E~aLtuAVirRA*GoJZuoyuRwpBHR?`aeG{TVvLKI#^jPn)zwV?-<;EI|u7$ z?$z%Z{MQtI4;=L7M$hih)Rb+=#(Vul{9FGwL~h*dPgK8u^=9pV(#W3v=$Osk(cD35K|%31hUcy`YW{&rfj`J9?o!C7bWX^0g1(-*^u zedo6@oO43957!Se`x}DAY9Bkl+k%f+_1JkECQ9e*_EcPJ{qB&!`K$cW$SV)B`JEAY zYU&4*bs?{mfz~+V=Uc5Ezn)3P99QRX`|xypubnL58G{?!!D8PbeEyr*SPQae2#)@! z03q08da~yRj{cnkwny}2?;-TH_go;aoi`o%+&MDmaphk%N0sk?kF8v)4_{>OHaPkZ z47ls3-^apTcIi$p>^^-ua?;b1SmbE2=)YjV_J@D6cP9GEa`sr3v&XWWUD@2_X)^!k z54iKWJhC%n?fECm;;8~(peUUSJg_+8|wsp)On@Ar|}sC4`r-?KnG z8)TIEX&;XKya7Kt(ao6b!!^mPTV$m|rRU-zu0>(2--GG1h%)7oVE6r@$n@G5rkDMD zYq0aN`yhL#z-=2Z?$z6aPfLr}$DQOU zAEn$e1845L#L6BY8;&gnPAT?chA~c^ zHoQKcORIGKjvMT?UB3?mcm1qQ*YAVj>E4y`CigiQXPgt#8drONo|*JlBqvU%<@?jA zC#Rk|%;lrO^v9=tApSlx=wm*a5B9y~PU-L2-;~IZFcYQnetE2Z!upVsrr<3=75!rKu?$NY# z;`?k`MSN`R70&otW8ui!j&)URjc0uCoou_#+XT~3hd0KykriXmO{e1cgpG0ZAmjBp zad@Eh%v><&wV%wIII9bT_LB0TF>R?f}cchAFoffhw|J-;qc7` z7YE~1rMcjkVJ|+P*qkAIvEp#%(#xZbC}9GJeLgE#O!V&`xU+)~jU8R%I5*f@wUj?^ z;Q3a{|6t%ByDmQr&zF8|8sGWBjgL+J{3y8N(wAR2@VGJmlYxKix?B{VFa6kb+>3)d zE}O2)CBb5%@48$XY+dM_P3Prj!TQfiwQhdbePCF}%OmTty! zWjXuq2s>loej2%B(EIJwcbUs0(<|k!7&v`Azr=ZEICs?@?tHEaW=q$3UmeURUHx4X zYz%AKIDZpaexTD=oWBkBjHIjGwZU=-zqR{AFk8BgabqxEDRPVR`nF0Q8NyZ?6%y!~fm|9cnj zicH_?Tv(^O!-;{uW8NE#SGqs9hB*k=Z$~r(_j8_bdk9Gv$dECdG5ee@ms}Jcr=(GM+w52lHc( zJez8x?elb*V17N1*z4ahmkp*j=89<*W8#-f#TgTB>?;JDpV*CA8*RsYaxlN*V6T72 zTq&5|m`_Qo7!$v8D$ba2=ecUIF+0!NXglU=!TgGYz5b2)sloO^%wHMSZjEsI&~=9v$#;-!hIioZn&)?Z=KrrW82TI^vtY$q(#=W5Svy$XbFZ!o zcDKai{^9-@j9(Beui$QoZ0zL+eb?y5@LeOnUBqsUZVFG=?+G^t&PnA*KX&c&XfWSOxxWsae)aQ&e7vb2eb|is zEE9eAq|veM>;Wyk#4t8-3?zsaew&sU*!?C5~qxoUIU}MlvPwW@xfLW2pxdCo|*dRK-BsqD z&cKcH!zSUZpVB+JY2@B(e)MBk-0M$SKb)kIcJ^h9y{~e_Srkw8E>EF5%*Ugxkn%QhP>Y( zYu#h_@we&kI|G}Kq<(17$w7aNP7b2GE_kf_!$^1Sp!?2{XZDXhzv7Z-K05e~IJw94 z2c+V~2aE5(LDn|U7*`x@j!Qjwq%*EK=-xNdnb+^an-|^jgRb!%6q&y9oiOMdAH8w# z;)C5?eh&$DMzWm~AN_Xmy72NAeec|%!T9z)EVzBGPx~GoUSIn5Jt7$2clslP`NP-d zjlt$o`OT5}ZO%I?G8=qz)LVk_{w%HB+alvm~f_IcB&k2v^X z*Ry&|)|ibm+nncqy03KQjth3b*)^xRtG%1!oZv2!1C!^Dsjd4#*D^~6O`IFko`}fdi zt#IBg=cLt7Y@N@JIS|gR{B&wE&HS`Ghr|0m>b>%HX%+94bT3QA84vF7pk0F9FTVJ6 zSH!Vvc)Hr{HrUBS3sY;idw9Cee~;kKpU$7byZ3zV8JS-;wcjha_MRnddXDxUbeo1} zr|;a<{u#->!EE$1=R<-ObEexb6=%*co7V~?rQf$dl{KcE6r_R9yom(`zhh@ zSH^ee_|?ez)3xt6g88$4jp>__edDClS4`gu_KlOScBcjV#>sE(z7x!ru49}Tj91E? zHE?1sclN-ErN6n)38z1MdTaOb#N?TBZe(YLO}}xT7mQaG{`u<|KNxgbmL+H6hvC>| z>!#?t*XIwsy=F81#(6^VR+NNn|{(eJ_rz?|5%7 ziHv90{B!9b)Ab$bvS2>x>hEX4{8^KJbGsrkJ7=@s{(cb|FR%P6ts?i}uS~_sEpWfd z{W6#zd*m5V8?EPyT>k4|e&rFjKiT`6N&X{oLd1J6KJI~r^JLb*7{ECCU{*C$8VEZ8Ee!Kir zWPRv5&TYZ$j8nVYBeSIw^POoGG1J|ViW4(zO*|LK_q<9~ope zeP;h9*f_=>f8%^KJYD_%UvT}>@!RiJ9z^Cd9nM-V6IrpAbW5e;tR39<{_h66TjFv5 zd=vRzIQ)X(zW1LI+1TFu>AOZ}26v6d-#E_-PuKT;e}@*gZN~78)8C`@-jDBlzrRn5 z?|c9EgFV;#96UdAa{yi6=PwA>mwq}vn{R&{*_j(xYboDe7@hgR?D%M2xhR-kUM+XY zz{xe`E(>Q}ls+SW8kt}3^Ze+?u6-^K=36Ou#lY!TKUamTAAQ)2{roy|_oRKU8TAnd zAMD2W#u@0DSuWU_*)iifGY3XioEf?&rEZey%)tCCKgim?XD`b8sQb_6FTop*bPtSl zkBoHg-+hs%hkkR?N54~ZKIGOX2h+O~zCCO_=p#on)$UKh`0>5}b7Z_Rdhc(K>>RrH z^u70Y1V7%rzcV~t@4delkL$hncjMiAeDD1|!T7Gn{lV^i@5cj?#j2EhaNxw&{IDn- zTRJ|PA07%e2L1HJesKbfO{nudnaei1T`-dCnhZ*6lpVB+J zbhzGYe)MBk-^&E^qm)~2;Pk7X6~fhzK5WK*R*u}gYoAp{eZ;{ByS}qI|Lz03a_)=! z<5|<+weEuRD{qYNoU=}LkDYmK`^*V;#@lE4!Di#M`p7-{$T#Fo2U+VLyN|c0zi)wT z_D_A)pi47#Z*+1H-95o$-Q6SIU4yRQ5I2uKzv7Z-4v4I;A`jBfO~s237T*?wtZkk# zt~l7dGIicaXIyd6?K#q!*B#-_i*DaR*Z8)MOyBtS8}yBj-Z*&i!R{`!!rOM~&_Z=5?u#*cH}&XMusoVQD4IghUXW*RT@IOjb(9DYIO)SS0YWOJAE=$rGN z8~k`VZ{6^8&3Wqu-xSn2cmm&TkQ4m zt&h)s)6+KI<&v1#`L?OA@!0m8r?$4s4z|9#>c@|}xO3)nSg^Y&7cES6ui|Rz@bGk9 zgCl~w26W@^oJU69DwR#`-w<4T&l5JC>rsPl)9~!{^_{cdE#4ArjoCHMw+1)P{yq}t z+rrb;_uGSwJA2R>o3EbJcSg^~813_KLm&5wt@UDOJ&#UnKehKy{xx>q&!cCzNjUAi zp`A|4m$lc|zcqie6h6$zOS7feAY-lH`u=^l85Qf3&!!`e&N0! zSwA{?^Zc|Ir24l<<8}Mdz&HQ>I5MuaeHVt)cl>+$!pQi>aZ#{w@r~o+U_R;i^6zFY zN%ik$woaVpn6RfV9r*s1x-2s8`sn@#@ zd{pv?(mx~EHM03=JD=TxJD+i$*gZVm;lqCJ5sd48{%bI9I{uCIieSZl((RdwGahU` z#Y4tBJ2%9B?_lHi?0B~B6As^X*f$ubD%LAqCV9m=>=)j=*?C8?AFm2#x80!cd|w^D z^Bv!t{lnAM?tqcq_>LSHo~}9VpkSQ-&5;KO8+%sjZK-T(|GHqYvF|sp*9YVEYwkKM zm=F1mzWJ`}c0@SyaG&hGbvrT~zU%geV4SLS-I^QT6y7+k#%H4z(@j?RbB zuu@5KN~Orwps9Z<8XY9sbxJi2n&hxUeLuhFzIK0Oy1Crf^}P=Fbzk@4cfU^!r_7l? zn6}nn-NDSk!^1(>HW*A9tQAf_(fYkN7{q2;hwrh^{`+im^f8BRv%`+taaeCKa^$oA zU=lPK3mtLDM~}q6EA&X{+0dLA?GrjUbX@3+&{sm|guWWOGIU+&N1>mGej8d9dLgv- zl)+#|Xl7`W(A>}#p>0F+LT?D|9@-=M?4Pm14)#nYcx`HE&-}OK>5*r>2%8gGE4vIk zfGy}@;=4zR^NY+Az(=wWhWcVG`&yI}`6Y(;vFohIiM=_}`& z8?17k2w!%?@O&-e9;wrV>E*JZlZS(c((_}F*Vt=?)mRJ*sX33lP>SOU-tGNz1VnTiC40*H1cLiqF;;2 z$Tb#TtNm1Re?Jvgoq}#oe@S^jn3*KQ*#eYhH4H%?n#I z=Gfr0`1NyM8oBC?*DB|d*Z2>+`>}F%S5A&xx_Xn>`l?PrP?k;&Oi!6_kJRDpRx$?kkl}E|_Jg$sQ z<$<4!eZ?lP$z!bhju>0B{y&UvqTL7-IvxR{56P z&(}As^2JZazG9R6`Ti=hvFcp>I&bn<*@W)(5x4D-)ujxP^KHxb%6c{|@^da~M*)n=pocf<4(A$e}q#KT*SFQ46-?41j%T;3gCZ1(SII&mwvPlwmY z;w}vxwc)9|mV9pHYreDP{F1MVJQysQKOXB9;f&R%in}tny*fK_A56a@1RGD~9!d}E z`{CvIaC)_4V@~8p8b<$UwKOMhv;D%u?8}MtV$qMsYcevM{v6rVJU0(-p6ujDp3|@ze{N)F z$UME7diOjW>|^-YsPD-b*iX z#_8$h1v3WO`=oOHLgU3@|K-T;0b{Z!d$YidQ|}eyc#n|zot<7x7(cT2M~!n{Mw)dVWy?EXeWaDkzcykh;>`hVj_K7z| z*}pox*w&dn*&74qe)dM_*Js2JyLSSe`gjL;HyD4L^lAYUkLXU-|pqSH5?L5z9L9zo*I8weHw^gT-aAQ}(akeX5D^Oc3|s8-u#fj!&H9LJNbs zFFqUc{d147G2fFKeq!@EIWp{&_=vG2yg2ZMSv_~e7enq1?+7}*c=q&{ntoyG>ztn- za`xGX#m@P;py}R}%)b(zEKlFyi^I!vQK;r}Nig_#V&e^RX?U2pPbbFZ;kD-ypYF2I zw?k@zmyh%Rt&lsRpO5`wftWDndFj6ZvNch)(HOZWZAYR^U;-7~j@)Um%lw>G@| zZi@`#&u@A9pN8C7{LG2WP7S>aei7c?)32eu^vhs$?A#T<3%Nho(TUIhH>nSux0O2a zy(<{p9&-*J3iW-*V{a|)O>b@V)`IUju@%eu{3GOK3N%_Ui|-Ltoxcis&22) zvxkd|m#4G0G$arH@>J(XqKB9K*U0K~etfL&W8vAr)xkIOZz1)Z7rj_&3S+w@e&!%= zIyU`z%8UGXdim*V-si^NygHlUPlRgTPlcynnRw>?WYb6N=1tEYE-t?EeL8&QD~5bw z_FLuqY-GC1xAGJ3oq6Bc`_G5BuaKs*pT%1hf!=;s*R`Usf3@T-z7`*P>4~K^tM{XYrueo#SJ`#e-4{zVfpA6@7Mf&o&G)Wv8 z8O8=*cg!ck`+MZYcg!b)(^XEN3J+5o@t1|y@S^jjFh3IZfz1>Tn2~R&Wxj3Jf zH+@uH?(`>w;KsyX-+WIB&sT2dyd-`3(VZNE@x#l#;=mW?e5%1I;nhHnm21Bir$*1F zYH?b4*flMWrQu=x<#9%MP3`GU55eStmxmgV;kAE1AAU~qnH#G8`-Nce+P_~64?|i^ z6J^ag`%(m$bH=vr<1@oMU-UDwV%GSq@N8cj^7d!ryW=dK6TV-QMZqekbEBiLoX!gm zLt5oj^}QhYi(~ccdtvl!&P+Y+rHjIg2`}v8@NhYQuGw80-oB&j*R=MYSoR_tb!V^M z?r!_iUbNoMaerUiBjjc2t&zU=`S)YLAok9vxMf>sS6{X_HCtz%pE#$rv9AojB!#l? zjrEPl#?qJnMa^H#<>7B_I=NpM*?yzDEksvm>ZP$u;5S z#@`*WIQe}yvfm1fM_>2iwc+V%-0Q=`>Kxn{UULrUZV17Qi8pU~li|iYG~@gryx$JQ z5zqO!C;9v?L|5-6-^<@Oylno^Z2Xp1`}L2(;AQitX5(*&vbirjylnp5Y-*nx>;7PH zXZ)!2T78f1OJ3%o-#7U>U-yTXAJe)!9tZ}nv$L|qr zLooBkTTgW%!z+h>hgV-Y)V!Z=7+kA)m(R1&t2g|Xmd|s+;C>gC&uf~meCSq%V8+MS zxABGWhldt5zb=vR{OJB0g7L%m=K-(#au{Ad?o0JkD{mv~G!`9w(dUyhd}=W3L|^N- zcKG#!(N*kq!dGl}F`HVOb;Hxc&3k5eP5yKZ?) z`l`XJ!i!5U_f6Ala;KXeg2^4F=NqvOM- zzD-+%x1Z_yK2wM^+2*%WmKBZ0TyB>>s{=Mn4)^ zGgrC;LNN2d*P6X2e65+h`*+iU(a{&x-E>ee`-;BK>HEUpAJQt%gCoQH=k)!Njpv-w z*In^}@Z#28@xkzaY$PV147qy$9U2`UcC~K~4-YrS)v2;NKNucvFR7dLt2*dw{g#EN zx2Am5sLtuf!#k&J)Xm*?WOzBfI#l}pKJ-0%D*x{?wqFmwAiZy$-nT;U&ehLOzeULX zLbj*Hm!ESg#x3!=D6&|_xizx2)YrRjd30>Nk=VE-Bsvw3)9nG<{C*hhX}niHPh z`%nz|`t2OaFImtgR+Say#E5pO({D%1R!S5cP?)mJ9T|)l@ Dy3*3V literal 0 HcmV?d00001 diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/shadow-pass.frag.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/shadow-pass.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..86b5c126ec9284c4b19d605e1ef5ad02e0c22779 GIT binary patch literal 152 zcmZQ(Qf6mhV`SiF;ALQAfB-=TCI&_zlN%@kqTPLhee{Y;QuItr4L~aR7??p6SdO28 zm4OAw2I1Vq%sh~|08k#pX9r?opjk{nS`jD)(gk9h0rh}rkQxvGiT?nKumb77KvOM% F7yyFJ2$%o> literal 0 HcmV?d00001 diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/shadow-pass.vert.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/shadow-pass.vert.spv new file mode 100644 index 0000000000000000000000000000000000000000..8b49cd0fb8348f6af04081fdf9f25b100c8c0bfb GIT binary patch literal 1820 zcmZ9M$!=3Y5JlT|Vg~|AAY=d%#{;v$3Bd_Nl*E>h?1dLWuwcOki46eFv5YwZG8*uBy$Z2J(dDZM{Lmbn%SUEZEt=F?x!uav=g z#(hpNICVMaXYMD+ysv%i=u6!dvkE((RdCU(dE~68;821yuH0J2S&v*N@RdZF283ZmHrvAlK>6I=b{~ZuJf3Vyq6Z?m=Z=LU(nrFC+E2@1(MQ4|1DC zT0wTe7QR)awtZ~#dmSmK-Da=fj@&M~--oum?_b+|o5(9b&V1Uwe>wd(E8RHe+(DYt zp7ie`Ye3%f+I|~y_80Z0^yA$A@4M+P-nQHec)^()iw6 z4s!^562^Ll^v*qFEbURn#hW<7ju{_UKI3}+CDOd+(>_DG*IJMHF7(-#Oa2#(@A?$h z7~ZXKYLZ&T{oX?N&aFwG(!IvN4fJb!$9us0eM3b1=~f)#FW0NMTE_J&&e|@>Ypr*H z=iRTr1AY=$-(6sC_e|iN!E@l8oK5udqT(lX=3HI@@5LVUMIR^V`Xc@*x}158?+nj@ SZ^*M}OitTYclQtX zS3Z1vv$D2++moZIQD5NMu{4~TNb&CV+xKpa0FI|iU=?hGan9&72)bzcw1F$^MfCpK zZltqW-@{kkbqeh98@6|ipWv3TUEybra-D)RhTM~avm&{@f-_&a{em+Gxr2fm$lUXS zGdKNS7Ti$gj*wmYF^Bv@qtxdM{lZ_pS@-ZFwoAWTNb|Qob(e8m!9~r{n>eFiRroc~ z;#y$Tor+TJ;-^Iq%oE4P2ggCUW*Y_HUqHKRVtCdl~!tuj8GIWa+H!&HrEz z%)vKiKKh!2_G;#$#yi+9ZEL#=qK37sn^*YXLysKw-^{l09%kK~jHm7U3V-i;3{v{E zvq5K#<7{2}{DiUJyZqYk8Q@Hu!#1AAn+5WR+4g&u^Ncg`oAyn~d&c?rZOdyrj|w=8 zXPhyoB~SrZaF@|5&<76iT0~v}`nuN!FF0?lSAj9ykMq|ukKo-8)p2g3d!I9Kw$4+( zU3TsDI&jaKI0y4JzYXAC_}xd>&so_2@Oyyn-sfD`7P@EUgX^H1hkFP7_b~sbpi{sZ PJz^rx&b{Acy$SvR(~U)D literal 0 HcmV?d00001 diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/solar-visibility.vert.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/solar-visibility.vert.spv new file mode 100644 index 0000000000000000000000000000000000000000..c493caf5fdc55435e07a6281e1c59996895e1d82 GIT binary patch literal 956 zcmZ9J$x1^(5JfwqVoYKj;uPgMYa${zAcz`Qx^SWR1%ewF>Owz2aPLOAYfS$?9*n^AL~ zUr}pC-I+df&UcIh^;9!SZm+*JQ*iZb`gpfC@SOTb)Rv;25#OV>O0M1qH)#5;19ex@ zHptZq;2H<#v literal 0 HcmV?d00001 diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/solid-shadow.vert.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/solid-shadow.vert.spv new file mode 100644 index 0000000000000000000000000000000000000000..8b49cd0fb8348f6af04081fdf9f25b100c8c0bfb GIT binary patch literal 1820 zcmZ9M$!=3Y5JlT|Vg~|AAY=d%#{;v$3Bd_Nl*E>h?1dLWuwcOki46eFv5YwZG8*uBy$Z2J(dDZM{Lmbn%SUEZEt=F?x!uav=g z#(hpNICVMaXYMD+ysv%i=u6!dvkE((RdCU(dE~68;821yuH0J2S&v*N@RdZF283ZmHrvAlK>6I=b{~ZuJf3Vyq6Z?m=Z=LU(nrFC+E2@1(MQ4|1DC zT0wTe7QR)awtZ~#dmSmK-Da=fj@&M~--oum?_b+|o5(9b&V1Uwe>wd(E8RHe+(DYt zp7ie`Ye3%f+I|~y_80Z0^yA$A@4M+P-nQHec)^()iw6 z4s!^562^Ll^v*qFEbURn#hW<7ju{_UKI3}+CDOd+(>_DG*IJMHF7(-#Oa2#(@A?$h z7~ZXKYLZ&T{oX?N&aFwG(!IvN4fJb!$9us0eM3b1=~f)#FW0NMTE_J&&e|@>Ypr*H z=iRTr1AY=$-(6sC_e|iN!E@l8oK5udqT(lX=3HI@@5LVUMIR^V`Xc@*x}158?+nj@ SZ^*M}OitT)lJt7cK^ddaoI%oPn+vRpAU!b;7&L~4 zed#7yR-~q-6{Qvyl$VOGkflL+NwW{;C8K`7XXmWbOT%;CIp=@QoH_HKd7pVI+x4$0 zRacZcmAaI=w<{H`d#PQi0?mFykJkRJ)W1vLsZ?F~Crp|;ss64dbLvMl40YfcrOINw zW4jVnMXxNJwP2BsAxHx<8JUNyL23!^#yI0@kO^qU^+jx9_d~b53B+EU^d@4}ZS9dJ ze#16bw=%3hwk_hUQ>-z=oI`AUhPf)SrVO)Qu}K+b4Pwn1R-LeGGtAnIyFSBe6E=h1 zHC|0`E!L`TbBryps6W;jV~nfI;wsU_hGtmQ-DP0n)emI)!8X0BIHUQP*%TLJZprjHnf_>|znAIzl71!($U$G`bq(rgQNR4wA%vCA z!FC;D`p+h^1AQH$uOq#-zJY0MPjtr)f=9>pLd2Aa>5XoS7}qAI7-P=!5ZV}HoM!+c zrt~6WAiZ(UWh?K`rPe{<#`Q)YOs`!>{(5?w*r;UtpSQU#_tM;+(VqMcqZiYbqv7;* zh^-Ir8UORf$Wt!HDdtj~dqmP5Ho>K{-3x;Yr2NsJvY`hX#1awbKsoK z$u@pU((^U?KFVvHmf6Pn9x9bu+G@P1u$PVN1H=9o%jFTboa622?w8_z?V#6h zjS=$|y6ab*uZD@`%6!hJ?Y$NJ$yZkryt;bz<*}#Tu$Q;Aj2g*FGPh^f=9uFkp0BZR z>E67A$dep+Rz?$T&Q*xMh)&pD=M7G?eaVy!kdD<}~I3FxRjVabEd(B*ksQHdfyWxY$fDC-z5q zYdNk1@$Qj49Ya1u!dH#Yz0r3v^I1a$K5NkTY38d&cOzVzzEg-f*J8QNf!TAlYtM9e$G`S#M=oKN2#J=cb$2?YmHmXT-GoH-I(R%(7q8}%sN*j>}G6n=aM7uq?zaq z^xjo+G@O1GnE2FW&qfz>jQ5t`DnsbS9pfG5H;ecbV*KCDMRz`1VDl0t+zfY(^z$){ z)#v|oI^%s`EWkFW{uqB7y0}eVoac6IpfB+`QAQ>ZBG3${wZ{ETX6U^wotye#sx3Wp&OI){ya8M!Mpx)-d_MS zCg=S{Y@R~&NB))Q`f}bc!v4F3tm1$HzwzOJ2p?jyZ&Vma?WV~gj!@59b{ zKY(qVEqFVaFyY|+Hw7U*yhw9_`UGXhi3FmUK2h4ca8vAk-U4J#= zS@y0thWKr(FXH@$HD(pP^Z4By$9zz&a<)d7HTv at+NZ5Owz2aPLOAYfS$?9*n^AL~ zUr}pC-I+df&UcIh^;9!SZm+*JQ*iZb`gpfC@SOTb)Rv;25#OV>O0M1qH)#5;19ex@ zHptZq;2H<#v + + net10.0 + enable + enable + latest + true + + + + + false + runtime + + + + + + diff --git a/samples/AcDream.RenderPacks.NoOp/NoOpRenderPack.cs b/samples/AcDream.RenderPacks.NoOp/NoOpRenderPack.cs new file mode 100644 index 00000000..302d8551 --- /dev/null +++ b/samples/AcDream.RenderPacks.NoOp/NoOpRenderPack.cs @@ -0,0 +1,54 @@ +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.RenderPacks.NoOp; + +///

+/// Minimal external render-pack entry point. It declares no resources, passes, +/// pipeline variants, shaders, or GPU capabilities, so selecting it is a +/// renderer no-op while still exercising discovery and atomic activation. +/// +public sealed class NoOpRenderPack : IRenderPackPlugin, IRenderPackAssets +{ + private static RenderPackDescriptor Descriptor { get; } = new( + "sample.no-op-render-pack", + "No-op Render Pack Sample", + new Version(1, 0, 0), + RenderPackApi.Current, + RenderPackTier.Tier1, + RequiredCapabilities: [], + OptionalCapabilities: [], + Resources: [], + Passes: [], + SceneReplays: [], + PipelineVariants: [], + QualityPresets: + [ + new RenderQualityPreset( + "conformance", + "Conformance", + RequiredCapabilities: [], + ResourceOverrides: [], + SettingOverrides: [], + MaxResidentGpuBytes: 0, + MaxIncrementalGpuMillisecondsP50: 0, + MaxIncrementalGpuMillisecondsP99: 0, + MaxIncrementalCpuMillisecondsP50: 0, + MaxIncrementalCpuMillisecondsP99: 0), + ], + Settings: [], + AtmospherePolicy: null) + { + FeatureSummary = "No visual changes; exercises render-pack discovery and atomic activation.", + }; + + public void Register(IRenderPackRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + registry.Register(Descriptor, this); + } + + public Stream OpenRead(string assetKey) => + throw new FileNotFoundException( + "The no-op conformance pack declares no assets.", + assetKey); +} diff --git a/samples/AcDream.RenderPacks.NoOp/packages.neutral.lock.json b/samples/AcDream.RenderPacks.NoOp/packages.neutral.lock.json new file mode 100644 index 00000000..3924e2e2 --- /dev/null +++ b/samples/AcDream.RenderPacks.NoOp/packages.neutral.lock.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "acdream.plugin.abstractions": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/samples/AcDream.RenderPacks.NoOp/plugin.json b/samples/AcDream.RenderPacks.NoOp/plugin.json new file mode 100644 index 00000000..9573e41a --- /dev/null +++ b/samples/AcDream.RenderPacks.NoOp/plugin.json @@ -0,0 +1,10 @@ +{ + "$schema": "../../docs/render-packs/plugin-manifest-v1.schema.json", + "id": "sample.no-op-render-pack", + "displayName": "No-op Render Pack Sample", + "version": "1.0.0", + "entryDll": "AcDream.RenderPacks.NoOp.dll", + "apiVersion": 1, + "dependencies": [], + "kinds": ["renderPack"] +} diff --git a/samples/AcDream.RenderPacks.ShadowsOnlyTier2/AcDream.RenderPacks.ShadowsOnlyTier2.csproj b/samples/AcDream.RenderPacks.ShadowsOnlyTier2/AcDream.RenderPacks.ShadowsOnlyTier2.csproj new file mode 100644 index 00000000..0aa92060 --- /dev/null +++ b/samples/AcDream.RenderPacks.ShadowsOnlyTier2/AcDream.RenderPacks.ShadowsOnlyTier2.csproj @@ -0,0 +1,22 @@ + + + net10.0 + enable + enable + latest + true + + + + false + runtime + + + + + + AcDream.RenderPacks.ShadowsOnlyTier2.Shaders.%(Filename)%(Extension) + + + diff --git a/samples/AcDream.RenderPacks.ShadowsOnlyTier2/ShadowsOnlyTier2RenderPack.cs b/samples/AcDream.RenderPacks.ShadowsOnlyTier2/ShadowsOnlyTier2RenderPack.cs new file mode 100644 index 00000000..8751dffe --- /dev/null +++ b/samples/AcDream.RenderPacks.ShadowsOnlyTier2/ShadowsOnlyTier2RenderPack.cs @@ -0,0 +1,231 @@ +using System.Reflection; +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.RenderPacks.ShadowsOnlyTier2; + +/// +/// Minimal Tier-2 moving celestial-shadow pack. The custom tone-map pass is only the +/// technical HDR output copy; no Tier-2+ atmosphere post stack is declared. +/// +public sealed class ShadowsOnlyTier2RenderPack : IRenderPackPlugin, IRenderPackAssets +{ + private const string ShaderPrefix = "shaders/"; + private const string EmbeddedPrefix = + "AcDream.RenderPacks.ShadowsOnlyTier2.Shaders."; + + private static RenderPackDescriptor Descriptor { get; } = new( + "sample.shadows-only-tier2", + "Shadows-Only Tier 2 SDK Sample", + new Version(1, 0, 0), + RenderPackApi.Current, + RenderPackTier.Tier2, + [ + RenderCapability.MainWorldColorIntermediate, + RenderCapability.FullscreenPasses, + RenderCapability.AuthoredWeather, + RenderCapability.DirectionalShadowMaps, + RenderCapability.OutdoorDirectionalShadowCasterReplay, + RenderCapability.AnimatedCasterTransforms, + RenderCapability.AlphaCutoutShadowCasters, + RenderCapability.AuthoredCelestialDirectionalLight, + ], + [RenderCapability.GpuTimestampQueries], + [ShadowResource()], + Passes(), + [CasterReplay()], + Variants(), + [Preset()], + Settings(), + Policy()) + { + FeatureSummary = "Moving sun-and-moon shadows from terrain, trees, buildings, players, " + + "and monsters without bloom, rays, grading, vignette, or volumetric shafts.", + }; + + public void Register(IRenderPackRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + registry.Register(Descriptor, this); + } + + public Stream OpenRead(string assetKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(assetKey); + if (!assetKey.StartsWith(ShaderPrefix, StringComparison.Ordinal) + || assetKey.Length == ShaderPrefix.Length + || assetKey.Contains('\\') + || assetKey.Contains("..", StringComparison.Ordinal)) + { + throw new FileNotFoundException("Only declared embedded shaders are available.", assetKey); + } + string fileName = assetKey[ShaderPrefix.Length..]; + if (fileName.Contains('/')) + throw new FileNotFoundException("Nested shader paths are not declared.", assetKey); + return Assembly.GetExecutingAssembly().GetManifestResourceStream(EmbeddedPrefix + fileName) + ?? throw new FileNotFoundException("The declared shader asset is missing.", assetKey); + } + + private static RenderResourceDeclaration ShadowResource() => new( + "directional-shadow-depth", + RenderResourceKind.Image2DArray, + RenderFormatClass.DirectionalDepth, + new RenderExtentDeclaration(RenderExtentMode.AbsolutePixels, 1024, 1024, Layers: 2), + SizeBytes: 0, + RenderResourceUsage.Sampled | RenderResourceUsage.DepthAttachment, + RenderResourceLifetime.ActivePack, + EstimatedResidentBytes: 8L * 1024 * 1024) + { + Semantic = RenderResourceSemantic.DirectionalShadowDepth, + }; + + private static IReadOnlyList Passes() => + [ + new RenderPassDeclaration( + "record-directional-shadow-casters", + RenderPassHook.ShadowDepthBeforeWorld, + Shader("shadow-pass.vert.spv"), + Shader("shadow-pass.frag.spv"), + [ + RenderSemanticInput.CameraMatrices, + RenderSemanticInput.SelectedCelestialDirectionalLight, + RenderSemanticInput.ShadowCasterTransforms, + RenderSemanticInput.ActiveDayGroup, + RenderSemanticInput.Weather, + ], + [], + ["directional-shadow-depth"]) + { + Semantic = RenderPassSemantic.DirectionalShadowDepth, + }, + new RenderPassDeclaration( + "copy-hdr-world-to-output", + RenderPassHook.ToneMap, + Shader("glow-filter.vert.spv"), + Shader("glow-filter.frag.spv"), + [RenderSemanticInput.WorldColor], + [], + []), + ]; + + private static SceneReplayDeclaration CasterReplay() => new( + "replay-all-headline-casters", + RenderSceneReplaySemantic.OutdoorDirectionalShadowCasters, + RenderCasterClass.Terrain + | RenderCasterClass.OpaqueWorld + | RenderCasterClass.AlphaCutoutWorld + | RenderCasterClass.AnimatedOpaque + | RenderCasterClass.AnimatedAlphaCutout, + ViewCount: 4); + + private static IReadOnlyList Variants() => + [ + Variant("terrain-caster", RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster, + RenderPipelineBaseSemantic.Terrain, "landscape-shadow", + RenderMaterialClass.Opaque, [RenderSemanticInput.CameraMatrices]), + Variant("opaque-caster", RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster, + RenderPipelineBaseSemantic.WorldMesh, "solid-shadow", + RenderMaterialClass.Opaque | RenderMaterialClass.AnimatedOpaque, + [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]), + Variant("cutout-caster", RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster, + RenderPipelineBaseSemantic.WorldMesh, "cutout-shadow", + RenderMaterialClass.AlphaCutout | RenderMaterialClass.AnimatedAlphaCutout, + [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]), + Variant("terrain-receiver", RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver, + RenderPipelineBaseSemantic.Terrain, "landscape-lit", + RenderMaterialClass.Opaque, + [RenderSemanticInput.DirectionalShadowMaps, + RenderSemanticInput.SelectedCelestialDirectionalLight]), + Variant("world-receiver", RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver, + RenderPipelineBaseSemantic.WorldMesh, "object-lit", + RenderMaterialClass.Opaque | RenderMaterialClass.AlphaCutout + | RenderMaterialClass.AnimatedOpaque | RenderMaterialClass.AnimatedAlphaCutout, + [RenderSemanticInput.DirectionalShadowMaps, + RenderSemanticInput.SelectedCelestialDirectionalLight]), + ]; + + private static PipelineVariantDeclaration Variant( + string id, + RenderPipelineVariantSemantic semantic, + RenderPipelineBaseSemantic baseSemantic, + string shaderStem, + RenderMaterialClass materials, + IReadOnlyList inputs) => new( + id, + baseSemantic, + Shader(shaderStem + ".vert.spv"), + Shader(shaderStem + ".frag.spv"), + materials, + inputs) + { + Semantic = semantic, + }; + + private static RenderQualityPreset Preset() => new( + "balanced", + "Balanced", + [], + [], + [], + MaxResidentGpuBytes: 64L * 1024 * 1024, + MaxIncrementalGpuMillisecondsP50: 2.0, + MaxIncrementalGpuMillisecondsP99: 3.0, + MaxIncrementalCpuMillisecondsP50: 0.2, + MaxIncrementalCpuMillisecondsP99: 0.5) + { + Semantic = RenderQualitySemantic.Medium, + }; + + private static IReadOnlyList Settings() => + [ + Float("shadow-opacity", "Shadow opacity", + RenderSettingSemantic.DirectionalShadowStrength, "0.72", 0, 1, 0.02), + Integer("shadow-reach", "Shadow reach (metres)", + RenderSettingSemantic.DirectionalShadowReachMetres, "144", 16, 240, 1), + new RenderSettingDeclaration( + "shadow-filter", "Shadow filter samples", RenderSettingKind.Choice, + "9", null, null, null, ["1", "9", "25"]) + { + Semantic = RenderSettingSemantic.DirectionalShadowPcfTaps, + }, + ]; + + private static RenderSettingDeclaration Float( + string id, string displayName, RenderSettingSemantic semantic, + string value, double minimum, double maximum, double step) => new( + id, displayName, RenderSettingKind.Float, value, + minimum, maximum, step, []) + { + Semantic = semantic, + }; + + private static RenderSettingDeclaration Integer( + string id, string displayName, RenderSettingSemantic semantic, + string value, double minimum, double maximum, double step) => new( + id, displayName, RenderSettingKind.Integer, value, + minimum, maximum, step, []) + { + Semantic = semantic, + }; + + private static AtmospherePolicyDeclaration Policy() => new( + [ + new SunElevationResponsePoint(-90, 1), + new SunElevationResponsePoint(90, 1), + ], + [ + new ActiveDayGroupMultiplier(0, 1), + new ActiveDayGroupMultiplier(1, 0.35), + new ActiveDayGroupMultiplier(2, 0.20), + ]) + { + DirectionalShadowLightElevationResponse = + [ + new SunElevationResponsePoint(-90, 0), + new SunElevationResponsePoint(1, 0), + new SunElevationResponsePoint(12, 1), + new SunElevationResponsePoint(90, 1), + ], + }; + + private static string Shader(string fileName) => ShaderPrefix + fileName; +} diff --git a/samples/AcDream.RenderPacks.ShadowsOnlyTier2/packages.neutral.lock.json b/samples/AcDream.RenderPacks.ShadowsOnlyTier2/packages.neutral.lock.json new file mode 100644 index 00000000..3924e2e2 --- /dev/null +++ b/samples/AcDream.RenderPacks.ShadowsOnlyTier2/packages.neutral.lock.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "acdream.plugin.abstractions": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/samples/AcDream.RenderPacks.ShadowsOnlyTier2/plugin.json b/samples/AcDream.RenderPacks.ShadowsOnlyTier2/plugin.json new file mode 100644 index 00000000..62569dd7 --- /dev/null +++ b/samples/AcDream.RenderPacks.ShadowsOnlyTier2/plugin.json @@ -0,0 +1,10 @@ +{ + "$schema": "../../docs/render-packs/plugin-manifest-v1.schema.json", + "id": "sample.shadows-only-tier2", + "displayName": "Shadows-Only Tier 2 SDK Sample", + "version": "1.0.0", + "entryDll": "AcDream.RenderPacks.ShadowsOnlyTier2.dll", + "apiVersion": 1, + "dependencies": [], + "kinds": ["renderPack"] +} diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 7494efc3..7fbb005c 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -51,7 +51,9 @@ internal sealed record FrameRootDependencies( LiveEntityAnimationRuntimeView Animations, UpdateFrameClock UpdateClock, GameFrameGraphSlot FrameGraphs, - Action Log) + Action Log, + AcDream.App.Rendering.Packs.DeferredRenderPackDiagnosticsSource? + RenderPackDiagnostics = null) { public RuntimeLocalPlayerMovementState PlayerController => Runtime.MovementOwner; @@ -234,6 +236,7 @@ internal sealed class FrameRootCompositionPhase ref bool bindingsOwnedByScope) { FrameRootDependencies d = _dependencies; + bindings = new FrameRootRuntimeBindings(); WorldRenderFoundation foundation = world.Foundation; // Campaign V slice V6h: the frame root's raw-GL render graph fork was // deleted at slice V11. The graph is the clear pass, the private- @@ -285,6 +288,35 @@ internal sealed class FrameRootCompositionPhase renderFrameLivePreparation); Fault(FrameRootCompositionPoint.RenderResourcesCreated); + AcDream.App.Rendering.Packs.RenderPackController? renderPackController = null; + AcDream.App.Rendering.Packs.RenderPackSelectionBinding? renderPackSelection = null; + AcDream.App.Rendering.Packs.AtmosphericFrameInputState? atmosphericInputs = null; + if (settings.RenderPacks is { } renderPackCatalog) + { + renderPackController = new AcDream.App.Rendering.Packs.RenderPackController( + renderPackCatalog.Snapshot, + new AcDream.App.Rendering.Packs.AtmosphericRenderPackRuntimeFactory( + host.GpuDevice), + new AcDream.App.Rendering.Packs.RenderPackReceiverPipelineCoordinator( + foundation.Terrain!, + live.DrawDispatcher!), + AcDream.App.Rendering.Packs.ThreadPoolRenderPackPreparationScheduler.Instance, + renderPackCatalog); + bindings.Adopt("render-pack controller", renderPackController); + renderPackSelection = new AcDream.App.Rendering.Packs.RenderPackSelectionBinding( + d.Settings, + renderPackController, + d.Log); + bindings.Adopt("render-pack selection", renderPackSelection); + if (d.RenderPackDiagnostics is { } renderPackDiagnostics) + { + bindings.Adopt( + "render-pack diagnostics", + renderPackDiagnostics.BindOwned(renderPackController)); + } + atmosphericInputs = new AcDream.App.Rendering.Packs.AtmosphericFrameInputState(); + } + var renderWeatherFrame = new RenderWeatherFrameController( d.WorldTime, d.Weather); @@ -463,7 +495,8 @@ internal sealed class FrameRootCompositionPhase worldScenePasses, d.RenderRange, worldSceneDiagnostics, - live.WorldAvailability); + live.WorldAvailability, + atmosphericInputs); // The world renderer runs INSIDE the frame's one backbuffer pass, // which this phase opens, publishes on the scope, and closes. worldSceneRenderer = @@ -474,14 +507,70 @@ internal sealed class FrameRootCompositionPhase (d.Graphics as VulkanGameWindowGraphics)?.WorldPassScopeCore ?? throw new InvalidOperationException( "The Vulkan world phase requires the Vulkan graphics handle."), - worldSceneRenderer); + worldSceneRenderer, + renderPackController, + atmosphericInputs, + renderPackSelection is null + ? null + : renderPackSelection.ApplyAtFrameBoundary, + live.RenderSceneShadow, + live.DrawDispatcher, + foundation.Terrain); } Fault(FrameRootCompositionPoint.WorldRendererCreated); - bindings = new FrameRootRuntimeBindings(); WorldLifecycleAutomationController? lifecycleAutomation = null; if (interaction.RetainedUi?.Screenshots is { } screenshots && d.Options.AutomationArtifactDirectory is { } artifactDirectory) { + AcDream.UI.Abstractions.Panels.Settings.RenderPackSelectionSettings? + automationLastEnhancedSelection = null; + + (bool Succeeded, string Error) SaveAutomationRenderPackSelection( + AcDream.UI.Abstractions.Panels.Settings.RenderPackSelectionSettings selection) + { + d.Settings.SaveDisplay(d.Settings.Display with + { + RenderPack = selection, + }); + return d.Settings.Display.RenderPack == selection + ? (true, string.Empty) + : (false, $"render-pack selection '{selection.PresetId}' was not persisted"); + } + + (bool Succeeded, string Error) SelectAutomationRenderPack(string preset) + { + var selection = string.Equals( + preset, + "retail", + StringComparison.Ordinal) + ? AcDream.UI.Abstractions.Panels.Settings + .RenderPackSelectionSettings.Retail + : new AcDream.UI.Abstractions.Panels.Settings + .RenderPackSelectionSettings( + AcDream.App.Rendering.Packs + .BuiltInAtmosphericRenderPack.Id, + "1.0.0", + preset); + return SaveAutomationRenderPackSelection(selection); + } + + (bool Succeeded, string Error) DisableAutomationRenderPack() + { + var current = d.Settings.Display.RenderPack; + if (!current.IsRetail) + automationLastEnhancedSelection = current; + return SaveAutomationRenderPackSelection( + AcDream.UI.Abstractions.Panels.Settings + .RenderPackSelectionSettings.Retail); + } + + (bool Succeeded, string Error) ReenableAutomationRenderPack() + { + return automationLastEnhancedSelection is { } selection + ? SaveAutomationRenderPackSelection(selection) + : (false, "render-pack re-enable requires a prior enhanced selection"); + } + var resourceSnapshots = new WorldLifecycleResourceSnapshotSource( live.WorldState, @@ -515,7 +604,86 @@ internal sealed class FrameRootCompositionPhase resourceSnapshots.Capture, screenshots, artifactDirectory, - message => d.Log("[UI-PROBE] " + message)); + message => d.Log("[UI-PROBE] " + message), + () => renderPackController?.MinimumPerformanceSampleCount ?? 0, + () => + { + if (renderPackController is null) + { + return ( + false, + "render-pack performance automation is unavailable"); + } + bool reset = renderPackController.TryResetPerformanceEvidence( + out string error); + return (reset, error); + }, + () => renderPackController?.Snapshot.State == + AcDream.App.Rendering.Packs.RenderPackActivationState.FailedToRetail, + getRenderPackStatus: () => + { + AcDream.App.Rendering.Packs.RenderPackActivationSnapshot snapshot = + renderPackController?.Snapshot + ?? new AcDream.App.Rendering.Packs.RenderPackActivationSnapshot( + AcDream.App.Rendering.Packs.RenderPackActivationState.Retail, + AcDream.UI.Abstractions.Panels.Settings + .RenderPackSelectionSettings.Retail, + ActivePackDisplayName: null, + Reason: null, + ActivationGeneration: 0); + var state = snapshot.State switch + { + AcDream.App.Rendering.Packs.RenderPackActivationState.Retail => + AcDream.App.UI.Testing + .RetailUiAutomationRenderPackState.Retail, + AcDream.App.Rendering.Packs.RenderPackActivationState.CandidatePending => + AcDream.App.UI.Testing + .RetailUiAutomationRenderPackState.CandidatePending, + AcDream.App.Rendering.Packs.RenderPackActivationState.Active => + AcDream.App.UI.Testing + .RetailUiAutomationRenderPackState.Active, + AcDream.App.Rendering.Packs.RenderPackActivationState.FailedToRetail => + AcDream.App.UI.Testing + .RetailUiAutomationRenderPackState.FailedToRetail, + _ => throw new ArgumentOutOfRangeException(), + }; + return new AcDream.App.UI.Testing + .RetailUiAutomationRenderPackStatus( + state, + snapshot.Selection.PackId, + snapshot.Selection.PresetId, + snapshot.ActivationGeneration, + snapshot.Reason); + }, + selectRenderPack: SelectAutomationRenderPack, + disableRenderPack: DisableAutomationRenderPack, + reenableRenderPack: ReenableAutomationRenderPack, + getFramebufferSize: () => + { + var size = d.Window.FramebufferSize; + return (size.X, size.Y); + }, + resizeFramebuffer: (width, height) => + { + if (d.Settings.Display.Fullscreen) + { + return ( + false, + "automation framebuffer resize requires windowed mode"); + } + string resolution = $"{width}x{height}"; + d.Settings.SaveDisplay(d.Settings.Display with + { + Resolution = resolution, + }); + return string.Equals( + d.Settings.Display.Resolution, + resolution, + StringComparison.Ordinal) + ? (true, string.Empty) + : (false, $"framebuffer resize '{resolution}' was not persisted"); + }, + requestClientClose: d.Window.Close); bindings.Adopt( "world lifecycle automation owner", lifecycleAutomation); diff --git a/src/AcDream.App/Composition/HostInputCameraComposition.cs b/src/AcDream.App/Composition/HostInputCameraComposition.cs index 7be703e7..93bd8cf9 100644 --- a/src/AcDream.App/Composition/HostInputCameraComposition.cs +++ b/src/AcDream.App/Composition/HostInputCameraComposition.cs @@ -52,7 +52,10 @@ internal sealed record HostInputCameraDependencies( LocalPlayerModeState LocalPlayerMode, ChaseCameraInputState ChaseCameraInput, PointerPositionState PointerPosition, - IRenderFrameDiagnosticLog RenderDiagnosticLog); + IRenderFrameDiagnosticLog RenderDiagnosticLog, + float? InitialOrbitDistanceMeters = null, + float? InitialOrbitYawDegrees = null, + float? InitialOrbitPitchDegrees = null); /// /// The construction seam every backend differs at. Campaign V slice V6h widened @@ -101,7 +104,10 @@ internal interface IHostInputCameraCompositionFactory IKeyboardSource keyboard, IMouseSource mouse, KeyBindings bindings); - CameraController CreateCameraController(); + CameraController CreateCameraController( + float? initialOrbitDistanceMeters, + float? initialOrbitYawDegrees, + float? initialOrbitPitchDegrees); IFramebufferCameraTarget CreateCameraTarget(CameraController camera); CameraPointerInputController CreateCameraPointerInput( IReadOnlyList mice, @@ -311,7 +317,10 @@ internal sealed class HostInputCameraCompositionPhase : Fault(HostInputCameraCompositionPoint.CameraInputBound); } - CameraController camera = _factory.CreateCameraController(); + CameraController camera = _factory.CreateCameraController( + _dependencies.InitialOrbitDistanceMeters, + _dependencies.InitialOrbitYawDegrees, + _dependencies.InitialOrbitPitchDegrees); _publication.PublishCameraController(camera); Fault(HostInputCameraCompositionPoint.CameraPublished); _dependencies.FramebufferResize.BindCamera( diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index b44ded67..d74658b3 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -92,7 +92,10 @@ internal sealed record InteractionRetainedUiDependencies( // needed. gmMapUI::Update @0x004a1eb0 reads GameTime::current_game_time // every 5s — MapPageController owns that cadence, this just supplies the // current reading. - Func CurrentCalendar) + Func CurrentCalendar, + AcDream.App.Rendering.Packs.RenderPackCatalogSource? RenderPackCatalog = null, + Func? + RenderPackDiagnostics = null) { public RuntimeActionState Actions => Runtime.ActionOwner; @@ -643,7 +646,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory screenshots = new FrameScreenshotController( d.BackbufferReader, Path.Combine(artifactDirectory, "screenshots"), - ProbeLog); + ProbeLog, + d.RenderPackDiagnostics); } checkpoint(InteractionRetainedUiCompositionPoint.UiProbeCreated); @@ -949,7 +953,53 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory LoadDisplay: () => d.Settings.Display, SaveDisplay: d.Settings.SaveDisplay, LoadAudio: () => d.Settings.Audio, - SaveAudio: d.Settings.SaveAudio), + SaveAudio: d.Settings.SaveAudio, + LoadRenderPackChoices: d.RenderPackCatalog is null + ? null + : () => d.RenderPackCatalog.Snapshot().Entries + .Select(entry => + new ConfigOptionsPageController.RenderPackChoice( + entry.Descriptor.Id, + entry.Descriptor.DisplayName, + entry.Descriptor.PackVersion.ToString(), + entry.IsCompatible, + entry.IncompatibilityReason, + entry.Descriptor.QualityPresets + .Select(preset => + { + entry.PresetIncompatibilityReasons.TryGetValue( + preset.Id, + out string? reason); + return new ConfigOptionsPageController.RenderPackPresetChoice( + preset.Id, + preset.DisplayName, + entry.IsCompatible && reason is null, + reason ?? entry.IncompatibilityReason) + { + SettingOverrides = preset.SettingOverrides, + MaxResidentGpuBytes = preset.MaxResidentGpuBytes, + MaxIncrementalGpuMillisecondsP50 = + preset.MaxIncrementalGpuMillisecondsP50, + MaxIncrementalGpuMillisecondsP99 = + preset.MaxIncrementalGpuMillisecondsP99, + MaxIncrementalCpuMillisecondsP50 = + preset.MaxIncrementalCpuMillisecondsP50, + MaxIncrementalCpuMillisecondsP99 = + preset.MaxIncrementalCpuMillisecondsP99, + }; + }) + .ToArray()) + { + FeatureSummary = entry.Descriptor.FeatureSummary, + Settings = entry.Descriptor.Settings, + }) + .ToArray(), + LoadRenderPackCatalogRevision: d.RenderPackCatalog is null + ? null + : () => d.RenderPackCatalog.Revision, + LoadRenderPackFailureNotice: d.RenderPackDiagnostics is null + ? null + : () => d.RenderPackDiagnostics().FailureReason), // Campaign FA slice FA3: the social panel's own bindings — // FA2's typed Fellowship/Allegiance snapshot readers off the // GameRuntime views, plus J4.1's Friends/Squelch owners diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs index 185032e5..87f8b1cd 100644 --- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs +++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs @@ -792,6 +792,67 @@ internal sealed class DeferredWorldLifecycleAutomationRuntime !_deactivated && _target?.IsWorldViewportVisible == true; public int PortalMaterializationCount => !_deactivated ? _target?.PortalMaterializationCount ?? 0 : 0; + public int RenderPackPerformanceSampleCount => + !_deactivated ? _target?.RenderPackPerformanceSampleCount ?? 0 : 0; + public bool RenderPackFailedToRetail => + !_deactivated && _target?.RenderPackFailedToRetail == true; + public RetailUiAutomationRenderPackStatus RenderPackStatus => + !_deactivated + ? _target?.RenderPackStatus + ?? RetailUiAutomationRenderPackStatus.Retail + : RetailUiAutomationRenderPackStatus.Retail; + public int FramebufferWidth => + !_deactivated ? _target?.FramebufferWidth ?? 0 : 0; + public int FramebufferHeight => + !_deactivated ? _target?.FramebufferHeight ?? 0 : 0; + + public bool TrySelectRenderPack(string presetId, out string error) + { + if (!_deactivated && _target is { } target) + return target.TrySelectRenderPack(presetId, out error); + error = "world lifecycle automation is not bound"; + return false; + } + + public bool TryDisableRenderPack(out string error) + { + if (!_deactivated && _target is { } target) + return target.TryDisableRenderPack(out error); + error = "world lifecycle automation is not bound"; + return false; + } + + public bool TryReenableRenderPack(out string error) + { + if (!_deactivated && _target is { } target) + return target.TryReenableRenderPack(out error); + error = "world lifecycle automation is not bound"; + return false; + } + + public bool TryResizeFramebuffer(int width, int height, out string error) + { + if (!_deactivated && _target is { } target) + return target.TryResizeFramebuffer(width, height, out error); + error = "world lifecycle automation is not bound"; + return false; + } + + public bool TryResetRenderPackPerformance(out string error) + { + if (!_deactivated && _target is { } target) + return target.TryResetRenderPackPerformance(out error); + error = "world lifecycle automation is not bound"; + return false; + } + + public bool TryRequestClientClose(out string error) + { + if (!_deactivated && _target is { } target) + return target.TryRequestClientClose(out error); + error = "world lifecycle automation is not bound"; + return false; + } public IDisposable Bind(IRetailUiAutomationRuntime target) { diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 60246361..aa9391f8 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -74,7 +74,9 @@ internal sealed record LivePresentationDependencies( DeferredRenderFrameDiagnosticsSource? DevFrameDiagnostics, DeferredRenderFrameDiagnosticsSource UiFrameDiagnostics, Action Log, - Action? Toast) + Action? Toast, + AcDream.App.Rendering.Packs.IRenderPackDiagnosticsSnapshotSource? + RenderPackDiagnostics = null) { public SelectionState Selection => Runtime.ActionOwner.Selection; @@ -448,7 +450,8 @@ internal sealed class LivePresentationCompositionPhase LiveRenderProjectionJournal? liveRenderProjections = renderSceneShadow?.BindLiveRuntime( liveEntities, - new GpuWorldRenderTraversalOrderSource(worldState)); + new GpuWorldRenderTraversalOrderSource(worldState), + d.PlayerIdentity); Fault(LivePresentationCompositionPoint.CanonicalRuntimeCreated); bindings.Adopt( @@ -803,7 +806,9 @@ internal sealed class LivePresentationCompositionPhase d.TranslucencyFades, selectionScene, d.RetailAlphaQueue, - alphaScratchBudgets.DispatcherBytes), + alphaScratchBudgets.DispatcherBytes, + foundation.TerrainAtlas?.BuildingDetailTexture ?? default, + () => d.Settings.DisplayPreview.BuildingDetailTextures), static value => value.Dispose()); var selectionQuery = new WorldSelectionQuery( liveEntities, @@ -1238,9 +1243,11 @@ internal sealed class LivePresentationCompositionPhase ?? throw new InvalidOperationException( "The graphics backend must publish a world pass scope."), foundation.MeshAdapter!.MeshManager!, - envCellFrustum), + envCellFrustum, + foundation.TerrainAtlas?.EnvironmentDetailTexture ?? default, + () => d.Settings.DisplayPreview.BuildingDetailTextures), static value => value.Dispose()); - // The three pipelines ARE its program, built at construction — the + // The four pipelines ARE its program, built at construction — the // raw-GL arm's separate Initialize(Shader) step was deleted at V11. Fault(LivePresentationCompositionPoint.EnvironmentCellsCreated); @@ -1484,7 +1491,8 @@ internal sealed class LivePresentationCompositionPhase new SilkRenderFrameTitleSink(d.Window), d.RenderDiagnosticLog, d.Options.UiProbeDump, - resourceDiagnostics); + resourceDiagnostics, + d.RenderPackDiagnostics); if (d.DevFrameDiagnostics is { } devFrameDiagnostics) { bindings.Adopt( diff --git a/src/AcDream.App/Composition/SettingsDevToolsComposition.cs b/src/AcDream.App/Composition/SettingsDevToolsComposition.cs index e0863994..15e7df79 100644 --- a/src/AcDream.App/Composition/SettingsDevToolsComposition.cs +++ b/src/AcDream.App/Composition/SettingsDevToolsComposition.cs @@ -1,4 +1,8 @@ using AcDream.App.Settings; +using AcDream.App.Plugins; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Packs; +using AcDream.UI.Abstractions.Panels.Settings; using Silk.NET.Input; namespace AcDream.App.Composition; @@ -18,11 +22,22 @@ namespace AcDream.App.Composition; /// keybinds.json (not retail's .keymap format — register row AP-202). /// internal sealed record SettingsDevToolsResult( - AcDream.UI.Abstractions.Settings.QualitySettings ResolvedQuality); + AcDream.UI.Abstractions.Settings.QualitySettings ResolvedQuality) +{ + internal RenderPackCatalogSource? RenderPacks { get; init; } + + internal RenderPackSelectionSettings RenderPackSelection { get; init; } = + RenderPackSelectionSettings.Retail; +} internal sealed record SettingsDevToolsDependencies( RuntimeSettingsController Settings, - IRuntimeSettingsStartupTarget StartupTarget); + IRuntimeSettingsStartupTarget StartupTarget) +{ + internal BufferedRenderPackRegistry? RenderPacks { get; init; } + + internal IGpuDevice? GpuDevice { get; init; } +} /// /// Production Phase 3: applies the resolved startup display/audio settings. @@ -51,6 +66,19 @@ internal sealed class SettingsDevToolsCompositionPhase : ArgumentNullException.ThrowIfNull(content); _dependencies.Settings.ApplyStartup(_dependencies.StartupTarget); - return new SettingsDevToolsResult(_dependencies.Settings.ResolvedQuality); + RenderPackCatalogSource? renderPacks = null; + if (_dependencies.RenderPacks is { } registry + && _dependencies.GpuDevice is { } gpu) + { + renderPacks = new RenderPackCatalogSource( + registry, + RenderPackCapabilityResolver.Resolve(gpu.Capabilities)); + } + + return new SettingsDevToolsResult(_dependencies.Settings.ResolvedQuality) + { + RenderPacks = renderPacks, + RenderPackSelection = _dependencies.Settings.Display.RenderPack, + }; } } diff --git a/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs b/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs index a96508e8..a7091979 100644 --- a/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs +++ b/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs @@ -82,8 +82,23 @@ internal sealed class VulkanHostInputCameraCompositionFactory KeyBindings bindings) => InputDispatcher.CreateDetached(keyboard, mouse, bindings); - public CameraController CreateCameraController() => - new(new OrbitCamera(), new FlyCamera()); + public CameraController CreateCameraController( + float? initialOrbitDistanceMeters, + float? initialOrbitYawDegrees, + float? initialOrbitPitchDegrees) + { + var orbit = new OrbitCamera(); + if (initialOrbitDistanceMeters is { } distance) + orbit.Distance = distance; + if (initialOrbitYawDegrees is { } yaw) + orbit.Yaw = DegreesToRadians(yaw); + if (initialOrbitPitchDegrees is { } pitch) + orbit.Pitch = DegreesToRadians(pitch); + return new CameraController(orbit, new FlyCamera()); + } + + private static float DegreesToRadians(float degrees) => + degrees * (MathF.PI / 180f); public IFramebufferCameraTarget CreateCameraTarget(CameraController camera) => new CameraFramebufferTarget(camera); diff --git a/src/AcDream.App/Composition/WorldRenderComposition.cs b/src/AcDream.App/Composition/WorldRenderComposition.cs index 8ecdbd0c..76b0055a 100644 --- a/src/AcDream.App/Composition/WorldRenderComposition.cs +++ b/src/AcDream.App/Composition/WorldRenderComposition.cs @@ -78,7 +78,10 @@ internal interface IGameWindowWorldRenderPublication internal interface IWorldRenderCompositionFactory { WorldRegionData LoadRegion(IDatReaderWriter dats); - void InitializeEnvironment(WorldEnvironmentController environment, Region region); + void InitializeEnvironment( + WorldEnvironmentController environment, + Region region, + IDatReaderWriter dats); /// /// Campaign V slice V6i-2: the terrain atlas built through /// . The raw-GL arm this @@ -188,11 +191,13 @@ internal sealed class RetailWorldRenderCompositionFactory public void InitializeEnvironment( WorldEnvironmentController environment, - Region region) + Region region, + IDatReaderWriter dats) { ArgumentNullException.ThrowIfNull(environment); ArgumentNullException.ThrowIfNull(region); - environment.Initialize(region); + ArgumentNullException.ThrowIfNull(dats); + environment.Initialize(region, dats); } public TerrainAtlas AcquireBackendNeutralTerrainAtlas( @@ -452,7 +457,10 @@ internal sealed class WorldRenderCompositionPhase WorldRegionData region = _factory.LoadRegion(content.Dats); Fault(WorldRenderCompositionPoint.RegionLoaded); - _factory.InitializeEnvironment(_dependencies.Environment, region.Region); + _factory.InitializeEnvironment( + _dependencies.Environment, + region.Region, + content.Dats); Fault(WorldRenderCompositionPoint.EnvironmentInitialized); // Campaign V slice V6i-2: the atlas builds through IGpuDevice on diff --git a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs index f6b9ada9..a2588d5b 100644 --- a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs +++ b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs @@ -1,6 +1,9 @@ using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; +using System.Text.Json; +using AcDream.App.Rendering.Packs; + namespace AcDream.App.Diagnostics; /// @@ -22,6 +25,7 @@ internal sealed class FrameScreenshotController private readonly Func _readRgba; private readonly string _directory; private readonly Action _log; + private readonly Func? _renderPackMetadata; private readonly Queue _pending = new(); private readonly Dictionary _status = new(StringComparer.OrdinalIgnoreCase); @@ -29,13 +33,15 @@ internal sealed class FrameScreenshotController internal FrameScreenshotController( Func readRgba, string directory, - Action? log = null) + Action? log = null, + Func? renderPackMetadata = null) { _readRgba = readRgba ?? throw new ArgumentNullException(nameof(readRgba)); _directory = string.IsNullOrWhiteSpace(directory) ? throw new ArgumentException("A screenshot directory is required.", nameof(directory)) : Path.GetFullPath(directory); _log = log ?? (_ => { }); + _renderPackMetadata = renderPackMetadata; } public bool TryRequest(string name, out string error) @@ -85,6 +91,27 @@ internal sealed class FrameScreenshotController string temporaryPath = path + ".tmp"; using (Image image = Image.LoadPixelData(flipped, width, height)) image.SaveAsPng(temporaryPath); + + string? metadataPath = null; + string? temporaryMetadataPath = null; + if (_renderPackMetadata is not null) + { + metadataPath = Path.Combine(_directory, name + ".metadata.json"); + temporaryMetadataPath = metadataPath + ".tmp"; + var metadata = new FrameScreenshotMetadata( + SchemaVersion: 1, + Width: width, + Height: height, + RenderPack: _renderPackMetadata()); + File.WriteAllBytes( + temporaryMetadataPath, + JsonSerializer.SerializeToUtf8Bytes( + metadata, + new JsonSerializerOptions { WriteIndented = true })); + } + + if (metadataPath is not null && temporaryMetadataPath is not null) + File.Move(temporaryMetadataPath, metadataPath, overwrite: true); File.Move(temporaryPath, path, overwrite: true); _status[name] = new CaptureStatus(CaptureState.Complete); @@ -93,6 +120,9 @@ internal sealed class FrameScreenshotController } catch (Exception exception) { + TryDelete(Path.Combine(_directory, name + ".png.tmp")); + TryDelete(Path.Combine(_directory, name + ".metadata.json.tmp")); + TryDelete(Path.Combine(_directory, name + ".metadata.json")); string message = $"screenshot '{name}' failed: {exception.Message}"; _status[name] = new CaptureStatus(CaptureState.Failed, message); _log($"[world-gate] screenshot-failed name={name} error={exception.Message}"); @@ -100,6 +130,22 @@ internal sealed class FrameScreenshotController } } + private static void TryDelete(string path) + { + try + { + File.Delete(path); + } + catch (Exception error) when (error is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException) + { + // Preserve the primary capture error. The next artifact directory + // teardown reports any file that could not be cleaned. + } + } + internal static byte[] FlipRows(byte[] pixels, int width, int height) { int stride = checked(width * 4); @@ -249,3 +295,9 @@ internal sealed class FrameScreenshotController } } + +internal sealed record FrameScreenshotMetadata( + int SchemaVersion, + int Width, + int Height, + RenderPackDiagnosticsSnapshot RenderPack); diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs index 0ac7241e..3122834f 100644 --- a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs +++ b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs @@ -240,6 +240,22 @@ internal sealed class WorldLifecycleAutomationController : private readonly Func _getTransitOwnership; private readonly Func _getPortalMaterializationCount; + private readonly Func _getRenderPackPerformanceSampleCount; + private readonly Func _getRenderPackFailedToRetail; + private readonly Func + _getRenderPackStatus; + private readonly Func + _selectRenderPack; + private readonly Func<(bool Succeeded, string Error)>? + _disableRenderPack; + private readonly Func<(bool Succeeded, string Error)>? + _reenableRenderPack; + private readonly Func<(int Width, int Height)> _getFramebufferSize; + private readonly Func + _resizeFramebuffer; + private readonly Func<(bool Succeeded, string Error)> + _resetRenderPackPerformance; + private readonly Action? _requestClientClose; private readonly Func _captureResources; private readonly FrameScreenshotController _screenshots; @@ -248,6 +264,7 @@ internal sealed class WorldLifecycleAutomationController : private readonly object _requestOwner = new(); private readonly object _sync = new(); private readonly Queue _requests = []; + private string? _lastEnabledRenderPackPreset; private int _sequence; private bool _disposed; @@ -260,7 +277,17 @@ internal sealed class WorldLifecycleAutomationController : Func captureResources, FrameScreenshotController screenshots, string artifactDirectory, - Action? log = null) + Action? log = null, + Func? getRenderPackPerformanceSampleCount = null, + Func<(bool Succeeded, string Error)>? resetRenderPackPerformance = null, + Func? getRenderPackFailedToRetail = null, + Func? getRenderPackStatus = null, + Func? selectRenderPack = null, + Func<(bool Succeeded, string Error)>? disableRenderPack = null, + Func<(bool Succeeded, string Error)>? reenableRenderPack = null, + Func<(int Width, int Height)>? getFramebufferSize = null, + Func? resizeFramebuffer = null, + Action? requestClientClose = null) { _getReveal = getReveal ?? throw new ArgumentNullException(nameof(getReveal)); _getEnvironmentOwnership = getEnvironmentOwnership @@ -270,6 +297,21 @@ internal sealed class WorldLifecycleAutomationController : ?? throw new ArgumentNullException(nameof(getTransitOwnership)); _getPortalMaterializationCount = getPortalMaterializationCount ?? throw new ArgumentNullException(nameof(getPortalMaterializationCount)); + _getRenderPackPerformanceSampleCount = + getRenderPackPerformanceSampleCount ?? (() => 0); + _getRenderPackFailedToRetail = getRenderPackFailedToRetail ?? (() => false); + _getRenderPackStatus = getRenderPackStatus + ?? (() => RetailUiAutomationRenderPackStatus.Retail); + _selectRenderPack = selectRenderPack + ?? (_ => (false, "render-pack selection automation is unavailable")); + _disableRenderPack = disableRenderPack; + _reenableRenderPack = reenableRenderPack; + _getFramebufferSize = getFramebufferSize ?? (() => (0, 0)); + _resizeFramebuffer = resizeFramebuffer + ?? ((_, _) => (false, "framebuffer resize automation is unavailable")); + _resetRenderPackPerformance = resetRenderPackPerformance + ?? (() => (false, "render-pack performance automation is unavailable")); + _requestClientClose = requestClientClose; _captureResources = captureResources ?? throw new ArgumentNullException(nameof(captureResources)); _screenshots = screenshots ?? throw new ArgumentNullException(nameof(screenshots)); _artifactDirectory = string.IsNullOrWhiteSpace(artifactDirectory) @@ -281,6 +323,113 @@ internal sealed class WorldLifecycleAutomationController : public bool IsWorldReady => _getReveal().IsReady; public bool IsWorldViewportVisible => _getReveal().WorldViewportObserved; public int PortalMaterializationCount => _getPortalMaterializationCount(); + public int RenderPackPerformanceSampleCount => + _getRenderPackPerformanceSampleCount(); + public bool RenderPackFailedToRetail => _getRenderPackFailedToRetail(); + public RetailUiAutomationRenderPackStatus RenderPackStatus => + _getRenderPackStatus(); + public int FramebufferWidth => _getFramebufferSize().Width; + public int FramebufferHeight => _getFramebufferSize().Height; + + public bool TrySelectRenderPack(string presetId, out string error) + { + ArgumentException.ThrowIfNullOrWhiteSpace(presetId); + string normalized = presetId.ToLowerInvariant(); + if (normalized == "off") + normalized = "retail"; + if (normalized is not ("retail" or "low" or "medium" or "high" or "auto")) + { + error = $"unknown render-pack preset '{presetId}'"; + return false; + } + + (bool succeeded, string selectionError) = _selectRenderPack(normalized); + if (succeeded && normalized != "retail") + _lastEnabledRenderPackPreset = normalized; + error = selectionError; + return succeeded; + } + + public bool TryDisableRenderPack(out string error) + { + if (_disableRenderPack is not null) + { + (bool succeeded, string disableError) = _disableRenderPack(); + error = disableError; + return succeeded; + } + RetailUiAutomationRenderPackStatus current = RenderPackStatus; + if (current.State == RetailUiAutomationRenderPackState.Active + && !string.Equals(current.PackId, "retail", StringComparison.OrdinalIgnoreCase)) + { + _lastEnabledRenderPackPreset = current.PresetId; + } + return TrySelectRenderPack("retail", out error); + } + + public bool TryReenableRenderPack(out string error) + { + if (_reenableRenderPack is not null) + { + (bool succeeded, string reenableError) = _reenableRenderPack(); + error = reenableError; + return succeeded; + } + if (string.IsNullOrWhiteSpace(_lastEnabledRenderPackPreset)) + { + error = "render-pack re-enable requires a prior active enhanced selection"; + return false; + } + return TrySelectRenderPack(_lastEnabledRenderPackPreset, out error); + } + + public bool TryResizeFramebuffer(int width, int height, out string error) + { + if (width < 320 || height < 240 || width > 8192 || height > 8192) + { + error = "automation framebuffer size must be within 320x240 and 8192x8192"; + return false; + } + (bool succeeded, string resizeError) = _resizeFramebuffer(width, height); + error = resizeError; + return succeeded; + } + + public bool TryResetRenderPackPerformance(out string error) + { + // A terminal fallback owns no enhanced evidence. Treat reset as an + // idempotent no-op so a reset/wait/screenshot automation sequence can + // report the unavailable preset instead of stopping before capture. + if (RenderPackFailedToRetail) + { + error = string.Empty; + return true; + } + (bool succeeded, string resetError) = _resetRenderPackPerformance(); + error = resetError; + return succeeded; + } + + public bool TryRequestClientClose(out string error) + { + if (_requestClientClose is null) + { + error = "client-close automation is unavailable"; + return false; + } + + try + { + _requestClientClose(); + error = string.Empty; + return true; + } + catch (Exception exception) + { + error = $"client-close automation failed: {exception.Message}"; + return false; + } + } public bool TryRequestCheckpoint( string name, diff --git a/src/AcDream.App/Plugins/BufferedRenderPackRegistry.cs b/src/AcDream.App/Plugins/BufferedRenderPackRegistry.cs new file mode 100644 index 00000000..0b034187 --- /dev/null +++ b/src/AcDream.App/Plugins/BufferedRenderPackRegistry.cs @@ -0,0 +1,171 @@ +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.App.Plugins; + +/// +/// Pre-device render-pack discovery buffer. Registration only retains the +/// immutable declaration and lazy asset source; it deliberately never calls +/// or touches the renderer. This lets +/// plugins register before the window/GPU exists without weakening the retail +/// no-op contract. +/// +internal sealed class BufferedRenderPackRegistry : IRenderPackRegistry, IDisposable +{ + private readonly object _sync = new(); + private readonly Dictionary _registrations = + new(StringComparer.OrdinalIgnoreCase); + private long _revision; + private long _nextRegistrationId; + private bool _disposed; + + /// + /// Monotonic catalog generation. Consumers use to + /// invalidate their cached view and consume the new snapshot at a safe + /// frame/UI boundary; no renderer path polls the registry per frame. + /// + internal long Revision + { + get + { + lock (_sync) + return _revision; + } + } + + internal event Action? Changed; + + internal IReadOnlyList Snapshot() + { + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _registrations.Values + .OrderBy(static value => value.Descriptor.Id, StringComparer.OrdinalIgnoreCase) + .Select(static value => new BufferedRenderPackRegistration( + value.Descriptor, + value.Assets, + value.RegistrationId)) + .ToArray(); + } + } + + public IDisposable Register( + RenderPackDescriptor descriptor, + IRenderPackAssets assets) + { + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(assets); + + Registration registration; + long revision; + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_registrations.ContainsKey(descriptor.Id)) + { + throw new InvalidOperationException( + $"A render pack with id '{descriptor.Id}' is already registered."); + } + + registration = new Registration( + this, + descriptor, + assets, + checked(++_nextRegistrationId)); + _registrations.Add(descriptor.Id, registration); + revision = checked(++_revision); + } + + PublishChanged(revision); + return registration; + } + + public void Dispose() + { + Registration[] registrations; + long? revision = null; + lock (_sync) + { + if (_disposed) + return; + _disposed = true; + registrations = _registrations.Values.ToArray(); + _registrations.Clear(); + if (registrations.Length != 0) + revision = checked(++_revision); + } + + foreach (Registration registration in registrations) + registration.WithdrawFromOwner(); + if (revision is { } changedRevision) + PublishChanged(changedRevision); + } + + private void Withdraw(Registration registration) + { + long? revision = null; + lock (_sync) + { + if (_registrations.TryGetValue( + registration.Descriptor.Id, + out Registration? active) + && ReferenceEquals(active, registration)) + { + _registrations.Remove(registration.Descriptor.Id); + revision = checked(++_revision); + } + } + + if (revision is { } changedRevision) + PublishChanged(changedRevision); + } + + private void PublishChanged(long revision) + { + Delegate[] subscribers = Changed?.GetInvocationList() ?? []; + foreach (Delegate subscriber in subscribers) + { + try { ((Action)subscriber)(revision); } + catch + { + // Registration ownership must not be corrupted by a UI or + // controller observer. The next explicit snapshot still sees + // the authoritative revision and contents. + } + } + } + + private sealed class Registration : IDisposable + { + private BufferedRenderPackRegistry? _owner; + + internal Registration( + BufferedRenderPackRegistry owner, + RenderPackDescriptor descriptor, + IRenderPackAssets assets, + long registrationId) + { + _owner = owner; + Descriptor = descriptor; + Assets = assets; + RegistrationId = registrationId; + } + + internal RenderPackDescriptor Descriptor { get; } + + internal IRenderPackAssets Assets { get; } + + internal long RegistrationId { get; } + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)?.Withdraw(this); + + internal void WithdrawFromOwner() => + Interlocked.Exchange(ref _owner, null); + } +} + +internal sealed record BufferedRenderPackRegistration( + RenderPackDescriptor Descriptor, + IRenderPackAssets Assets, + long RegistrationId); diff --git a/src/AcDream.App/Plugins/GraphicalPluginSession.cs b/src/AcDream.App/Plugins/GraphicalPluginSession.cs index a968028c..e7b1f007 100644 --- a/src/AcDream.App/Plugins/GraphicalPluginSession.cs +++ b/src/AcDream.App/Plugins/GraphicalPluginSession.cs @@ -1,6 +1,7 @@ using AcDream.Core.Plugins; using AcDream.Platform; using AcDream.Plugin.Abstractions; +using AcDream.Plugin.Abstractions.Rendering; using AcDream.Runtime.Session; namespace AcDream.App.Plugins; @@ -44,7 +45,8 @@ internal sealed class GraphicalPluginSession : IDisposable IReadOnlyList? allowList, string sessionId, IPluginHost host, - SessionStatusWriter statusWriter) + SessionStatusWriter statusWriter, + IRenderPackRegistry? renderPacks = null) { ArgumentNullException.ThrowIfNull(paths); ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); @@ -53,7 +55,11 @@ internal sealed class GraphicalPluginSession : IDisposable var plugins = new PluginSession( host, - status => Report(statusWriter, sessionId, status)); + status => Report(statusWriter, sessionId, status), + renderPacks, + renderPacks is null + ? [PluginKind.Gameplay] + : [PluginKind.Gameplay, PluginKind.RenderPack]); return new GraphicalPluginSession( plugins, [ diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index d62cbb7b..32c3fecf 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -149,6 +149,15 @@ if (runtimeOptions.DevTools) var worldGameState = new AcDream.Core.Plugins.WorldGameState(); var worldEvents = new AcDream.Core.Plugins.WorldEvents(); var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry(); +using var renderPackRegistry = new AcDream.App.Plugins.BufferedRenderPackRegistry(); +using IDisposable atmosphericPackRegistration = renderPackRegistry.Register( + AcDream.App.Rendering.Packs.BuiltInAtmosphericRenderPack.Descriptor, + AcDream.App.Rendering.Packs.BuiltInAtmosphericRenderPack.CreateAssets( + Path.Combine( + AppContext.BaseDirectory, + "Rendering", + "Shaders", + "spv"))); // Constructed here and handed to both sides: GameWindow binds it to the live // session's Runtime owners, the plugin host exposes it to plugins. using var automation = new AcDream.App.Plugins.AppAutomationSurface(); @@ -158,7 +167,8 @@ using var window = new GameWindow( worldEvents, uiRegistry, graphicalPlatform, - automation); + automation, + renderPackRegistry); var host = new AppPluginHost( new SerilogAdapter(Log.Logger), worldGameState, @@ -171,7 +181,8 @@ GraphicalPluginSession pluginSession = GraphicalPluginSession.Create( runtimeOptions.Plugins, runtimeOptions.SessionId ?? "app", host, - window.StatusWriter); + window.StatusWriter, + renderPackRegistry); window.StartPluginHosting(pluginSession); try diff --git a/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs b/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs index aa279667..c8a4c2ce 100644 --- a/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs +++ b/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs @@ -194,14 +194,14 @@ internal sealed class RhiCompositeTextureArrayBackend : ICompositeTextureArrayBa } /// - /// The GL backend reads GL_MAX_ARRAY_TEXTURE_LAYERS. The pinned - /// has no array-layer field and §3.3 is - /// frozen, so this reports Vulkan's guaranteed maxImageArrayLayers - /// minimum of 256. That is not a limitation in practice: + /// The selected Vulkan adapter's probed maxImageArrayLayers. This is + /// normally far above the cache's own bound: /// caps every /// array at 64, so the true device limit is never the binding constraint. /// - public int MaximumArrayLayers => 256; + public int MaximumArrayLayers => checked((int)Math.Min( + _device.Capabilities.MaxImageArrayLayers, + (uint)int.MaxValue)); public CompositeTextureArrayResource Create(int width, int height, int capacity) { diff --git a/src/AcDream.App/Rendering/DirectionalShadowCascadeFitter.cs b/src/AcDream.App/Rendering/DirectionalShadowCascadeFitter.cs new file mode 100644 index 00000000..72a861dc --- /dev/null +++ b/src/AcDream.App/Rendering/DirectionalShadowCascadeFitter.cs @@ -0,0 +1,286 @@ +using System.Numerics; + +namespace AcDream.App.Rendering; + +internal readonly record struct DirectionalShadowCascadeFitInput( + Matrix4x4 CameraView, + Matrix4x4 CameraProjection, + Vector3 SurfaceToLightDirection, + DirectionalShadowQuality Quality, + float CameraNearMeters = 0.1f, + float PracticalSplitLambda = 0.65f, + float CasterDepthPaddingMeters = 48f, + float ResidentMaximumReachMeters = float.PositiveInfinity); + +internal readonly record struct DirectionalShadowCascade( + int Index, + float SplitNearMeters, + float SplitFarMeters, + Matrix4x4 LightView, + Matrix4x4 LightProjection, + Matrix4x4 WorldToShadowClip, + Vector2 StabilizedLightSpaceCenter, + float HalfExtentMeters, + float TexelWorldSize, + float CasterDepthPaddingMeters, + DirectionalShadowWorldBias Bias); + +/// +/// Pure camera-relative cascade fitting. It receives no scene/PView callback, +/// so fitting N cascades cannot trigger N CPU visibility traversals. +/// +internal static class DirectionalShadowCascadeFitter +{ + private const float RadiusQuantizationMeters = 1f / 16f; + + public static int Fit( + in DirectionalShadowCascadeFitInput input, + Span destination) + { + Validate(in input, destination.Length); + if (!Matrix4x4.Invert(input.CameraView, out Matrix4x4 inverseView)) + throw new ArgumentException("Camera view matrix is not invertible.", nameof(input)); + if (!Matrix4x4.Invert(input.CameraProjection, out Matrix4x4 inverseProjection)) + throw new ArgumentException("Camera projection matrix is not invertible.", nameof(input)); + + Vector3 lightDirection = Vector3.Normalize(input.SurfaceToLightDirection); + float maximumReach = MathF.Min( + input.Quality.MaximumReachMeters, + input.ResidentMaximumReachMeters); + if (maximumReach <= input.CameraNearMeters) + return 0; + float splitNear = input.CameraNearMeters; + Span corners = stackalloc Vector3[8]; + for (int cascadeIndex = 0; + cascadeIndex < input.Quality.CascadeCount; + cascadeIndex++) + { + float splitFar = PracticalSplit( + input.CameraNearMeters, + maximumReach, + cascadeIndex + 1, + input.Quality.CascadeCount, + input.PracticalSplitLambda); + BuildFrustumSliceCorners( + inverseView, + inverseProjection, + splitNear, + splitFar, + corners); + destination[cascadeIndex] = FitCascade( + cascadeIndex, + splitNear, + splitFar, + corners, + lightDirection, + input.Quality.MapResolution, + input.CasterDepthPaddingMeters, + input.Quality.BiasPolicy); + splitNear = splitFar; + } + + return input.Quality.CascadeCount; + } + + internal static float PracticalSplit( + float nearMeters, + float farMeters, + int splitIndex, + int splitCount, + float lambda) + { + if (!float.IsFinite(nearMeters) + || !float.IsFinite(farMeters) + || nearMeters <= 0f + || farMeters <= nearMeters) + { + throw new ArgumentOutOfRangeException(nameof(farMeters)); + } + if (splitCount <= 0 || splitIndex <= 0 || splitIndex > splitCount) + throw new ArgumentOutOfRangeException(nameof(splitIndex)); + if (!float.IsFinite(lambda) || lambda < 0f || lambda > 1f) + throw new ArgumentOutOfRangeException(nameof(lambda)); + + float fraction = (float)splitIndex / splitCount; + float logarithmic = nearMeters * MathF.Pow(farMeters / nearMeters, fraction); + float uniform = nearMeters + (farMeters - nearMeters) * fraction; + return lambda * logarithmic + (1f - lambda) * uniform; + } + + private static DirectionalShadowCascade FitCascade( + int index, + float splitNear, + float splitFar, + ReadOnlySpan corners, + Vector3 surfaceToLight, + int mapResolution, + float depthPadding, + in DirectionalShadowBiasPolicy biasPolicy) + { + Vector3 center = Vector3.Zero; + for (int i = 0; i < corners.Length; i++) + center += corners[i]; + center /= corners.Length; + + float radius = 0f; + for (int i = 0; i < corners.Length; i++) + radius = MathF.Max(radius, Vector3.Distance(center, corners[i])); + radius = MathF.Ceiling(radius / RadiusQuantizationMeters) + * RadiusQuantizationMeters; + radius = MathF.Max(radius, RadiusQuantizationMeters); + + Vector3 up = StableLightUp(surfaceToLight); + Matrix4x4 lightRotation = Matrix4x4.CreateLookAt( + Vector3.Zero, + -surfaceToLight, + up); + + Vector3 lightCenter = Vector3.Transform(center, lightRotation); + float texelWorldSize = (2f * radius) / mapResolution; + float snappedX = SnapToTexel(lightCenter.X, texelWorldSize); + float snappedY = SnapToTexel(lightCenter.Y, texelWorldSize); + + float minZ = float.PositiveInfinity; + float maxZ = float.NegativeInfinity; + for (int i = 0; i < corners.Length; i++) + { + float z = Vector3.Transform(corners[i], lightRotation).Z; + minZ = MathF.Min(minZ, z); + maxZ = MathF.Max(maxZ, z); + } + + // Move the light eye toward the selected celestial source. The + // receiver slice then lies + // between depthPadding and span+depthPadding metres in front of it, + // while the far extension admits casters behind the slice as well. + float eyeAxis = maxZ + depthPadding; + Vector3 eye = surfaceToLight * eyeAxis; + Matrix4x4 lightView = Matrix4x4.CreateLookAt( + eye, + eye - surfaceToLight, + up); + float nearPlane = 0.1f; + float farPlane = MathF.Max( + nearPlane + 0.1f, + (maxZ - minZ) + 2f * depthPadding); + Matrix4x4 lightProjection = Matrix4x4.CreateOrthographicOffCenter( + snappedX - radius, + snappedX + radius, + snappedY - radius, + snappedY + radius, + nearPlane, + farPlane); + + return new DirectionalShadowCascade( + index, + splitNear, + splitFar, + lightView, + lightProjection, + lightView * lightProjection, + new Vector2(snappedX, snappedY), + radius, + texelWorldSize, + depthPadding, + biasPolicy.Resolve(texelWorldSize)); + } + + private static void BuildFrustumSliceCorners( + Matrix4x4 inverseView, + Matrix4x4 inverseProjection, + float nearMeters, + float farMeters, + Span destination) + { + int cursor = 0; + for (int depthIndex = 0; depthIndex < 2; depthIndex++) + { + float distance = depthIndex == 0 ? nearMeters : farMeters; + for (int yIndex = 0; yIndex < 2; yIndex++) + { + float y = yIndex == 0 ? -1f : 1f; + for (int xIndex = 0; xIndex < 2; xIndex++) + { + float x = xIndex == 0 ? -1f : 1f; + Vector4 viewCorner = Vector4.Transform( + new Vector4(x, y, 1f, 1f), + inverseProjection); + if (MathF.Abs(viewCorner.W) <= 1e-6f) + throw new ArgumentException("Camera projection produced a corner at infinity."); + Vector3 view = new( + viewCorner.X / viewCorner.W, + viewCorner.Y / viewCorner.W, + viewCorner.Z / viewCorner.W); + float viewDepth = MathF.Abs(view.Z); + if (viewDepth <= 1e-6f) + throw new ArgumentException("Camera projection produced zero view depth."); + view *= distance / viewDepth; + destination[cursor++] = Vector3.Transform(view, inverseView); + } + } + } + } + + private static float SnapToTexel(float value, float texelWorldSize) => + MathF.Round(value / texelWorldSize, MidpointRounding.AwayFromZero) + * texelWorldSize; + + /// + /// Uses Duff's numerically stable revision of Frisvad's orthonormal basis. + /// The selected celestial source occupies the accepted upper hemisphere, + /// where this basis varies continuously through the exact zenith. The old + /// 0.95 dot-product branch rotated the cascade basis abruptly, while + /// projected world-up merely moved that discontinuity to exact zenith. + /// + internal static Vector3 StableLightUp(Vector3 surfaceToLight) + { + surfaceToLight = Vector3.Normalize(surfaceToLight); + float sign = MathF.CopySign(1f, surfaceToLight.Z); + float a = -1f / (sign + surfaceToLight.Z); + float b = surfaceToLight.X * surfaceToLight.Y * a; + return Vector3.Normalize(new Vector3( + b, + sign + surfaceToLight.Y * surfaceToLight.Y * a, + -surfaceToLight.Y)); + } + + private static void Validate( + in DirectionalShadowCascadeFitInput input, + int destinationLength) + { + DirectionalShadowQuality quality = input.Quality; + if (quality.CascadeCount <= 0 || quality.CascadeCount > 4) + throw new ArgumentOutOfRangeException(nameof(input), "Cascade count must be in [1,4]."); + if (destinationLength < quality.CascadeCount) + throw new ArgumentException("Destination cannot hold every configured cascade."); + if (quality.MapResolution <= 0 + || !float.IsFinite(quality.MaximumReachMeters) + || quality.MaximumReachMeters <= input.CameraNearMeters) + { + throw new ArgumentOutOfRangeException(nameof(input), "Shadow quality dimensions are invalid."); + } + if (!float.IsFinite(input.CameraNearMeters) || input.CameraNearMeters <= 0f) + throw new ArgumentOutOfRangeException(nameof(input), "Camera near distance must be positive."); + if (float.IsNaN(input.ResidentMaximumReachMeters) + || input.ResidentMaximumReachMeters < 0f) + { + throw new ArgumentOutOfRangeException( + nameof(input), + "Resident shadow reach must be nonnegative or positive infinity."); + } + if (!float.IsFinite(input.PracticalSplitLambda) + || input.PracticalSplitLambda < 0f + || input.PracticalSplitLambda > 1f) + { + throw new ArgumentOutOfRangeException(nameof(input), "Split lambda must be in [0,1]."); + } + if (!float.IsFinite(input.CasterDepthPaddingMeters) + || input.CasterDepthPaddingMeters <= 0f) + { + throw new ArgumentOutOfRangeException(nameof(input), "Caster depth padding must be positive."); + } + float lightLength = input.SurfaceToLightDirection.Length(); + if (!float.IsFinite(lightLength) || lightLength <= 1e-6f) + throw new ArgumentOutOfRangeException(nameof(input), "Light direction must be finite and nonzero."); + } +} diff --git a/src/AcDream.App/Rendering/DirectionalShadowQuality.cs b/src/AcDream.App/Rendering/DirectionalShadowQuality.cs new file mode 100644 index 00000000..7c8d6c6d --- /dev/null +++ b/src/AcDream.App/Rendering/DirectionalShadowQuality.cs @@ -0,0 +1,362 @@ +using AcDream.App.Rendering.Packs; +using AcDream.Core.World; + +namespace AcDream.App.Rendering; + +/// +/// User-visible quality rows for Dereth's selected celestial directional shadows. Presets may +/// reduce count, resolution, reach, and filtering cost; they never remove a +/// headline caster class. +/// +internal enum DirectionalShadowPreset : byte +{ + Low, + Medium, + High, +} + +[Flags] +internal enum DirectionalShadowSemantics : ushort +{ + None = 0, + Terrain = 1 << 0, + TreesAndOutdoorStatics = 1 << 1, + Buildings = 1 << 2, + Players = 1 << 3, + Monsters = 1 << 4, + AnimatedTransforms = 1 << 5, + AlphaCutoutCasters = 1 << 6, + + Headline = Terrain + | TreesAndOutdoorStatics + | Buildings + | Players + | Monsters + | AnimatedTransforms + | AlphaCutoutCasters, +} + +/// +/// Converts shadow-map texel footprint into receiver-side offsets expressed in +/// metres. No term is an NDC constant: the projection may change without +/// silently changing the amount of world geometry displaced. +/// +internal readonly record struct DirectionalShadowBiasPolicy( + float ConstantTexels, + float SlopeTexels, + float NormalTexels, + float MinimumMeters, + float MaximumMeters) +{ + public DirectionalShadowWorldBias Resolve(float texelWorldSize) + { + if (!float.IsFinite(texelWorldSize) || texelWorldSize <= 0f) + throw new ArgumentOutOfRangeException(nameof(texelWorldSize)); + if (!float.IsFinite(MinimumMeters) + || !float.IsFinite(MaximumMeters) + || MinimumMeters < 0f + || MaximumMeters < MinimumMeters) + { + throw new InvalidOperationException( + "Directional-shadow bias bounds must be finite, non-negative, and ordered."); + } + + var minimumMeters = MinimumMeters; + var maximumMeters = MaximumMeters; + return new DirectionalShadowWorldBias( + ConstantDepthMeters: Math.Clamp( + ConstantTexels * texelWorldSize, + minimumMeters, + maximumMeters), + SlopeDepthMeters: Math.Clamp( + SlopeTexels * texelWorldSize, + minimumMeters, + maximumMeters), + NormalOffsetMeters: Math.Clamp( + NormalTexels * texelWorldSize, + minimumMeters, + maximumMeters)); + } +} + +internal readonly record struct DirectionalShadowWorldBias( + float ConstantDepthMeters, + float SlopeDepthMeters, + float NormalOffsetMeters); + +internal readonly record struct DirectionalShadowQuality( + DirectionalShadowPreset Preset, + int CascadeCount, + int MapResolution, + float MaximumReachMeters, + int PcfRadiusTexels, + long ApproximateDepthMapBytes, + double IncrementalGpuP50BudgetMilliseconds, + double IncrementalGpuP99BudgetMilliseconds, + double IncrementalCpuP50BudgetMilliseconds, + double IncrementalCpuP99BudgetMilliseconds, + long PackResidentGpuByteBudget, + DirectionalShadowSemantics Semantics, + DirectionalShadowBiasPolicy BiasPolicy) +{ + private const long MiB = 1024L * 1024L; + + public static DirectionalShadowQuality For(DirectionalShadowPreset preset) => + preset switch + { + DirectionalShadowPreset.Low => Create( + preset, + cascades: 2, + // The physical integrated-GPU row funds Low's cheaper + // quarter-resolution separable post path by reducing only + // texel density. Both cascades and every semantic caster + // class remain present. + resolution: 768, + reachMeters: 72f, + pcfRadius: 0, + gpuP50: 2.0, + gpuP99: 3.0, + cpuP50: 0.15, + cpuP99: 0.50, + residentBudget: 64L * MiB, + bias: new DirectionalShadowBiasPolicy( + 0.45f, 1.25f, 1.0f, 0.001f, 0.35f)), + DirectionalShadowPreset.Medium => Create( + preset, + cascades: 3, + resolution: 1536, + reachMeters: 144f, + pcfRadius: 1, + gpuP50: 3.25, + gpuP99: 4.50, + cpuP50: 0.25, + cpuP99: 0.75, + residentBudget: 128L * MiB, + bias: new DirectionalShadowBiasPolicy( + 0.40f, 1.15f, 0.9f, 0.001f, 0.30f)), + DirectionalShadowPreset.High => Create( + preset, + cascades: 4, + resolution: 2048, + reachMeters: 240f, + pcfRadius: 2, + gpuP50: 4.50, + gpuP99: 6.00, + cpuP50: 0.35, + cpuP99: 1.00, + residentBudget: 256L * MiB, + bias: new DirectionalShadowBiasPolicy( + 0.35f, 1.0f, 0.8f, 0.001f, 0.25f)), + _ => throw new ArgumentOutOfRangeException(nameof(preset), preset, null), + }; + + private static DirectionalShadowQuality Create( + DirectionalShadowPreset preset, + int cascades, + int resolution, + float reachMeters, + int pcfRadius, + double gpuP50, + double gpuP99, + double cpuP50, + double cpuP99, + long residentBudget, + DirectionalShadowBiasPolicy bias) => + new( + preset, + cascades, + resolution, + reachMeters, + pcfRadius, + checked((long)cascades * resolution * resolution * sizeof(float)), + gpuP50, + gpuP99, + cpuP50, + cpuP99, + residentBudget, + DirectionalShadowSemantics.Headline, + bias); +} + +internal enum DirectionalShadowGateReason : byte +{ + Enabled, + PackDisabled, + PortalOrLoginCover, + Indoor, + NoVisibleCelestial, + SelectedLightBelowHorizon, + SelectedLightHasNoEnergy, + AtmosphereSuppressed, + ResidentWindowUnavailable, +} + +/// +/// Visible policy owned by the selected atmospheric pack. AC continues to own +/// the sky and weather inputs; these values only map them to enhancement +/// strength and softness. +/// +internal readonly record struct DirectionalShadowAtmospherePolicy( + float MinimumLightElevationSin, + float FullStrengthLightElevationSin, + float ClearStrength, + float OvercastStrength, + float RainStrength, + float SnowStrength, + float StormStrength, + float ClearSoftness, + float OvercastSoftness, + float RainSoftness, + float SnowSoftness, + float StormSoftness) +{ + public static DirectionalShadowAtmospherePolicy BuiltIn { get; } = new( + MinimumLightElevationSin: MathF.Sin(MathF.PI / 180f), + FullStrengthLightElevationSin: MathF.Sin(12f * MathF.PI / 180f), + ClearStrength: 1.0f, + OvercastStrength: 0.65f, + RainStrength: 0.45f, + SnowStrength: 0.60f, + StormStrength: 0.25f, + ClearSoftness: 1.0f, + OvercastSoftness: 1.5f, + RainSoftness: 1.8f, + SnowSoftness: 1.6f, + StormSoftness: 2.0f); + + public float StrengthFor(WeatherKind weather) => weather switch + { + WeatherKind.Clear => ClearStrength, + WeatherKind.Overcast => OvercastStrength, + WeatherKind.Rain => RainStrength, + WeatherKind.Snow => SnowStrength, + WeatherKind.Storm => StormStrength, + _ => throw new ArgumentOutOfRangeException(nameof(weather), weather, null), + }; + + public float SoftnessFor(WeatherKind weather) => weather switch + { + WeatherKind.Clear => ClearSoftness, + WeatherKind.Overcast => OvercastSoftness, + WeatherKind.Rain => RainSoftness, + WeatherKind.Snow => SnowSoftness, + WeatherKind.Storm => StormSoftness, + _ => throw new ArgumentOutOfRangeException(nameof(weather), weather, null), + }; +} + +internal readonly record struct DirectionalShadowEnvironmentInput( + bool PackEnabled, + bool PortalOrLoginCoverVisible, + bool PlayerInsideCell, + AuthoredCelestialShadowSource Source, + AtmosphereSnapshot Atmosphere, + float ActiveDayGroupMultiplier = 1f); + +internal readonly record struct DirectionalShadowEnvironmentState( + DirectionalShadowGateReason Reason, + System.Numerics.Vector3 SurfaceToLightDirection, + float LightElevationSin, + float Strength, + float SoftnessMultiplier, + AuthoredCelestialShadowSourceKind SourceKind = + AuthoredCelestialShadowSourceKind.None, + int SourceObjectIndex = -1, + uint SourceGfxObjId = 0u) +{ + public bool ShouldRender => Reason is DirectionalShadowGateReason.Enabled; +} + +internal static class DirectionalShadowEnvironmentGate +{ + private const float MinimumDirectionalEnergy = 1e-5f; + + public static DirectionalShadowEnvironmentState Evaluate( + in DirectionalShadowEnvironmentInput input, + in DirectionalShadowAtmospherePolicy policy) + { + if (!input.PackEnabled) + return Disabled(DirectionalShadowGateReason.PackDisabled); + if (input.PortalOrLoginCoverVisible) + return Disabled(DirectionalShadowGateReason.PortalOrLoginCover); + if (input.PlayerInsideCell) + return Disabled(DirectionalShadowGateReason.Indoor); + + if (!input.Source.IsAvailable) + return Disabled(DirectionalShadowGateReason.NoVisibleCelestial); + + System.Numerics.Vector3 surfaceToLight = + input.Source.SurfaceToLightDirection; + float elevation = input.Source.ElevationSin; + if (!float.IsFinite(elevation) + || elevation <= policy.MinimumLightElevationSin) + { + return new DirectionalShadowEnvironmentState( + DirectionalShadowGateReason.SelectedLightBelowHorizon, + surfaceToLight, + elevation, + 0f, + 1f, + input.Source.Kind, + input.Source.ObjectIndex, + input.Source.GfxObjId); + } + + float energy = input.Source.AuthoredEnergy; + if (!float.IsFinite(energy) + || energy <= MinimumDirectionalEnergy) + { + return new DirectionalShadowEnvironmentState( + DirectionalShadowGateReason.SelectedLightHasNoEnergy, + surfaceToLight, + elevation, + 0f, + 1f, + input.Source.Kind, + input.Source.ObjectIndex, + input.Source.GfxObjId); + } + + float elevationSpan = MathF.Max( + 1e-5f, + policy.FullStrengthLightElevationSin - policy.MinimumLightElevationSin); + float elevationStrength = Math.Clamp( + (elevation - policy.MinimumLightElevationSin) / elevationSpan, + 0f, + 1f); + float weatherStrength = policy.StrengthFor(input.Atmosphere.Kind); + float atmosphereProgress = Math.Clamp(input.Atmosphere.Intensity, 0f, 1f); + float dayGroupStrength = Math.Clamp(input.ActiveDayGroupMultiplier, 0f, 1f); + float strength = elevationStrength + * Math.Clamp(energy, 0f, 1f) + * weatherStrength + * atmosphereProgress + * dayGroupStrength; + if (!float.IsFinite(strength) || strength <= 0f) + { + return new DirectionalShadowEnvironmentState( + DirectionalShadowGateReason.AtmosphereSuppressed, + surfaceToLight, + elevation, + 0f, + policy.SoftnessFor(input.Atmosphere.Kind), + input.Source.Kind, + input.Source.ObjectIndex, + input.Source.GfxObjId); + } + + return new DirectionalShadowEnvironmentState( + DirectionalShadowGateReason.Enabled, + surfaceToLight, + elevation, + Math.Clamp(strength, 0f, 1f), + MathF.Max(1f, policy.SoftnessFor(input.Atmosphere.Kind)), + input.Source.Kind, + input.Source.ObjectIndex, + input.Source.GfxObjId); + } + + private static DirectionalShadowEnvironmentState Disabled( + DirectionalShadowGateReason reason) => + new(reason, System.Numerics.Vector3.UnitZ, 0f, 0f, 1f); +} diff --git a/src/AcDream.App/Rendering/DirectionalShadowReceiver.cs b/src/AcDream.App/Rendering/DirectionalShadowReceiver.cs new file mode 100644 index 00000000..0e023b9a --- /dev/null +++ b/src/AcDream.App/Rendering/DirectionalShadowReceiver.cs @@ -0,0 +1,154 @@ +using System.Numerics; +using AcDream.App.Rendering.Gpu; + +namespace AcDream.App.Rendering; + +/// +/// The ordinary, non-ref view of the directional-shadow allocation produced for +/// one frame. The serial prevents a ring slice from leaking into a later frame. +/// +internal readonly record struct DirectionalShadowFrameBinding( + long FrameSerial, + bool Enabled, + IGpuBuffer? Buffer, + uint OffsetBytes, + uint SizeBytes, + GpuTextureSlot TextureSlot, + int CascadeCount) +{ + internal static DirectionalShadowFrameBinding Disabled => default; + + internal bool IsValidFor(IGpuFrame frame) => + Enabled + && Buffer is not null + && FrameSerial == frame.Serial + && SizeBytes == DirectionalShadowUniforms.SizeInBytes + && TextureSlot.IsAssigned + && CascadeCount is >= 2 and <= 4; +} + +/// +/// Receiver-side seam. A pack runtime may publish this source at a stable frame +/// boundary without exposing the producer's target or ref-struct allocation. +/// +internal interface IDirectionalShadowReceiverSource +{ + DirectionalShadowPipelineShaders PipelineShaders { get; } + + bool TryGetCurrentFrameBinding( + IGpuFrame frame, + out DirectionalShadowFrameBinding binding); +} + +internal readonly record struct DirectionalShadowPipelineShaders( + GpuShaderSet TerrainCaster, + GpuShaderSet WorldOpaqueCaster, + GpuShaderSet WorldAlphaCutoutCaster, + GpuShaderSet TerrainReceiver, + GpuShaderSet WorldReceiver) +{ + internal DirectionalShadowMultiviewPipelineShaders? MultiviewCasters { get; init; } + + internal static DirectionalShadowPipelineShaders Local { get; } = new( + new GpuShaderSet("directional_shadow_terrain"), + new GpuShaderSet("directional_shadow_world_opaque"), + new GpuShaderSet("directional_shadow_world_cutout"), + new GpuShaderSet("terrain_atmospheric"), + new GpuShaderSet("mesh_atmospheric")) + { + MultiviewCasters = new DirectionalShadowMultiviewPipelineShaders( + new GpuShaderSet("directional_shadow_terrain_multiview"), + new GpuShaderSet("directional_shadow_world_opaque_multiview"), + new GpuShaderSet("directional_shadow_world_cutout_multiview")), + }; +} + +internal readonly record struct DirectionalShadowMultiviewPipelineShaders( + GpuShaderSet TerrainCaster, + GpuShaderSet WorldOpaqueCaster, + GpuShaderSet WorldAlphaCutoutCaster); + +internal readonly record struct DirectionalShadowCascadeBlend( + int PrimaryCascade, + int SecondaryCascade, + float SecondaryWeight, + bool WithinShadowReach); + +/// CPU mirror of receiver-only cascade and world-metre bias policy. +internal static class DirectionalShadowReceiverPolicy +{ + internal const string AtmosphericWorldPassName = "atmospheric-world-hdr"; + + internal static bool ShouldSelectReceiverPipeline( + string passName, + bool sourcePresent, + bool bindingValid) => + sourcePresent + && bindingValid + && string.Equals( + passName, + AtmosphericWorldPassName, + StringComparison.Ordinal); + + internal static DirectionalShadowCascadeBlend SelectCascade( + float cameraDistanceMeters, + Vector4 splitFarMeters, + int cascadeCount, + float blendWidthMeters) + { + if (!float.IsFinite(cameraDistanceMeters) || cameraDistanceMeters < 0f) + throw new ArgumentOutOfRangeException(nameof(cameraDistanceMeters)); + if (cascadeCount is < 2 or > 4) + throw new ArgumentOutOfRangeException(nameof(cascadeCount)); + if (!float.IsFinite(blendWidthMeters) || blendWidthMeters < 0f) + throw new ArgumentOutOfRangeException(nameof(blendWidthMeters)); + + Span splits = stackalloc float[4] + { + splitFarMeters.X, + splitFarMeters.Y, + splitFarMeters.Z, + splitFarMeters.W, + }; + for (int i = 0; i < cascadeCount; i++) + { + if (!float.IsFinite(splits[i]) + || splits[i] <= 0f + || (i > 0 && splits[i] < splits[i - 1])) + { + throw new ArgumentException( + "Directional-shadow split distances must be finite, positive, and monotonic.", + nameof(splitFarMeters)); + } + } + + int primary = 0; + while (primary < cascadeCount && cameraDistanceMeters > splits[primary]) + primary++; + if (primary == cascadeCount) + return new DirectionalShadowCascadeBlend(cascadeCount - 1, cascadeCount - 1, 0f, false); + + if (primary == cascadeCount - 1 || blendWidthMeters <= 0f) + return new DirectionalShadowCascadeBlend(primary, primary, 0f, true); + + float blendStart = MathF.Max(0f, splits[primary] - blendWidthMeters); + float t = Math.Clamp( + (cameraDistanceMeters - blendStart) / MathF.Max(blendWidthMeters, 1e-6f), + 0f, + 1f); + float smooth = t * t * (3f - 2f * t); + return new DirectionalShadowCascadeBlend(primary, primary + 1, smooth, true); + } + + internal static float ReceiverBiasMeters( + in DirectionalShadowWorldBias bias, + float normalDotSurfaceToLight) => + bias.ConstantDepthMeters + + bias.SlopeDepthMeters * (1f - Math.Clamp(normalDotSurfaceToLight, 0f, 1f)); + + internal static bool ShouldSample( + bool bindingEnabled, + bool indoor, + bool hasSelectedCelestialDirectionalLight) => + bindingEnabled && !indoor && hasSelectedCelestialDirectionalLight; +} diff --git a/src/AcDream.App/Rendering/DirectionalShadowTransformBufferSet.cs b/src/AcDream.App/Rendering/DirectionalShadowTransformBufferSet.cs new file mode 100644 index 00000000..9ecc4e32 --- /dev/null +++ b/src/AcDream.App/Rendering/DirectionalShadowTransformBufferSet.cs @@ -0,0 +1,439 @@ +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Wb; + +namespace AcDream.App.Rendering; + +internal readonly record struct DirectionalShadowTransformPublishStats( + bool TopologyUploaded, + int DynamicMatricesUpdated, + int DynamicRangesUpdated, + long BytesWritten, + int CurrentChangedMatrices = 0, + int PendingReplayMatrices = 0, + bool UsedFullDynamicFallback = false, + bool DenseDirectUpload = false, + bool DenseFlightReplay = false); + +/// +/// Pack-owned transform storage indexed by the RHI frame-flight slot. A slot is +/// handed back only after its prior GPU submission retires, so stable topology +/// can keep every static matrix in mapped storage and update only the exact +/// dynamic indices already refreshed by . +/// No animation, scene lookup, or pose derivation happens here. +/// +internal sealed class DirectionalShadowTransformBufferSet : IDisposable +{ + private readonly IGpuDevice _device; + private SlotState[] _slots = []; + private ulong _denseTopologyBuildSequence; + private ulong _denseRevision = 1; + private bool _disposed; + + internal DirectionalShadowTransformBufferSet(IGpuDevice device) + { + _device = device ?? throw new ArgumentNullException(nameof(device)); + if (!device.Capabilities.SupportsPersistentlyMappedRings) + { + throw new NotSupportedException( + "Directional-shadow retained transforms require persistently mapped host-writable buffers."); + } + } + + internal long RetainedGpuBytes + { + get + { + long total = 0; + for (int i = 0; i < _slots.Length; i++) + total = checked(total + (_slots[i].Buffer?.SizeBytes ?? 0L)); + return total; + } + } + + internal int BufferCount + { + get + { + int count = 0; + for (int i = 0; i < _slots.Length; i++) + { + if (_slots[i].Buffer is not null) + count++; + } + return count; + } + } + + internal long RetainedScratchBytes + { + get + { + long bytes = checked((long)_slots.Length + * System.Runtime.CompilerServices.Unsafe.SizeOf()); + for (int index = 0; index < _slots.Length; index++) + bytes = checked(bytes + (_slots[index].Pending?.RetainedBytes ?? 0L)); + return bytes; + } + } + + internal DirectionalShadowTransformPublishStats LastStats { get; private set; } + + internal WorldTransformFrameSlice Publish( + IGpuFrame frame, + ulong topologyBuildSequence, + ReadOnlySpan transforms, + ReadOnlySpan dynamicTransformSlots) + { + return Publish( + frame, + topologyBuildSequence, + transforms, + dynamicTransformSlots, + dynamicTransformSlots, + denseRefresh: false); + } + + internal WorldTransformFrameSlice Publish( + IGpuFrame frame, + ulong topologyBuildSequence, + ReadOnlySpan transforms, + ReadOnlySpan dynamicTransformSlots, + ReadOnlySpan allDynamicTransformSlots) + { + return Publish( + frame, + topologyBuildSequence, + transforms, + dynamicTransformSlots, + allDynamicTransformSlots, + denseRefresh: false); + } + + internal WorldTransformFrameSlice Publish( + IGpuFrame frame, + ulong topologyBuildSequence, + ReadOnlySpan transforms, + ReadOnlySpan dynamicTransformSlots, + ReadOnlySpan allDynamicTransformSlots, + bool denseRefresh, + uint bindingSizeBytes = 0) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(frame); + if (topologyBuildSequence == 0) + throw new ArgumentOutOfRangeException(nameof(topologyBuildSequence)); + uint requiredInstances = checked((uint)transforms.Length); + if (bindingSizeBytes == 0) + { + bindingSizeBytes = WorldTransformCapacityPolicy.ResolveBindingSizeBytes( + requiredInstances, + _device.Capabilities.MaxStorageBufferRangeBytes); + } + WorldTransformCapacityPolicy.ValidateBindingSizeBytes( + bindingSizeBytes, + requiredInstances, + _device.Capabilities.MaxStorageBufferRangeBytes); + if (!denseRefresh) + ValidateDynamicSlots(dynamicTransformSlots, transforms.Length); + ResetDenseRevisionForTopology(topologyBuildSequence); + if (denseRefresh) + { + ValidateDynamicSlots(allDynamicTransformSlots, transforms.Length); + if (_denseRevision == ulong.MaxValue) + { + throw new InvalidOperationException( + "Directional-shadow dense transform revision was exhausted."); + } + _denseRevision++; + for (int index = 0; index < _slots.Length; index++) + _slots[index].Pending?.Clear(); + } + EnsureSlotCapacity(frame.SlotIndex); + ref SlotState slot = ref _slots[frame.SlotIndex]; + bool matchingSlot = slot.Buffer is not null + && slot.TopologyBuildSequence == topologyBuildSequence + && slot.TransformCount == transforms.Length + && slot.Buffer.SizeBytes >= bindingSizeBytes; + bool denseFlightReplay = matchingSlot + && slot.ConsumedDenseRevision != _denseRevision; + int pendingReplayMatrices = matchingSlot + ? slot.Pending?.Count ?? 0 + : 0; + if (!denseRefresh) + { + MarkPendingChanges( + topologyBuildSequence, + transforms.Length, + dynamicTransformSlots); + } + int contentBytes = checked(transforms.Length * 64); + int allocationBytes = checked((int)bindingSizeBytes); + + if (slot.Buffer is null + || slot.TopologyBuildSequence != topologyBuildSequence + || slot.TransformCount != transforms.Length + || slot.Buffer.SizeBytes < allocationBytes) + { + IGpuBuffer? candidate = null; + try + { + candidate = _device.CreateBuffer(new GpuBufferDescription( + $"directional-shadow-transforms-slot-{frame.SlotIndex}-build-{topologyBuildSequence}", + allocationBytes, + GpuBufferUsage.Storage | GpuBufferUsage.TransferDestination, + GpuMemoryResidency.HostWritable)); + if (!candidate.HostWritesAreCoherent) + { + throw new NotSupportedException( + "Directional-shadow retained transforms require coherent " + + "host-writable Vulkan memory. The pack will fail safe " + + "on this adapter rather than expose unflushed pose data."); + } + if (!transforms.IsEmpty) + { + candidate.Upload(0, MemoryMarshal.AsBytes(transforms)); + frame.PublishHostStorageWrites(candidate); + } + } + catch + { + candidate?.Dispose(); + throw; + } + + IGpuBuffer? previous = slot.Buffer; + PendingTransformSet pending = slot.Pending + ?? new PendingTransformSet(transforms.Length); + pending.EnsureCapacity(transforms.Length); + pending.Clear(); + slot = new SlotState( + candidate, + topologyBuildSequence, + transforms.Length, + pending, + _denseRevision); + previous?.Dispose(); + LastStats = new DirectionalShadowTransformPublishStats( + TopologyUploaded: true, + DynamicMatricesUpdated: 0, + DynamicRangesUpdated: 0, + BytesWritten: contentBytes, + CurrentChangedMatrices: dynamicTransformSlots.Length, + PendingReplayMatrices: 0, + DenseDirectUpload: denseRefresh); + } + else + { + PendingTransformSet pending = slot.Pending + ?? throw new InvalidOperationException( + "A retained directional-shadow flight slot has no pending-change owner."); + bool directDenseUpload = denseRefresh || denseFlightReplay; + ReadOnlySpan slotsToUpload = directDenseUpload + ? allDynamicTransformSlots + : pending.GetSorted(); + if (directDenseUpload && !denseRefresh) + ValidateDynamicSlots(allDynamicTransformSlots, transforms.Length); + int ranges = UploadDynamicRanges( + slot.Buffer, + transforms, + slotsToUpload, + out long bytesWritten); + if (ranges != 0) + frame.PublishHostStorageWrites(slot.Buffer); + LastStats = new DirectionalShadowTransformPublishStats( + TopologyUploaded: false, + DynamicMatricesUpdated: slotsToUpload.Length, + DynamicRangesUpdated: ranges, + BytesWritten: bytesWritten, + CurrentChangedMatrices: dynamicTransformSlots.Length, + PendingReplayMatrices: directDenseUpload ? 0 : pendingReplayMatrices, + DenseDirectUpload: denseRefresh, + DenseFlightReplay: denseFlightReplay && !denseRefresh); + pending.Clear(); + slot = slot with { ConsumedDenseRevision = _denseRevision }; + } + + IGpuBuffer buffer = slot.Buffer + ?? throw new InvalidOperationException( + "The retained directional-shadow transform buffer was not published."); + return new WorldTransformFrameSlice( + frame.Serial, + buffer, + BaseOffsetBytes: 0, + checked((uint)buffer.SizeBytes), + FirstInstance: 0, + checked((uint)transforms.Length)); + } + + private void ResetDenseRevisionForTopology(ulong topologyBuildSequence) + { + if (_denseTopologyBuildSequence == topologyBuildSequence) + return; + _denseTopologyBuildSequence = topologyBuildSequence; + _denseRevision = 1; + for (int index = 0; index < _slots.Length; index++) + _slots[index].Pending?.Clear(); + } + + private void MarkPendingChanges( + ulong topologyBuildSequence, + int transformCount, + ReadOnlySpan dynamicTransformSlots) + { + if (dynamicTransformSlots.IsEmpty) + return; + for (int index = 0; index < _slots.Length; index++) + { + ref SlotState candidate = ref _slots[index]; + if (candidate.Buffer is null + || candidate.TopologyBuildSequence != topologyBuildSequence + || candidate.TransformCount != transformCount) + { + continue; + } + PendingTransformSet pending = candidate.Pending + ??= new PendingTransformSet(transformCount); + pending.EnsureCapacity(transformCount); + pending.Mark(dynamicTransformSlots); + } + } + + private static int UploadDynamicRanges( + IGpuBuffer buffer, + ReadOnlySpan transforms, + ReadOnlySpan slots, + out long bytesWritten) + { + bytesWritten = 0; + int ranges = 0; + int cursor = 0; + while (cursor < slots.Length) + { + int start = slots[cursor]; + int end = start + 1; + cursor++; + while (cursor < slots.Length && slots[cursor] == end) + { + end++; + cursor++; + } + + ReadOnlySpan values = transforms.Slice(start, end - start); + ReadOnlySpan bytes = MemoryMarshal.AsBytes(values); + buffer.Upload(checked((long)start * 64L), bytes); + bytesWritten = checked(bytesWritten + bytes.Length); + ranges++; + } + return ranges; + } + + private static void ValidateDynamicSlots( + ReadOnlySpan slots, + int transformCount) + { + int previous = -1; + for (int i = 0; i < slots.Length; i++) + { + int current = slots[i]; + if ((uint)current >= (uint)transformCount) + { + throw new InvalidOperationException( + $"Dynamic shadow transform slot {current} is outside the " + + $"{transformCount}-matrix retained product."); + } + if (current <= previous) + { + throw new InvalidOperationException( + "Dynamic shadow transform slots must be strictly increasing."); + } + previous = current; + } + } + + private void EnsureSlotCapacity(int slotIndex) + { + ArgumentOutOfRangeException.ThrowIfNegative(slotIndex); + if (_slots.Length > slotIndex) + return; + int capacity = _slots.Length == 0 ? 2 : _slots.Length; + while (capacity <= slotIndex) + capacity = checked(capacity * 2); + Array.Resize(ref _slots, capacity); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + for (int i = 0; i < _slots.Length; i++) + { + _slots[i].Buffer?.Dispose(); + _slots[i] = default; + } + LastStats = default; + _denseTopologyBuildSequence = 0; + _denseRevision = 0; + } + + private record struct SlotState( + IGpuBuffer? Buffer, + ulong TopologyBuildSequence, + int TransformCount, + PendingTransformSet? Pending, + ulong ConsumedDenseRevision); + + private sealed class PendingTransformSet + { + private int[] _slots; + private bool[] _marked; + + internal PendingTransformSet(int capacity) + { + ArgumentOutOfRangeException.ThrowIfNegative(capacity); + _slots = new int[capacity]; + _marked = new bool[capacity]; + } + + internal int Count { get; private set; } + + internal long RetainedBytes => checked( + (long)_slots.Length * sizeof(int) + _marked.Length); + + internal void EnsureCapacity(int capacity) + { + ArgumentOutOfRangeException.ThrowIfNegative(capacity); + if (_slots.Length >= capacity) + return; + Array.Resize(ref _slots, capacity); + Array.Resize(ref _marked, capacity); + } + + internal void Mark(ReadOnlySpan slots) + { + for (int index = 0; index < slots.Length; index++) + { + int slot = slots[index]; + if (_marked[slot]) + continue; + _marked[slot] = true; + _slots[Count++] = slot; + } + } + + internal ReadOnlySpan GetSorted() + { + Array.Sort(_slots, 0, Count); + return _slots.AsSpan(0, Count); + } + + internal void Clear() + { + for (int index = 0; index < Count; index++) + _marked[_slots[index]] = false; + Count = 0; + } + } +} diff --git a/src/AcDream.App/Rendering/DirectionalShadowUniforms.cs b/src/AcDream.App/Rendering/DirectionalShadowUniforms.cs new file mode 100644 index 00000000..0148ce06 --- /dev/null +++ b/src/AcDream.App/Rendering/DirectionalShadowUniforms.cs @@ -0,0 +1,111 @@ +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu; + +namespace AcDream.App.Rendering; + +/// +/// Shader ABI SSOT companion for opt-in set 3 binding 6. The matching GLSL block is +/// directional_shadow_common.glsl; both are pinned at 336 std140 bytes. +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal readonly struct DirectionalShadowUniforms +{ + internal const int SizeInBytes = 336; + + public readonly Matrix4x4 WorldToClip0; + public readonly Matrix4x4 WorldToClip1; + public readonly Matrix4x4 WorldToClip2; + public readonly Matrix4x4 WorldToClip3; + public readonly Vector4 SplitFarMeters; + public readonly Vector4 Control; + public readonly Vector4 BiasMeters; + public readonly UInt4 TextureAndFlags; + public readonly Vector4 LightDirectionAndSource; + + internal DirectionalShadowUniforms( + Matrix4x4 worldToClip0, + Matrix4x4 worldToClip1, + Matrix4x4 worldToClip2, + Matrix4x4 worldToClip3, + Vector4 splitFarMeters, + Vector4 control, + Vector4 biasMeters, + UInt4 textureAndFlags, + Vector4 lightDirectionAndSource) + { + WorldToClip0 = worldToClip0; + WorldToClip1 = worldToClip1; + WorldToClip2 = worldToClip2; + WorldToClip3 = worldToClip3; + SplitFarMeters = splitFarMeters; + Control = control; + BiasMeters = biasMeters; + TextureAndFlags = textureAndFlags; + LightDirectionAndSource = lightDirectionAndSource; + } + + internal static DirectionalShadowUniforms Create( + ReadOnlySpan cascades, + in DirectionalShadowEnvironmentState environment, + in DirectionalShadowQuality quality, + GpuTextureSlot textureSlot) + { + if (cascades.Length != quality.CascadeCount) + throw new ArgumentException("The cascade span must match the selected quality.", nameof(cascades)); + if (!textureSlot.IsAssigned) + throw new ArgumentException("The directional depth array requires an assigned texture slot.", nameof(textureSlot)); + + Matrix4x4 matrix0 = cascades[0].WorldToShadowClip; + Matrix4x4 matrix1 = cascades.Length > 1 ? cascades[1].WorldToShadowClip : Matrix4x4.Identity; + Matrix4x4 matrix2 = cascades.Length > 2 ? cascades[2].WorldToShadowClip : Matrix4x4.Identity; + Matrix4x4 matrix3 = cascades.Length > 3 ? cascades[3].WorldToShadowClip : Matrix4x4.Identity; + float split0 = cascades[0].SplitFarMeters; + float split1 = cascades.Length > 1 ? cascades[1].SplitFarMeters : quality.MaximumReachMeters; + float split2 = cascades.Length > 2 ? cascades[2].SplitFarMeters : quality.MaximumReachMeters; + float split3 = cascades.Length > 3 ? cascades[3].SplitFarMeters : quality.MaximumReachMeters; + + // The pinned v1 receiver block carries one world-space bias triple. + // Publish the conservative outer-cascade values; the receiver derives + // each inner cascade's relative texel footprint from its projection + // matrix before applying this triple. That preserves the bias portion + // of the v1 ABI while the selected-light vec4 is appended at byte 320 + // without applying the outer map's visibly excessive offset nearby. + DirectionalShadowWorldBias bias = cascades[^1].Bias; + float effectiveReachMeters = cascades[^1].SplitFarMeters; + return new DirectionalShadowUniforms( + matrix0, + matrix1, + matrix2, + matrix3, + new Vector4(split0, split1, split2, split3), + new Vector4( + environment.Strength, + environment.SoftnessMultiplier, + effectiveReachMeters, + MathF.Max(1f, effectiveReachMeters * 0.02f)), + new Vector4( + bias.ConstantDepthMeters, + bias.SlopeDepthMeters, + bias.NormalOffsetMeters, + cascades[0].CasterDepthPaddingMeters), + new UInt4( + textureSlot.Index, + checked((uint)quality.CascadeCount), + checked((uint)quality.MapResolution), + 1u | (checked((uint)quality.PcfRadiusTexels) << 8)), + new Vector4( + environment.SurfaceToLightDirection, + checked((uint)environment.SourceKind))); + } +} + +/// Four uints with the exact 16-byte std140 uvec4 representation. +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal readonly struct UInt4(uint x, uint y, uint z, uint w) +{ + public readonly uint X = x; + public readonly uint Y = y; + public readonly uint Z = z; + public readonly uint W = w; +} diff --git a/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs b/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs new file mode 100644 index 00000000..56eaccef --- /dev/null +++ b/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs @@ -0,0 +1,951 @@ +using System.Diagnostics; +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Packs; +using AcDream.App.Rendering.Scene; +using AcDream.App.Rendering.Wb; +using DatReaderWriter.Enums; + +namespace AcDream.App.Rendering; + +internal readonly record struct DirectionalSunShadowRenderInput( + DirectionalShadowEnvironmentInput Environment, + Matrix4x4 CameraView, + Matrix4x4 CameraProjection, + DirectionalShadowCasterFrame Casters, + float CameraNearMeters = 0.1f, + float CasterDepthPaddingMeters = 48f, + float ResidentMaximumReachMeters = float.PositiveInfinity, + bool MeasureGpuTimers = true, + bool MeasureCpuStages = false); + +internal readonly record struct DirectionalSunShadowCpuStageTicks( + long EnvironmentGateTicks, + long PreparedDrawsAndTransformsTicks, + long FitAndUniformTicks, + long LayeredPassRecordingTicks, + long BookkeepingTicks); + +internal readonly record struct DirectionalShadowTransformChurnDiagnostics( + int CopiedSceneChanges, + int UpdateTransformChanges, + int UpdateAppearanceChanges, + int DynamicSynchronizationChanges, + int ActiveAnimatedStaticChanges, + int LiveDynamicRootChanges, + int EquippedChildChanges, + int DedupedCasterSlots, + bool SceneJournalFullRefresh, + bool DensityBulkRefresh, + int BatchedProjectionCopyCalls, + int ChangedMatrixSlots, + int FlightCurrentChangedMatrices, + int FlightPendingReplayMatrices, + int FlightUploadedMatrices, + int FlightUploadRanges, + long FlightBytesWritten, + bool FlightFullDynamicFallback, + bool DenseDirectUpload, + bool DenseFlightReplay, + DirectionalShadowCasterClassDiagnostics CasterClasses = default); + +internal readonly record struct DirectionalSunShadowDiagnostics( + DirectionalShadowGateReason GateReason, + float Strength, + int CascadeCount, + int DrawCalls, + int WorldOpaqueCommands, + int WorldAlphaCutoutCommands, + int TerrainCommands, + ulong WorldPreparationSequence, + ulong TerrainPreparationSequence, + double CpuMilliseconds, + double LastResolvedGpuMilliseconds, + bool HasResolvedGpuMeasurement, + long ResidentDepthBytes, + DirectionalSunShadowCpuStageTicks CpuStages = default, + DirectionalShadowTransformChurnDiagnostics TransformChurn = default, + AuthoredCelestialShadowSourceKind SourceKind = + AuthoredCelestialShadowSourceKind.None, + int SourceObjectIndex = -1, + uint SourceGfxObjId = 0u, + Vector3 SurfaceToLightDirection = default, + float LightElevationSin = 0f); + +internal static class DirectionalShadowBatchFlags +{ + internal const uint AlphaCutout = 1u << 0; + internal static uint Encode(DirectionalShadowCasterMaterial material) => + material is DirectionalShadowCasterMaterial.AlphaCutout + ? AlphaCutout + : 0u; +} + +/// +/// Tier-2 producer only: fits selected celestial-light cascades and records +/// their depth maps. +/// It does not alter the retail world pass or sample shadows in receivers. +/// +internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverSource, IDisposable +{ + internal const string TimerPrefix = "directional-shadow-cascade-"; + internal const string MultiviewTimerName = "directional-shadow-multiview"; + internal const uint LowMultiviewMask = 0b11; + private const int DrawCommandStride = 20; + + private readonly IGpuDevice _device; + private readonly DirectionalShadowQuality _quality; + private readonly DirectionalShadowAtmospherePolicy _atmospherePolicy; + private readonly DirectionalShadowPipelineShaders _pipelineShaders; + private readonly bool _multiviewCascades; + private readonly IGpuDirectionalDepthTarget _target; + private readonly IGpuSampler _sampler; + private readonly GpuTextureSlot _textureSlot; + private readonly IGpuPipeline _terrainPipeline; + private readonly IGpuPipeline _worldOpaquePipeline; + private readonly IGpuPipeline _worldCutoutPipeline; + private readonly IGpuPipeline? _terrainMultiviewPipeline; + private readonly IGpuPipeline? _worldOpaqueMultiviewPipeline; + private readonly IGpuPipeline? _worldCutoutMultiviewPipeline; + private readonly DirectionalShadowTransformBufferSet _transformBuffers; + private readonly DirectionalShadowCascade[] _cascades = new DirectionalShadowCascade[4]; + private DirectionalShadowBatchGpuData[] _batchScratch = []; + private IGpuBuffer? _worldBatchBuffer; + private IGpuBuffer? _worldCommandBuffer; + private IGpuBuffer? _terrainCommandBuffer; + private ulong _worldGpuBuildSequence; + private ulong _terrainGpuBuildSequence; + private DirectionalShadowFrameBinding _currentFrameBinding; + private bool _disposed; + + internal DirectionalSunShadowRenderer( + IGpuDevice device, + DirectionalShadowPreset preset, + DirectionalShadowAtmospherePolicy? atmospherePolicy = null, + DirectionalShadowPipelineShaders? pipelineShaders = null, + bool multiviewCascades = false) + : this( + device, + DirectionalShadowQuality.For(preset), + atmospherePolicy, + pipelineShaders, + multiviewCascades) + { + } + + internal DirectionalSunShadowRenderer( + IGpuDevice device, + DirectionalShadowQuality quality, + DirectionalShadowAtmospherePolicy? atmospherePolicy = null, + DirectionalShadowPipelineShaders? pipelineShaders = null, + bool multiviewCascades = false) + { + _device = device ?? throw new ArgumentNullException(nameof(device)); + if (quality.CascadeCount is < 1 or > 4 + || quality.MapResolution <= 0 + || !float.IsFinite(quality.MaximumReachMeters) + || quality.MaximumReachMeters <= 0f + || quality.PcfRadiusTexels is < 0 or > 2) + { + throw new ArgumentOutOfRangeException( + nameof(quality), + "Directional-shadow quality must declare 1..4 cascades, a positive " + + "resolution/reach, and a 0..2 PCF radius."); + } + _quality = quality; + _atmospherePolicy = atmospherePolicy ?? DirectionalShadowAtmospherePolicy.BuiltIn; + _pipelineShaders = pipelineShaders ?? DirectionalShadowPipelineShaders.Local; + _multiviewCascades = multiviewCascades; + if (multiviewCascades && quality.CascadeCount != 2) + throw new NotSupportedException("The multiview shadow hint requires exactly two Low cascades."); + if (multiviewCascades && !device.Capabilities.SupportsMultiview) + throw new NotSupportedException("The selected device does not support multiview shadow cascades."); + if (multiviewCascades && _pipelineShaders.MultiviewCasters is null) + throw new NotSupportedException("The pack did not declare all multiview shadow caster variants."); + + IGpuDirectionalDepthTarget? target = null; + IGpuSampler? sampler = null; + GpuTextureSlot textureSlot = GpuTextureSlot.Unassigned; + IGpuPipeline? terrain = null; + IGpuPipeline? opaque = null; + IGpuPipeline? cutout = null; + IGpuPipeline? terrainMultiview = null; + IGpuPipeline? opaqueMultiview = null; + IGpuPipeline? cutoutMultiview = null; + DirectionalShadowTransformBufferSet? transformBuffers = null; + try + { + target = device.CreateDirectionalDepthTarget( + new GpuDirectionalDepthTargetDescription( + $"directional-shadow-{quality.Preset.ToString().ToLowerInvariant()}", + _quality.MapResolution, + _quality.CascadeCount)); + sampler = device.CreateSampler(GpuSamplerDescription.ShadowNearestClamp); + textureSlot = device.RegisterTexture(target.DepthTexture, sampler); + terrain = CreatePipeline( + device, + "directional-shadow-terrain", + _pipelineShaders.TerrainCaster, + TerrainModernRenderer.TerrainVertexLayout, + GpuFrontFace.CounterClockwise); + opaque = CreatePipeline( + device, + "directional-shadow-world-opaque", + _pipelineShaders.WorldOpaqueCaster, + GpuVertexLayout.WorldMesh, + GpuFrontFace.Clockwise); + cutout = CreatePipeline( + device, + "directional-shadow-world-cutout", + _pipelineShaders.WorldAlphaCutoutCaster, + GpuVertexLayout.WorldMesh, + GpuFrontFace.Clockwise); + if (multiviewCascades) + { + DirectionalShadowMultiviewPipelineShaders shaders = + _pipelineShaders.MultiviewCasters!.Value; + terrainMultiview = CreatePipeline(device, "directional-shadow-terrain-multiview", + shaders.TerrainCaster, TerrainModernRenderer.TerrainVertexLayout, + GpuFrontFace.CounterClockwise, LowMultiviewMask); + opaqueMultiview = CreatePipeline(device, "directional-shadow-world-opaque-multiview", + shaders.WorldOpaqueCaster, GpuVertexLayout.WorldMesh, + GpuFrontFace.Clockwise, LowMultiviewMask); + cutoutMultiview = CreatePipeline(device, "directional-shadow-world-cutout-multiview", + shaders.WorldAlphaCutoutCaster, GpuVertexLayout.WorldMesh, + GpuFrontFace.Clockwise, LowMultiviewMask); + } + transformBuffers = new DirectionalShadowTransformBufferSet(device); + } + catch + { + transformBuffers?.Dispose(); + cutoutMultiview?.Dispose(); + opaqueMultiview?.Dispose(); + terrainMultiview?.Dispose(); + cutout?.Dispose(); + opaque?.Dispose(); + terrain?.Dispose(); + if (textureSlot.IsAssigned) + device.ReleaseTextureSlot(textureSlot); + sampler?.Dispose(); + target?.Dispose(); + throw; + } + + _target = target; + _sampler = sampler; + _textureSlot = textureSlot; + _terrainPipeline = terrain; + _worldOpaquePipeline = opaque; + _worldCutoutPipeline = cutout; + _terrainMultiviewPipeline = terrainMultiview; + _worldOpaqueMultiviewPipeline = opaqueMultiview; + _worldCutoutMultiviewPipeline = cutoutMultiview; + _transformBuffers = transformBuffers; + } + + internal DirectionalShadowQuality Quality => _quality; + + internal bool MultiviewCascadesEnabled => _multiviewCascades; + + internal static string TimerName(int cascadeIndex) => cascadeIndex switch + { + 0 => "directional-shadow-cascade-0", + 1 => "directional-shadow-cascade-1", + 2 => "directional-shadow-cascade-2", + 3 => "directional-shadow-cascade-3", + _ => throw new ArgumentOutOfRangeException(nameof(cascadeIndex)), + }; + + internal IGpuTexture DepthTexture => _target.DepthTexture; + + internal GpuTextureSlot TextureSlot => _textureSlot; + + public DirectionalShadowPipelineShaders PipelineShaders => _pipelineShaders; + + internal DirectionalShadowFrameBinding CurrentFrameBinding => _currentFrameBinding; + + /// + /// Topology-only command metadata lives in pack-owned device-local buffers. + /// It is rebuilt transactionally when the retained CPU product changes and + /// is never copied through a per-frame ring on a stable scene. + /// + internal long RetainedCommandBufferBytes => checked( + (_worldBatchBuffer?.SizeBytes ?? 0L) + + (_worldCommandBuffer?.SizeBytes ?? 0L) + + (_terrainCommandBuffer?.SizeBytes ?? 0L)); + + internal int RetainedCommandBufferCount => + (_worldBatchBuffer is null ? 0 : 1) + + (_worldCommandBuffer is null ? 0 : 1) + + (_terrainCommandBuffer is null ? 0 : 1); + + internal long RetainedGpuBufferBytes => checked( + RetainedCommandBufferBytes + _transformBuffers.RetainedGpuBytes); + + internal int RetainedGpuBufferCount => checked( + RetainedCommandBufferCount + _transformBuffers.BufferCount); + + public bool TryGetCurrentFrameBinding( + IGpuFrame frame, + out DirectionalShadowFrameBinding binding) + { + ArgumentNullException.ThrowIfNull(frame); + binding = _currentFrameBinding; + return !_disposed && binding.IsValidFor(frame); + } + + internal DirectionalSunShadowDiagnostics Render( + IGpuFrame frame, + in DirectionalSunShadowRenderInput input, + WbDrawDispatcher world, + TerrainModernRenderer terrain) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(frame); + _currentFrameBinding = DirectionalShadowFrameBinding.Disabled; + ArgumentNullException.ThrowIfNull(world); + ArgumentNullException.ThrowIfNull(terrain); + long cpuStageStarted = input.MeasureCpuStages ? Stopwatch.GetTimestamp() : 0L; + DirectionalShadowEnvironmentState environment = + DirectionalShadowEnvironmentGate.Evaluate( + input.Environment, + _atmospherePolicy); + long environmentGateTicks = input.MeasureCpuStages + ? Stopwatch.GetTimestamp() - cpuStageStarted + : 0L; + if (!environment.ShouldRender) + return Disabled( + in environment, + new DirectionalSunShadowCpuStageTicks( + environmentGateTicks, 0L, 0L, 0L, 0L)); + if (input.ResidentMaximumReachMeters <= input.CameraNearMeters) + { + environment = environment with + { + Reason = DirectionalShadowGateReason.ResidentWindowUnavailable, + }; + return Disabled( + in environment, + new DirectionalSunShadowCpuStageTicks( + environmentGateTicks, 0L, 0L, 0L, 0L)); + } + + cpuStageStarted = input.MeasureCpuStages ? Stopwatch.GetTimestamp() : 0L; + DirectionalShadowPreparedDraws worldDraws = + world.PrepareDirectionalShadowDraws(input.Casters); + DirectionalShadowTerrainPreparedDraws terrainDraws = + terrain.PrepareDirectionalShadowDraws(); + DirectionalShadowMeshGeometry? worldGeometry = + worldDraws.Commands.IsEmpty ? null : world.GetDirectionalShadowGeometry(); + DirectionalShadowTerrainGeometry? terrainGeometry = + terrainDraws.Commands.IsEmpty ? null : terrain.GetDirectionalShadowGeometry(); + uint transformBindingSizeBytes = + world.ResolveDirectionalShadowTransformBindingSize( + worldDraws.Transforms.Length, + // Stats counts every current WB source render batch before + // transparent/cutout shadow rejection. Ordinary WB submission + // publishes at most one matrix per source batch, making this a + // complete-frame upper bound available before shadow commands + // bind the one authoritative pose buffer. + worldDraws.Stats.SourceBatches); + WorldTransformFrameSlice retainedTransforms = _transformBuffers.Publish( + frame, + worldDraws.BuildSequence, + worldDraws.Transforms, + worldDraws.DynamicTransformSlots, + worldDraws.AllDynamicTransformSlots, + worldDraws.LastDynamicTransformRefreshWasDense, + transformBindingSizeBytes); + WorldTransformFrameSlice transforms = + world.BeginDirectionalShadowTransformFrame( + frame, + in retainedTransforms); + DirectionalShadowCasterBuildStats casterStats = input.Casters.Stats; + DirectionalShadowCasterClassDiagnostics casterClasses = + CompleteCasterClassDiagnostics( + in casterStats, + terrainDraws.Commands.Length); + DirectionalShadowTransformPublishStats publishStats = + _transformBuffers.LastStats; + var transformChurn = new DirectionalShadowTransformChurnDiagnostics( + casterStats.CopiedTransformChanges, + casterStats.UpdateTransformChanges, + casterStats.UpdateAppearanceChanges, + casterStats.DynamicSynchronizationChanges, + casterStats.ActiveAnimatedStaticChanges, + casterStats.LiveDynamicRootChanges, + casterStats.EquippedChildChanges, + casterStats.DedupedChangedCasterSlots, + casterStats.TransformJournalFullRefresh, + casterStats.DensityBulkRefresh, + casterStats.BatchedProjectionCopyCalls, + worldDraws.LastDynamicTransformRefreshCount, + publishStats.CurrentChangedMatrices, + publishStats.PendingReplayMatrices, + publishStats.DynamicMatricesUpdated, + publishStats.DynamicRangesUpdated, + publishStats.BytesWritten, + publishStats.UsedFullDynamicFallback, + publishStats.DenseDirectUpload, + publishStats.DenseFlightReplay, + casterClasses); + long preparedDrawsAndTransformsTicks = input.MeasureCpuStages + ? Stopwatch.GetTimestamp() - cpuStageStarted + : 0L; + try + { + return RenderPrepared( + frame, + environment, + input.CameraView, + input.CameraProjection, + input.CameraNearMeters, + input.CasterDepthPaddingMeters, + worldDraws, + terrainDraws, + worldGeometry, + terrainGeometry, + transforms, + input.ResidentMaximumReachMeters, + input.MeasureGpuTimers, + input.MeasureCpuStages, + new DirectionalSunShadowCpuStageTicks( + environmentGateTicks, + preparedDrawsAndTransformsTicks, + 0L, + 0L, + 0L), + transformChurn); + } + catch + { + world.CancelDirectionalShadowTransformFrame(frame); + throw; + } + } + + internal static DirectionalShadowCasterClassDiagnostics + CompleteCasterClassDiagnostics( + in DirectionalShadowCasterBuildStats casterStats, + int terrainCommandCount) + { + ArgumentOutOfRangeException.ThrowIfNegative(terrainCommandCount); + return casterStats.CasterClasses with + { + TerrainCommands = terrainCommandCount, + }; + } + + internal DirectionalSunShadowDiagnostics RenderPrepared( + IGpuFrame frame, + in DirectionalShadowEnvironmentState environment, + Matrix4x4 cameraView, + Matrix4x4 cameraProjection, + float cameraNearMeters, + float casterDepthPaddingMeters, + DirectionalShadowPreparedDraws worldDraws, + DirectionalShadowTerrainPreparedDraws terrainDraws, + DirectionalShadowMeshGeometry? worldGeometry, + DirectionalShadowTerrainGeometry? terrainGeometry, + WorldTransformFrameSlice worldTransforms, + float residentMaximumReachMeters = float.PositiveInfinity, + bool measureGpuTimers = true, + bool measureCpuStages = false, + DirectionalSunShadowCpuStageTicks cpuStages = default, + DirectionalShadowTransformChurnDiagnostics transformChurn = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(frame); + _currentFrameBinding = DirectionalShadowFrameBinding.Disabled; + ArgumentNullException.ThrowIfNull(worldDraws); + ArgumentNullException.ThrowIfNull(terrainDraws); + if (!environment.ShouldRender) + return Disabled(in environment, cpuStages); + if (!worldDraws.Commands.IsEmpty && worldGeometry is null) + throw new ArgumentNullException(nameof(worldGeometry)); + if (!terrainDraws.Commands.IsEmpty && terrainGeometry is null) + throw new ArgumentNullException(nameof(terrainGeometry)); + if (!worldTransforms.IsValidFor(frame)) + throw new ArgumentException( + "Shadow transforms must use this frame's shared N.5 allocation.", + nameof(worldTransforms)); + + long started = Stopwatch.GetTimestamp(); + var fit = new DirectionalShadowCascadeFitInput( + cameraView, + cameraProjection, + environment.SurfaceToLightDirection, + _quality, + cameraNearMeters, + PracticalSplitLambda: 0.65f, + casterDepthPaddingMeters, + residentMaximumReachMeters); + int cascadeCount = DirectionalShadowCascadeFitter.Fit( + fit, + _cascades); + if (cascadeCount == 0) + { + DirectionalShadowEnvironmentState unavailable = environment with + { + Reason = DirectionalShadowGateReason.ResidentWindowUnavailable, + }; + return Disabled(in unavailable, cpuStages); + } + + ReadOnlySpan cascades = + _cascades.AsSpan(0, cascadeCount); + DirectionalShadowUniforms uniforms = DirectionalShadowUniforms.Create( + cascades, + environment, + _quality, + _textureSlot); + GpuRingAllocation uniformAllocation = frame.AllocateRing( + DirectionalShadowUniforms.SizeInBytes, + GpuRingUsage.Uniform); + MemoryMarshal.Write(uniformAllocation.Data, in uniforms); + + long fitAndUniformFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L; + PreparedGpuUploads uploads = PrepareGpuData( + worldTransforms, + worldDraws, + terrainDraws); + if (MultiviewCascadesEnabled) + { + using IGpuPassEncoder encoder = frame.BeginPass( + GpuPassDescription.DirectionalDepthMultiview( + "directional-shadow-multiview", + _target, + LowMultiviewMask)); + using IDisposable? timer = measureGpuTimers + ? encoder.BeginTimerScope(MultiviewTimerName) + : null; + encoder.BindUniformBuffer( + GpuBindingModel.UniformDirectionalShadow, + uniformAllocation.Buffer, + uniformAllocation.OffsetBytes, + DirectionalShadowUniforms.SizeInBytes); + DrawTerrain(encoder, uploads, terrainDraws, terrainGeometry, 0, + _terrainMultiviewPipeline); + DrawWorld(encoder, uploads, worldDraws, worldGeometry, 0, + _worldOpaqueMultiviewPipeline, _worldCutoutMultiviewPipeline); + } + else for (int cascadeIndex = 0; cascadeIndex < cascadeCount; cascadeIndex++) + { + using IGpuPassEncoder encoder = frame.BeginPass( + GpuPassDescription.DirectionalDepth( + $"directional-shadow-{cascadeIndex}", + _target, + cascadeIndex)); + using IDisposable? timer = measureGpuTimers + ? encoder.BeginTimerScope(TimerName(cascadeIndex)) + : null; + encoder.BindUniformBuffer( + GpuBindingModel.UniformDirectionalShadow, + uniformAllocation.Buffer, + uniformAllocation.OffsetBytes, + DirectionalShadowUniforms.SizeInBytes); + + DrawTerrain(encoder, uploads, terrainDraws, terrainGeometry, cascadeIndex); + DrawWorld(encoder, uploads, worldDraws, worldGeometry, cascadeIndex); + } + + long passRecordingFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L; + + _currentFrameBinding = new DirectionalShadowFrameBinding( + frame.Serial, + Enabled: true, + uniformAllocation.Buffer, + uniformAllocation.OffsetBytes, + DirectionalShadowUniforms.SizeInBytes, + _textureSlot, + cascadeCount); + + (bool hasGpu, double gpuMilliseconds) = ResolveGpu(cascadeCount); + int drawsPerCascade = terrainDraws.Commands.IsEmpty ? 0 : 1; + drawsPerCascade = checked( + drawsPerCascade + + (worldDraws.Commands.IsEmpty + ? 0 + : worldDraws.OpaqueRuns.Length + worldDraws.AlphaCutoutRuns.Length)); + long finished = Stopwatch.GetTimestamp(); + cpuStages = cpuStages with + { + FitAndUniformTicks = measureCpuStages + ? fitAndUniformFinished - started + : 0L, + LayeredPassRecordingTicks = measureCpuStages + ? passRecordingFinished - fitAndUniformFinished + : 0L, + BookkeepingTicks = measureCpuStages + ? finished - passRecordingFinished + : 0L, + }; + return new DirectionalSunShadowDiagnostics( + DirectionalShadowGateReason.Enabled, + environment.Strength, + cascadeCount, + checked((MultiviewCascadesEnabled ? 1 : cascadeCount) * drawsPerCascade), + worldDraws.OpaqueCommandCount, + worldDraws.AlphaCutoutCommandCount, + terrainDraws.Commands.Length, + worldDraws.BuildSequence, + terrainDraws.BuildSequence, + (finished - started) * 1000d / Stopwatch.Frequency, + gpuMilliseconds, + hasGpu, + _quality.ApproximateDepthMapBytes, + cpuStages, + transformChurn, + environment.SourceKind, + environment.SourceObjectIndex, + environment.SourceGfxObjId, + environment.SurfaceToLightDirection, + environment.LightElevationSin); + } + + private PreparedGpuUploads PrepareGpuData( + in WorldTransformFrameSlice transforms, + DirectionalShadowPreparedDraws world, + DirectionalShadowTerrainPreparedDraws terrain) + { + if (_worldGpuBuildSequence != world.BuildSequence) + RebuildWorldGpuData(world); + if (_terrainGpuBuildSequence != terrain.BuildSequence) + RebuildTerrainGpuData(terrain); + + return new PreparedGpuUploads( + transforms, + Slice(_worldBatchBuffer), + Slice(_worldCommandBuffer), + Slice(_terrainCommandBuffer)); + } + + private void RebuildWorldGpuData(DirectionalShadowPreparedDraws world) + { + IGpuBuffer? batches = null; + IGpuBuffer? commands = null; + try + { + if (!world.Commands.IsEmpty) + { + EnsureBatchCapacity(world.Batches.Length); + for (int i = 0; i < world.Batches.Length; i++) + { + DirectionalShadowPreparedBatch batch = world.Batches[i]; + _batchScratch[i] = new DirectionalShadowBatchGpuData( + batch.TextureSlot.Index, + 0u, + batch.TextureLayer, + DirectionalShadowBatchFlags.Encode(batch.Material)); + } + + ReadOnlySpan batchBytes = MemoryMarshal.AsBytes( + _batchScratch.AsSpan(0, world.Batches.Length)); + ReadOnlySpan commandBytes = MemoryMarshal.AsBytes( + world.Commands); + batches = CreateRetainedBuffer( + $"directional-shadow-world-batches-{world.BuildSequence}", + batchBytes, + GpuBufferUsage.Storage); + commands = CreateRetainedBuffer( + $"directional-shadow-world-commands-{world.BuildSequence}", + commandBytes, + GpuBufferUsage.Indirect); + } + } + catch + { + commands?.Dispose(); + batches?.Dispose(); + throw; + } + + IGpuBuffer? previousBatches = _worldBatchBuffer; + IGpuBuffer? previousCommands = _worldCommandBuffer; + _worldBatchBuffer = batches; + _worldCommandBuffer = commands; + _worldGpuBuildSequence = world.BuildSequence; + previousCommands?.Dispose(); + previousBatches?.Dispose(); + } + + private void RebuildTerrainGpuData(DirectionalShadowTerrainPreparedDraws terrain) + { + IGpuBuffer? commands = null; + if (!terrain.Commands.IsEmpty) + { + commands = CreateRetainedBuffer( + $"directional-shadow-terrain-commands-{terrain.BuildSequence}", + MemoryMarshal.AsBytes(terrain.Commands), + GpuBufferUsage.Indirect); + } + + IGpuBuffer? previous = _terrainCommandBuffer; + _terrainCommandBuffer = commands; + _terrainGpuBuildSequence = terrain.BuildSequence; + previous?.Dispose(); + } + + private IGpuBuffer CreateRetainedBuffer( + string name, + ReadOnlySpan contents, + GpuBufferUsage usage) + { + if (contents.IsEmpty) + throw new ArgumentException("Retained shadow buffers cannot be empty.", nameof(contents)); + IGpuBuffer buffer = _device.CreateBuffer(new GpuBufferDescription( + name, + contents.Length, + usage | GpuBufferUsage.TransferDestination, + GpuMemoryResidency.DeviceLocal)); + try + { + buffer.Upload(0, contents); + return buffer; + } + catch + { + buffer.Dispose(); + throw; + } + } + + private static RetainedGpuBufferSlice Slice(IGpuBuffer? buffer) => + new(buffer, 0u, checked((uint)(buffer?.SizeBytes ?? 0L))); + + private void DrawTerrain( + IGpuPassEncoder encoder, + in PreparedGpuUploads uploads, + DirectionalShadowTerrainPreparedDraws draws, + DirectionalShadowTerrainGeometry? geometry, + int cascadeIndex, + IGpuPipeline? pipeline = null) + { + if (draws.Commands.IsEmpty) + return; + DirectionalShadowTerrainGeometry actual = geometry!.Value; + encoder.BindPipeline(pipeline ?? _terrainPipeline); + encoder.BindVertexBuffer(0, actual.VertexBuffer, 0); + encoder.BindIndexBuffer(actual.IndexBuffer, 0, GpuIndexType.UInt32); + GpuPushConstants push = PushForCascade(cascadeIndex, 0); + encoder.SetPushConstants(in push); + encoder.MultiDrawIndexedIndirect( + uploads.TerrainCommands.RequireBuffer(), + uploads.TerrainCommands.OffsetBytes, + checked((uint)draws.Commands.Length), + DrawCommandStride); + } + + private void DrawWorld( + IGpuPassEncoder encoder, + in PreparedGpuUploads uploads, + DirectionalShadowPreparedDraws draws, + DirectionalShadowMeshGeometry? geometry, + int cascadeIndex, + IGpuPipeline? opaquePipeline = null, + IGpuPipeline? cutoutPipeline = null) + { + if (draws.Commands.IsEmpty) + return; + DirectionalShadowMeshGeometry actual = geometry!.Value; + encoder.BindStorageBuffer( + GpuBindingModel.StorageInstances, + uploads.Transforms.Buffer, + uploads.Transforms.BaseOffsetBytes, + uploads.Transforms.BindingSizeBytes); + encoder.BindStorageBuffer( + GpuBindingModel.StorageBatches, + uploads.Batches.RequireBuffer(), + uploads.Batches.OffsetBytes, + uploads.Batches.SizeBytes); + DrawWorldRange( + encoder, + uploads.WorldCommands, + draws.OpaqueRuns, + cascadeIndex, + opaquePipeline ?? _worldOpaquePipeline, + actual); + DrawWorldRange( + encoder, + uploads.WorldCommands, + draws.AlphaCutoutRuns, + cascadeIndex, + cutoutPipeline ?? _worldCutoutPipeline, + actual); + } + + private static void DrawWorldRange( + IGpuPassEncoder encoder, + in RetainedGpuBufferSlice commands, + ReadOnlySpan runs, + int cascadeIndex, + IGpuPipeline pipeline, + in DirectionalShadowMeshGeometry geometry) + { + if (runs.IsEmpty) + return; + encoder.BindPipeline(pipeline); + encoder.BindVertexBuffer(0, geometry.VertexBuffer, 0); + encoder.BindIndexBuffer(geometry.IndexBuffer, 0, GpuIndexType.UInt16); + + for (int runIndex = 0; runIndex < runs.Length; runIndex++) + { + DirectionalShadowPreparedRun run = runs[runIndex]; + ApplyCull(encoder, run.CullMode); + GpuPushConstants push = PushForCascade(cascadeIndex, run.StartCommand); + encoder.SetPushConstants(in push); + encoder.MultiDrawIndexedIndirect( + commands.RequireBuffer(), + commands.OffsetBytes + checked((uint)(run.StartCommand * DrawCommandStride)), + checked((uint)run.CommandCount), + DrawCommandStride); + } + } + + private static GpuPushConstants PushForCascade(int cascadeIndex, int drawIdOffset) + { + GpuPushConstants push = GpuPushConstants.Default; + push.RenderPass = cascadeIndex; + push.DrawIdOffset = drawIdOffset; + return push; + } + + private static void ApplyCull(IGpuPassEncoder encoder, CullMode mode) + { + encoder.SetFrontFace(GpuFrontFace.Clockwise); + encoder.SetCullMode(mode switch + { + CullMode.None => GpuCullMode.None, + CullMode.Clockwise => GpuCullMode.Front, + _ => GpuCullMode.Back, + }); + } + + private static IGpuPipeline CreatePipeline( + IGpuDevice device, + string name, + GpuShaderSet shaders, + GpuVertexLayout layout, + GpuFrontFace frontFace, + uint viewMask = 0) => + device.CreatePipeline(new GpuPipelineDescription + { + Name = name, + Shaders = shaders, + VertexLayout = layout, + Topology = GpuPrimitiveTopology.TriangleList, + Blend = GpuBlendMode.None, + Depth = new GpuDepthState(true, true, GpuCompareOp.Less), + Cull = GpuCullMode.Back, + FrontFace = frontFace, + AlphaToCoverage = false, + ColorWrite = false, + HasColorAttachment = false, + AllowColorFormatVariants = false, + SampleCount = 1, + UsesRenderPackShaderAbi = true, + ViewMask = viewMask, + }); + + private (bool HasMeasurement, double Milliseconds) ResolveGpu(int cascadeCount) + { + if (MultiviewCascadesEnabled) + return _device.Timers.TryResolve(MultiviewTimerName, out double measured) + ? (true, measured) + : (false, 0d); + double total = 0d; + for (int i = 0; i < cascadeCount; i++) + { + if (!_device.Timers.TryResolve(TimerName(i), out double milliseconds)) + return (false, 0d); + total += milliseconds; + } + return (true, total); + } + + private DirectionalSunShadowDiagnostics Disabled( + in DirectionalShadowEnvironmentState environment, + DirectionalSunShadowCpuStageTicks cpuStages = default) => + new( + environment.Reason, + 0f, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0d, + 0d, + false, + _quality.ApproximateDepthMapBytes, + cpuStages, + SourceKind: environment.SourceKind, + SourceObjectIndex: environment.SourceObjectIndex, + SourceGfxObjId: environment.SourceGfxObjId, + SurfaceToLightDirection: environment.SurfaceToLightDirection, + LightElevationSin: environment.LightElevationSin); + + private void EnsureBatchCapacity(int required) + { + if (_batchScratch.Length >= required) + return; + int capacity = _batchScratch.Length == 0 ? 16 : _batchScratch.Length; + while (capacity < required) + capacity = checked(capacity * 2); + Array.Resize(ref _batchScratch, capacity); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _currentFrameBinding = DirectionalShadowFrameBinding.Disabled; + _worldCutoutPipeline.Dispose(); + _worldCutoutMultiviewPipeline?.Dispose(); + _worldOpaqueMultiviewPipeline?.Dispose(); + _terrainMultiviewPipeline?.Dispose(); + _worldOpaquePipeline.Dispose(); + _terrainPipeline.Dispose(); + _terrainCommandBuffer?.Dispose(); + _worldCommandBuffer?.Dispose(); + _worldBatchBuffer?.Dispose(); + _transformBuffers.Dispose(); + _device.ReleaseTextureSlot(_textureSlot); + _sampler.Dispose(); + _target.Dispose(); + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + private readonly record struct DirectionalShadowBatchGpuData( + uint TextureIndex, + uint Reserved, + uint TextureLayer, + uint Flags); + + private readonly record struct RetainedGpuBufferSlice( + IGpuBuffer? Buffer, + uint OffsetBytes, + uint SizeBytes) + { + internal IGpuBuffer RequireBuffer() => Buffer + ?? throw new InvalidOperationException( + "A non-empty directional-shadow draw has no retained GPU buffer."); + } + + private readonly record struct PreparedGpuUploads( + WorldTransformFrameSlice transforms, + RetainedGpuBufferSlice batches, + RetainedGpuBufferSlice worldCommands, + RetainedGpuBufferSlice terrainCommands) + { + internal WorldTransformFrameSlice Transforms { get; } = transforms; + internal RetainedGpuBufferSlice Batches { get; } = batches; + internal RetainedGpuBufferSlice WorldCommands { get; } = worldCommands; + internal RetainedGpuBufferSlice TerrainCommands { get; } = terrainCommands; + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6eba7668..29d16fdd 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -35,6 +35,43 @@ public sealed class GameWindow : System.Diagnostics.Stopwatch.GetTimestamp() / (double)System.Diagnostics.Stopwatch.Frequency; + internal static WindowOptions CreateStartupWindowOptions( + bool exactAutomationFramebuffer, + string persistedResolution, + bool useVSync) + { + WindowOptions defaults = WindowOptions.DefaultVulkan; + Vector2D size = new(1280, 720); + WindowBorder border = defaults.WindowBorder; + if (exactAutomationFramebuffer) + { + if (!SilkRuntimeDisplayWindowTarget.TryParseResolution( + persistedResolution, + out int width, + out int height)) + { + throw new InvalidOperationException( + "Exact automation framebuffer requires a valid persisted resolution."); + } + size = new Vector2D(width, height); + border = WindowBorder.Hidden; + } + + return defaults with + { + Size = size, + Title = "acdream — Vulkan", + VSync = useVSync, + WindowBorder = border, + // A desktop-sized borderless automation window must stay hidden, + // not iconified. Windows throttles/occludes an iconified GLFW + // surface, which prevents the performance gate from collecting a + // complete rolling sample window. Ordinary launches retain the + // Silk default visibility. + IsVisible = !exactAutomationFramebuffer, + }; + } + private readonly AcDream.App.RuntimeOptions _options; // Campaign LA slice LA1: no-op instance when --session-config didn't // configure a statusFile (or the env-var launch path was used at all). @@ -57,6 +94,13 @@ public sealed class GameWindow : // loop!" and would otherwise bury whatever exception actually wounded // the loop). See docs/ISSUES.md #343. private bool _renderLoopArmed; + // Silk may invoke Closing synchronously from IWindow.Close during Update, + // then still invoke Render once before its loop exits. Teardown cannot run + // from that Closing callback: it would dispose the scene while the cached + // render delegate is still eligible to execute. Latch the edge, skip that + // terminal render, and close the ownership graph after Run returns. + private bool _nativeCloseRequested; + private bool _nativeRunReturned; private SilkWindowCallbackBinding? _windowCallbacks; private GameWindowGraphics? _graphics; // Campaign V slice V6h: borrowed, not owned — _graphics owns the context and @@ -414,6 +458,8 @@ public sealed class GameWindow : private readonly AcDream.App.UI.RetailUiRuntimeLease _retailUiLease = new(); private InteractionUiLateBindings? _interactionUiLateBindings; private readonly DeferredRenderFrameDiagnosticsSource _uiFrameDiagnostics = new(); + private readonly AcDream.App.Rendering.Packs.DeferredRenderPackDiagnosticsSource + _renderPackDiagnostics = new(); private readonly AcDream.App.Combat.CombatAttackOperationsSlot _combatAttackOperations = new(); private readonly AcDream.App.Combat.RuntimeCombatTargetOperationsSlot @@ -449,6 +495,7 @@ public sealed class GameWindow : private AcDream.App.Rendering.ChargenPreviewController? _summaryPreviewController; // Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad. private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry; + private readonly AcDream.App.Plugins.BufferedRenderPackRegistry? _renderPackRegistry; private AcDream.App.Plugins.GraphicalPluginSession? _pluginSession; // Campaign V slice V11 deleted the ImGui developer-tools frontend along // with the OpenGL backend it required, so no host ever composes a @@ -636,7 +683,8 @@ public sealed class GameWindow : WorldEvents worldEvents, AcDream.App.Plugins.BufferedUiRegistry? uiRegistry, GraphicalHostPlatformServices platformServices, - AcDream.App.Plugins.AppAutomationSurface? automation = null) + AcDream.App.Plugins.AppAutomationSurface? automation = null, + AcDream.App.Plugins.BufferedRenderPackRegistry? renderPackRegistry = null) { _options = options ?? throw new System.ArgumentNullException(nameof(options)); _automation = automation; @@ -724,6 +772,7 @@ public sealed class GameWindow : characterOptionValue: _runtime.CharacterOwner.Options.GetOptionBit); _animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment(); _uiRegistry = uiRegistry; + _renderPackRegistry = renderPackRegistry; _animatedEntities = new LiveEntityAnimationRuntimeView( _liveEntityRuntimeSlot); // #184 Slice 2a: the extracted per-remote DR tick. Its stateful @@ -799,12 +848,10 @@ public sealed class GameWindow : // attribute there — both are attachment properties the RHI device // configures instead. The raw-GL window options this used to fork to // were deleted at Campaign V slice V11. - var options = WindowOptions.DefaultVulkan with - { - Size = new Vector2D(1280, 720), - Title = "acdream — Vulkan", - VSync = startupPacing.UseVSync, - }; + WindowOptions options = CreateStartupWindowOptions( + _options.ExactAutomationFramebuffer, + startup.Display.Resolution, + startupPacing.UseVSync); _startupPacing = startupPacing; _startupQuality = startup.Quality; @@ -833,6 +880,8 @@ public sealed class GameWindow : try { _window.Run(); + _nativeRunReturned = true; + CompleteShutdown(releaseNativeWindow: false); } catch (Exception failure) { @@ -1359,7 +1408,10 @@ public sealed class GameWindow : _localPlayerMode, _chaseCameraInput, _pointerPosition, - _renderDiagnosticLog), + _renderDiagnosticLog, + _options.InitialOrbitDistanceMeters, + _options.InitialOrbitYawDegrees, + _options.InitialOrbitPitchDegrees), this).Compose(platformResult), (platformResult, hostInputCamera) => new ContentEffectsAudioCompositionPhase( @@ -1387,7 +1439,11 @@ public sealed class GameWindow : new SilkRuntimeDisplayWindowTarget(_window!), _displayFramePacing, hostInputCamera.CameraController, - contentEffectsAudio.Audio?.Engine))) + contentEffectsAudio.Audio?.Engine)) + { + RenderPacks = _renderPackRegistry, + GpuDevice = hostInputCamera.GpuDevice, + }) .Compose(platformResult, hostInputCamera, contentEffectsAudio), (platformResult, contentEffectsAudio, settingsDevTools) => { @@ -1461,7 +1517,9 @@ public sealed class GameWindow : Console.WriteLine, hostInputCamera.GpuDevice, hostInputCamera.GpuFrameLifetime, - () => WorldTime.CurrentCalendar), + () => WorldTime.CurrentCalendar, + settingsDevTools.RenderPacks, + _renderPackDiagnostics.CaptureDiagnostics), _retailUiLease, this).Compose( platformResult, @@ -1519,7 +1577,8 @@ public sealed class GameWindow : DevFrameDiagnostics: null, _uiFrameDiagnostics, Console.WriteLine, - compositionToast), + compositionToast, + _renderPackDiagnostics), this).Compose( platformResult, hostInputCamera, @@ -1631,7 +1690,8 @@ public sealed class GameWindow : _animatedEntities, _updateFrameClock, _frameGraphs, - Console.WriteLine), + Console.WriteLine, + _renderPackDiagnostics), this).Compose( platformResult, hostInputCamera, @@ -1672,6 +1732,11 @@ public sealed class GameWindow : // #343: see OnUpdate above — armed on entry, cleared on every normal // exit path below, left stuck true if anything here throws. _renderLoopArmed = true; + if (_nativeCloseRequested) + { + _renderLoopArmed = false; + return; + } Vector2D size = _window!.Size; // Campaign V slice V6h: swapchain currency is the one piece of // presentation the RHI contract deliberately leaves to the host (plan @@ -1735,12 +1800,23 @@ public sealed class GameWindow : private void CompleteShutdown(bool releaseNativeWindow) { + // IWindow.Close can raise Closing synchronously from Update and Silk + // can still issue one cached Render callback before Run returns. Keep + // Closing as the one narrow shutdown edge, but do not release frame + // owners until the native loop has actually returned. OnRender sees + // this latch and makes that terminal callback inert. + if (!releaseNativeWindow && !_nativeRunReturned) + { + _nativeCloseRequested = true; + return; + } + if (!_lifetime.HasShutdownRoots) { // Campaign LA slice LA1: capture BEFORE the shutdown roots run — // by the time teardown completes, IsInWorld is always false // regardless of whether a real session was ever connected. - // OnClosing() and Dispose() both funnel through this method; + // post-Run shutdown and Dispose() both funnel through this method; // HasShutdownRoots's own guard means this fires exactly once, // from whichever of the two reaches it first. if (_runtime.Session.IsInWorld) @@ -1754,7 +1830,7 @@ public sealed class GameWindow : if (report.Status == GameWindowLifetimeStatus.Complete) { // "exited" = terminal — only the true Dispose() call (not the - // OnClosing() native-window-close-request pass) represents the + // post-Run native-window-close-request pass) represents the // process actually being done. if (releaseNativeWindow) ReportExited(report); diff --git a/src/AcDream.App/Rendering/Gpu/GpuBindingModel.cs b/src/AcDream.App/Rendering/Gpu/GpuBindingModel.cs index 5cdb3026..51ee71b7 100644 --- a/src/AcDream.App/Rendering/Gpu/GpuBindingModel.cs +++ b/src/AcDream.App/Rendering/Gpu/GpuBindingModel.cs @@ -1,3 +1,5 @@ +using AcDream.Plugin.Abstractions.Rendering; + namespace AcDream.App.Rendering.Gpu; /// @@ -60,16 +62,20 @@ internal static class GpuBindingModel /// Retail SmartBox selection lighting: one vec2 (luminosity, diffuse) per instance. public const uint StorageInstanceSelectionLighting = 8; - // Campaign V slice V11 deleted StorageTextureTable (binding 9): the GL-only - // emulation of the Vulkan texture table via a storage buffer of uvec2 - // bindless handles indexed by GpuTextureSlot.Index. The Vulkan backend - // always bound TextureTableSet instead and never used this binding — every - // Vulkan descriptor set layout declared it anyway (seeded with a dummy - // buffer, like every other unused-by-a-given-shader binding), purely - // because it counted toward StorageBindingCount. + /// + /// #226 per-instance retail detail category. One uint parallel to + /// : 1 = building shell, 0 = every other + /// object. EnvCell detail uses its renderer-wide category and does not + /// inspect this field. + /// + public const uint StorageInstanceDetailCategory = 9; + + // Campaign V slice V11 deleted the old GL-only StorageTextureTable from + // binding 9. #226 deliberately reclaims that vacant number for the detail + // category above; the Vulkan texture table remains set 2. /// One past the highest storage binding — the count the backend must support. - public const uint StorageBindingCount = 9; + public const uint StorageBindingCount = 10; // ---- set 1: uniform buffers ---- @@ -106,9 +112,43 @@ internal static class GpuBindingModel /// public const uint UniformSkyParams = 4; - /// Set index carrying every uniform buffer. + /// + /// Immutable authored-atmosphere inputs for one enhanced world frame in + /// opt-in render-pack descriptor set 3. + /// The std140 ABI is four vec4 values: sunScreen, sunColor, viewport, and + /// weather. See AtmosphericFrameUniforms and atmospheric_common.glsl. + /// + public const uint UniformAtmosphericFrame = 5; + + /// + /// Directional-shadow cascade matrices and sampling parameters. Reserved by + /// the shared pack ABI even when a Tier-1 graph leaves the dummy binding in + /// place, so Tier 2 never changes the common pipeline layout. + /// + public const uint UniformDirectionalShadow = 6; + + /// + /// Per-fullscreen-pass values for enhancement graphs. The v1 std140 ABI is + /// four vec4 values named params0..params3; individual passes assign their + /// meanings without changing the descriptor layout. + /// + public const uint UniformPackPass = 7; + + /// + /// Pack-declared settings in declaration order: sixteen std140 vec4 values + /// (64 scalar slots). Preset overrides are resolved before activation. + /// + public const uint UniformPackSettings = RenderPackShaderAbi.PackSettingsBinding; + + /// Set index carrying retail uniform buffers. public const uint UniformSet = 1; + /// + /// Opt-in render-pack uniform set. It is absent from retail layouts and is + /// created only while a render-pack pipeline is alive. + /// + public const uint RenderPackUniformSet = RenderPackShaderAbi.UniformDescriptorSet; + // ---- set 2: the global texture table ---- /// Set index of the sampled-texture descriptor array. diff --git a/src/AcDream.App/Rendering/Gpu/GpuCapabilityRecord.cs b/src/AcDream.App/Rendering/Gpu/GpuCapabilityRecord.cs index 3a64564e..2eb1c7e2 100644 --- a/src/AcDream.App/Rendering/Gpu/GpuCapabilityRecord.cs +++ b/src/AcDream.App/Rendering/Gpu/GpuCapabilityRecord.cs @@ -34,6 +34,13 @@ internal sealed record GpuCapabilityRecord /// Required alignment for a storage-buffer binding offset. public required uint MinStorageBufferOffsetAlignment { get; init; } + /// + /// Largest byte range one storage-buffer descriptor may expose. Vulkan + /// guarantees at least 128 MiB; optional render packs use the exact probed + /// value to size scene-dependent buffers instead of imposing a host ceiling. + /// + public uint MaxStorageBufferRangeBytes { get; init; } = 128u * 1024u * 1024u; + /// Required alignment for a uniform-buffer binding offset. public required uint MinUniformBufferOffsetAlignment { get; init; } @@ -43,6 +50,20 @@ internal sealed record GpuCapabilityRecord /// Highest supported multisample count for the backbuffer. public required uint MaxSampleCount { get; init; } + /// Largest supported two-dimensional image edge from the selected adapter. + public required uint MaxImageDimension2D { get; init; } + + /// Largest supported image-array layer count from the selected adapter. + public required uint MaxImageArrayLayers { get; init; } + + /// + /// Total bytes in device-local heaps on the selected adapter. Render-pack + /// policy derives a deliberately bounded share from this value before any + /// optional image is allocated; zero means that no optional pack memory may + /// be assumed. + /// + public required ulong DeviceLocalMemoryBytes { get; init; } + /// Multi-draw-indirect. Mandatory — it is the entire draw architecture. public required bool SupportsMultiDrawIndirect { get; init; } @@ -62,6 +83,27 @@ internal sealed record GpuCapabilityRecord /// public required bool SupportsPersistentlyMappedRings { get; init; } + /// + /// Whether RGBA16F images can be colour attachments, sampled, and linearly + /// filtered. Optional: absence disables HDR packs, never the retail client. + /// + public required bool SupportsRgba16FloatRenderTargets { get; init; } + + /// + /// Highest usable sample count for an RGBA16F colour attachment that is + /// also a sampled resolve target. Zero means the format is unavailable. + /// + public required uint MaxRgba16FloatSampleCount { get; init; } + + /// + /// Whether the selected combined depth/stencil format can also expose its + /// depth aspect as a sampled image. Optional: needed by screen-space packs. + /// + public required bool SupportsSampledDepth { get; init; } + + /// Vulkan core multiview; optional and used only by packs that declare it. + public required bool SupportsMultiview { get; init; } + /// /// Every mandatory capability this device fails to provide, phrased as /// operator-facing sentences. Empty means the device can run acdream. diff --git a/src/AcDream.App/Rendering/Gpu/GpuEnums.cs b/src/AcDream.App/Rendering/Gpu/GpuEnums.cs index 607d9a11..d4396d9d 100644 --- a/src/AcDream.App/Rendering/Gpu/GpuEnums.cs +++ b/src/AcDream.App/Rendering/Gpu/GpuEnums.cs @@ -57,9 +57,9 @@ internal enum GpuRingUsage } /// -/// Texture formats acdream actually produces from DAT surfaces. BC1/2/3 are the -/// DXT1/3/5 compressed surfaces uploaded verbatim; RGBA8 covers decoded and -/// composited art; R8 is the stb-baked font atlas. +/// Texture formats acdream uploads or renders. BC1/2/3 are the DXT1/3/5 DAT +/// surfaces uploaded verbatim; RGBA8 covers decoded and composited art; R8 is +/// the stb-baked font atlas; RGBA16F is reserved for opt-in HDR intermediates. /// internal enum GpuTextureFormat { @@ -72,6 +72,12 @@ internal enum GpuTextureFormat /// Colour attachment format for offscreen targets (paperdoll, appraisal). Rgba8UnormRenderTarget, + /// + /// Half-float HDR colour attachment used only by opt-in enhancement graphs. + /// The retail/default graph remains on . + /// + Rgba16FloatRenderTarget, + /// Combined depth+stencil attachment. #117's portal punch needs the stencil aspect. Depth24Stencil8, } @@ -129,6 +135,14 @@ internal enum GpuBlendMode /// `ParticleRenderer` needs it too (slice V4e). /// InverseAlpha, + + /// + /// Retail building/EnvCell detail overlay: + /// DstColor, OneMinusSrcAlpha. This intentionally preserves the + /// retail client's measured brightening; it is not a conventional + /// modulate/roughening blend. + /// + RetailDetail, } internal enum GpuCompareOp diff --git a/src/AcDream.App/Rendering/Gpu/GpuPassDescription.cs b/src/AcDream.App/Rendering/Gpu/GpuPassDescription.cs index 96b2740a..ab66c82f 100644 --- a/src/AcDream.App/Rendering/Gpu/GpuPassDescription.cs +++ b/src/AcDream.App/Rendering/Gpu/GpuPassDescription.cs @@ -21,20 +21,24 @@ internal readonly record struct GpuColorAttachment( Vector4 ClearColor); /// -/// The depth/stencil attachment for a pass. Depth is transient in every acdream -/// pass — nothing reads it after the frame — so is normally -/// , which lets Vulkan skip writing it back to -/// memory entirely. +/// The depth/stencil attachment for a pass. Ordinary world/private-viewport +/// depth is transient, so is normally +/// . Directional shadow layers instead name a +/// and use Store so receivers may sample them. /// /// What happens to existing contents on entry. /// What happens to contents on exit. /// Depth clear value. acdream renders with NDC z in [0,1], so far = 1. /// Stencil clear value; #117's portal punch uses the stencil aspect. +/// Dedicated layered depth target, or null for the pass colour target/backbuffer depth. +/// The cascade layer when is present. internal readonly record struct GpuDepthAttachment( GpuLoadOp Load, GpuStoreOp Store, float ClearDepth, - uint ClearStencil); + uint ClearStencil, + IGpuDirectionalDepthTarget? DirectionalTarget = null, + int Layer = 0); /// /// One rendering pass: a set of attachments, their load/store behaviour, and the @@ -57,15 +61,21 @@ internal sealed record GpuPassDescription /// Stable identifier, surfaced as a debug label in captures. public required string Name { get; init; } - /// The colour attachment. Required — acdream has no colour-less passes. + /// The colour attachment. Ignored when is false. public required GpuColorAttachment Color { get; init; } + /// False only for dedicated depth-only producers such as directional shadow maps. + public bool HasColorAttachment { get; init; } = true; + /// Depth/stencil attachment, or null for 2-D passes that need no depth. public GpuDepthAttachment? Depth { get; init; } /// Samples per pixel. Must equal of every pipeline bound inside. public int SampleCount { get; init; } = 1; + /// Non-zero Vulkan multiview mask. Ordinary passes always leave this zero. + public uint ViewMask { get; init; } + /// Clears colour and depth to the standard frame-start values against the backbuffer. public static GpuPassDescription BackbufferClear(string name, Vector4 clearColor, int sampleCount) => new() { @@ -82,4 +92,43 @@ internal sealed record GpuPassDescription ClearStencil: 0), SampleCount = sampleCount, }; + + /// Clears and stores one cascade layer of a directional-depth array. + public static GpuPassDescription DirectionalDepth( + string name, + IGpuDirectionalDepthTarget target, + int layer) => new() + { + Name = name, + Color = default, + HasColorAttachment = false, + Depth = new GpuDepthAttachment( + Load: GpuLoadOp.Clear, + Store: GpuStoreOp.Store, + ClearDepth: 1f, + ClearStencil: 0, + DirectionalTarget: target, + Layer: layer), + SampleCount = 1, + }; + + /// Clears and stores all contiguous cascade layers in one multiview pass. + public static GpuPassDescription DirectionalDepthMultiview( + string name, + IGpuDirectionalDepthTarget target, + uint viewMask) => new() + { + Name = name, + Color = default, + HasColorAttachment = false, + Depth = new GpuDepthAttachment( + GpuLoadOp.Clear, + GpuStoreOp.Store, + 1f, + 0, + target, + Layer: 0), + SampleCount = 1, + ViewMask = viewMask, + }; } diff --git a/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs b/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs index e04ee578..ef4b9cb8 100644 --- a/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs +++ b/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs @@ -168,13 +168,39 @@ internal sealed record GpuVertexLayout( } /// -/// Names one GLSL shader pair. The backend resolves it: the GL backend loads -/// Rendering/Shaders/{Name}.vert and .frag and compiles at startup; -/// the Vulkan backend loads the committed Rendering/Shaders/spv/{Name}.vert.spv -/// and .frag.spv produced by tools/compile-shaders.ps1. One source -/// of truth (the GLSL), two consumption paths. +/// Names one SPIR-V shader pair. Renderer-owned shaders resolve from the +/// committed shader directory. A selected render pack instead supplies an +/// immutable candidate-owned byte pair, so validation never turns into a +/// second host-path lookup or a private built-in shortcut. /// -internal readonly record struct GpuShaderSet(string Name); +internal readonly record struct GpuShaderSet +{ + internal GpuShaderSet(string name) + : this(name, ReadOnlyMemory.Empty, ReadOnlyMemory.Empty) + { + } + + internal GpuShaderSet( + string name, + ReadOnlyMemory vertexSpirv, + ReadOnlyMemory fragmentSpirv) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + if (vertexSpirv.IsEmpty != fragmentSpirv.IsEmpty) + throw new ArgumentException("Both SPIR-V stages must be supplied together."); + Name = name; + VertexSpirv = vertexSpirv; + FragmentSpirv = fragmentSpirv; + } + + internal string Name { get; } + + internal ReadOnlyMemory VertexSpirv { get; } + + internal ReadOnlyMemory FragmentSpirv { get; } + + internal bool HasEmbeddedSpirv => !VertexSpirv.IsEmpty; +} /// Depth-buffer behaviour baked into a pipeline. /// Whether depth testing is enabled at all. @@ -241,6 +267,8 @@ internal readonly record struct GpuStencilState( /// internal sealed record GpuPipelineDescription { + /// Non-zero only for a pipeline compiled for a matching multiview pass. + public uint ViewMask { get; init; } /// Stable identifier, e.g. "mesh-opaque". Surfaced to RenderDoc and validation layers. public required string Name { get; init; } @@ -275,6 +303,12 @@ internal sealed record GpuPipelineDescription /// Whether the pipeline writes colour at all. False for depth/stencil-only prepasses. public bool ColorWrite { get; init; } = true; + /// + /// Whether the compatible dynamic-rendering pass carries a colour + /// attachment. False creates a true depth-only graphics pipeline. + /// + public bool HasColorAttachment { get; init; } = true; + /// /// Whether this pipeline uses the stencil aspect at all. /// @@ -324,6 +358,21 @@ internal sealed record GpuPipelineDescription /// public GpuTextureFormat ColorFormat { get; init; } = GpuTextureFormat.Rgba8UnormRenderTarget; + /// + /// Whether an opt-in graph may prebuild this pipeline against an additional + /// colour-attachment format. World pipelines leave this enabled; dedicated + /// fullscreen pipelines already name their only format and disable it. + /// + public bool AllowColorFormatVariants { get; init; } = true; + + /// + /// Opts this pipeline into render-pack shader ABI v1. Vulkan then uses the + /// lazy four-set pipeline layout whose set 3 contains bindings 5..8; retail + /// pipelines keep the authoritative three-set layout and create no pack + /// descriptors or layouts. + /// + public bool UsesRenderPackShaderAbi { get; init; } + /// Sample count of the passes this pipeline is used in. Must match the pass. public int SampleCount { get; init; } = 1; } diff --git a/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs b/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs index 5e50d0fe..59f31b7a 100644 --- a/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs +++ b/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs @@ -55,7 +55,9 @@ internal struct GpuPushConstants /// public uint TextureIndexA; - /// GLSL uTextureIndexB. Secondary per-pass slot — currently the terrain alpha-mask array. + /// GLSL uTextureIndexB. Secondary per-pass slot; terrain + /// uses it for the alpha-mask array, while shared-pose world/detail passes + /// carry the absolute transform-prefix instance count. public uint TextureIndexB; /// diff --git a/src/AcDream.App/Rendering/Gpu/GpuResourceDescriptions.cs b/src/AcDream.App/Rendering/Gpu/GpuResourceDescriptions.cs index f16605fb..6118123c 100644 --- a/src/AcDream.App/Rendering/Gpu/GpuResourceDescriptions.cs +++ b/src/AcDream.App/Rendering/Gpu/GpuResourceDescriptions.cs @@ -76,22 +76,55 @@ internal readonly record struct GpuSamplerDescription( GpuAddressMode.ClampToEdge, GpuAddressMode.ClampToEdge, MaxAnisotropy: 1f); + + /// + /// Discrete nearest-clamp depth reads for manual PCF. Mip-nearest is + /// deliberate even though the shadow image has one level: it keeps this + /// pack-owned sampler distinct from the device's long-lived UI sampler. + /// + public static GpuSamplerDescription ShadowNearestClamp { get; } = new( + GpuFilter.Nearest, + GpuFilter.Nearest, + GpuMipFilter.Nearest, + GpuAddressMode.ClampToEdge, + GpuAddressMode.ClampToEdge, + MaxAnisotropy: 1f); } -/// An offscreen colour(+depth) bundle: paperdoll, creature appraisal, portal masking. +/// An offscreen colour(+depth) bundle: paperdoll, creature appraisal, portal masking, or an enhancement intermediate. /// Stable identifier for debug tooling. /// Colour attachment width in pixels. /// Colour attachment height in pixels. /// Colour attachment format. /// Depth/stencil format, or null for a colour-only target. -/// 1 for single-sampled. Offscreen targets stay single-sampled. +/// +/// Attachment sample count. Values above one use transient multisample +/// attachments and resolve into the single-sampled textures exposed by +/// . +/// +/// +/// Whether the depth result must be exposed for later shader sampling. This is +/// opt-in so ordinary private viewports retain transient attachment-only depth. +/// internal readonly record struct GpuRenderTargetDescription( string Name, int Width, int Height, GpuTextureFormat ColorFormat, GpuTextureFormat? DepthFormat, - int SampleCount); + int SampleCount, + bool SampleableDepth = false); + +/// +/// A single-sampled, sampleable depth-array used by directional shadow maps. +/// Each cascade is rendered through its own 2-D layer attachment while the +/// complete array is registered once in the global texture table. +/// +internal readonly record struct GpuDirectionalDepthTargetDescription( + string Name, + int Resolution, + int LayerCount, + GpuTextureFormat DepthFormat = GpuTextureFormat.Depth24Stencil8); /// /// A slot in the device's global texture table — the backend-neutral replacement diff --git a/src/AcDream.App/Rendering/Gpu/GpuResources.cs b/src/AcDream.App/Rendering/Gpu/GpuResources.cs index 17709943..262c2596 100644 --- a/src/AcDream.App/Rendering/Gpu/GpuResources.cs +++ b/src/AcDream.App/Rendering/Gpu/GpuResources.cs @@ -13,6 +13,13 @@ internal interface IGpuBuffer : IDisposable GpuBufferUsage Usage { get; } GpuMemoryResidency Residency { get; } + /// + /// True when CPU writes through a mapped HostWritable allocation are made + /// available without an explicit non-coherent atom flush. Retained mapped + /// resources may require this and fail safe when a device cannot provide it. + /// + bool HostWritesAreCoherent { get; } + /// /// Writes at . On a /// buffer this stages through a @@ -89,8 +96,32 @@ internal interface IGpuRenderTarget : IDisposable { GpuRenderTargetDescription Description { get; } - /// The colour attachment, for registering into the texture table or blitting into UI. + /// + /// The single-sampled colour result, for registering into the texture table + /// or blitting into UI. A multisampled target resolves into this texture; + /// callers never sample its transient multisample attachment directly. + /// IGpuTexture ColorTexture { get; } + + /// + /// The single-sampled depth result when + /// was requested; + /// otherwise null. Combined depth/stencil targets expose the depth aspect + /// only through the sampled view while retaining stencil for rendering. + /// + IGpuTexture? DepthTexture { get; } +} + +/// +/// A sampleable directional-depth array. Layers are attachment-addressable by +/// ; callers sample the full array through +/// after the producing passes end. +/// +internal interface IGpuDirectionalDepthTarget : IDisposable +{ + GpuDirectionalDepthTargetDescription Description { get; } + + IGpuTexture DepthTexture { get; } } /// @@ -106,4 +137,12 @@ internal interface IGpuTimerPool /// Milliseconds measured for in the most recent retired frame. bool TryResolve(string scopeName, out double milliseconds); + + /// + /// Consumes the newest retired measurement for . + /// Distribution builders use this form so a GPU result is sampled exactly + /// once even when the render thread runs several frames before another + /// flight slot retires. + /// + bool TryTakeResolved(string scopeName, out double milliseconds); } diff --git a/src/AcDream.App/Rendering/Gpu/IGpuDevice.cs b/src/AcDream.App/Rendering/Gpu/IGpuDevice.cs index 710accbb..fe9fe2bf 100644 --- a/src/AcDream.App/Rendering/Gpu/IGpuDevice.cs +++ b/src/AcDream.App/Rendering/Gpu/IGpuDevice.cs @@ -4,6 +4,10 @@ namespace AcDream.App.Rendering.Gpu; /// The RHI root: creates every GPU resource, owns the global texture table, and /// drives the frame loop. One instance per graphics context, constructed during /// composition and threaded into renderers in place of the raw GL handle. +/// Resource creation/registration and retirement are safe for one asynchronous +/// off-side render-pack preparation worker while the render thread records the +/// active generation. Frame/pass recording and queued-device-action draining +/// remain render-thread-only. /// /// Campaign V (see docs/plans/2026-07-27-vulkan-campaign.md) implements /// this interface twice: first on OpenGL — behaviour-preserving, so each renderer @@ -49,6 +53,10 @@ internal interface IGpuDevice : IDisposable IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description); + /// Creates the dedicated 2-4 cascade sampleable depth array. + IGpuDirectionalDepthTarget CreateDirectionalDepthTarget( + in GpuDirectionalDepthTargetDescription description); + /// /// Publishes a (texture, sampler) pair into the global table and returns the /// slot shaders index it by. The same texture registered with two samplers diff --git a/src/AcDream.App/Rendering/Gpu/IGpuFrame.cs b/src/AcDream.App/Rendering/Gpu/IGpuFrame.cs index 1aff1d76..7c18ff04 100644 --- a/src/AcDream.App/Rendering/Gpu/IGpuFrame.cs +++ b/src/AcDream.App/Rendering/Gpu/IGpuFrame.cs @@ -65,6 +65,14 @@ internal interface IGpuFrame : IDisposable /// GpuRingAllocation AllocateRing(int byteCount, GpuRingUsage usage); + /// + /// Publishes CPU writes made through a retained host-writable storage + /// buffer before a later pass reads them in a shader. Frame-ring writes use + /// the frame submission's existing visibility contract; this explicit seam + /// exists for pack-owned mapped buffers that persist across submissions. + /// + void PublishHostStorageWrites(IGpuBuffer buffer); + /// /// Opens a rendering pass. The returned encoder must be disposed before the /// next pass begins; nesting is not supported and no acdream pass needs it. diff --git a/src/AcDream.App/Rendering/Gpu/IGpuPipelineFormatVariantHost.cs b/src/AcDream.App/Rendering/Gpu/IGpuPipelineFormatVariantHost.cs new file mode 100644 index 00000000..e039ecbe --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/IGpuPipelineFormatVariantHost.cs @@ -0,0 +1,13 @@ +namespace AcDream.App.Rendering.Gpu; + +/// +/// Device-owned lifetime for attachment-format variants of already-created +/// graphics pipelines. Vulkan dynamic rendering bakes the colour format into a +/// pipeline; an enhancement graph acquires its HDR format before recording any +/// enhanced pass and releases it when the pack retires. The clean retail path +/// never acquires a lease and therefore creates no HDR world variants. +/// +internal interface IGpuPipelineFormatVariantHost +{ + IDisposable AcquirePipelineColorFormat(GpuTextureFormat format); +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanCapabilityRecord.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCapabilityRecord.cs index ad689e33..5e9d5d42 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanCapabilityRecord.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCapabilityRecord.cs @@ -39,6 +39,9 @@ internal sealed record VulkanDeviceFeatureSupport /// gl_DrawID. Resets per indirect dispatch exactly as GL's does. public required bool ShaderDrawParameters { get; init; } + /// Optional Vulkan 1.1 core multiview support for layered shadow cascades. + public required bool Multiview { get; init; } + // ---- 1.2 ---- /// One monotonic serial replaces the GL fence array; the retirement ledger keeps its keys. @@ -93,6 +96,7 @@ internal sealed record VulkanDeviceFeatureSupport TextureCompressionBc = true, SamplerAnisotropy = true, ShaderDrawParameters = true, + Multiview = true, TimelineSemaphore = true, HostQueryReset = true, RuntimeDescriptorArray = true, @@ -123,6 +127,7 @@ internal sealed record VulkanDeviceFeatureSupport var n when Is(n, nameof(TextureCompressionBc)) => this with { TextureCompressionBc = false }, var n when Is(n, nameof(SamplerAnisotropy)) => this with { SamplerAnisotropy = false }, var n when Is(n, nameof(ShaderDrawParameters)) => this with { ShaderDrawParameters = false }, + var n when Is(n, nameof(Multiview)) => this with { Multiview = false }, var n when Is(n, nameof(TimelineSemaphore)) => this with { TimelineSemaphore = false }, var n when Is(n, nameof(HostQueryReset)) => this with { HostQueryReset = false }, var n when Is(n, nameof(RuntimeDescriptorArray)) => this with { RuntimeDescriptorArray = false }, @@ -170,6 +175,15 @@ internal sealed record VulkanDeviceLimitSupport /// public required uint MaxDescriptorSetStorageBuffersDynamic { get; init; } + /// Must reach every storage binding declared by descriptor set 0. + public required uint MaxDescriptorSetStorageBuffers { get; init; } + + /// + /// Must reach every set-0 storage binding because the shared layout exposes + /// all of them to both the vertex and fragment stages. + /// + public required uint MaxPerStageDescriptorStorageBuffers { get; init; } + /// Must reach the number of dynamic uniform bindings set 1 declares. public required uint MaxDescriptorSetUniformBuffersDynamic { get; init; } @@ -185,12 +199,25 @@ internal sealed record VulkanDeviceLimitSupport /// Ring allocations must satisfy this; getting it wrong is a driver error on Vulkan. public required uint MinStorageBufferOffsetAlignment { get; init; } + /// + /// Largest legal range in one storage-buffer descriptor. Vulkan 1.3 + /// guarantees at least 128 MiB; enhanced scene buffers are bounded by the + /// actual adapter value rather than a renderer-authored constant. + /// + public required uint MaxStorageBufferRange { get; init; } + /// As above, for the SceneLighting uniform block. public required uint MinUniformBufferOffsetAlignment { get; init; } /// Largest 2D image edge; the terrain atlas and composite arrays are sized against it. public required uint MaxImageDimension2D { get; init; } + /// Largest image-array layer count reported by the selected physical device. + public required uint MaxImageArrayLayers { get; init; } + + /// Sum of device-local heap bytes reported by the selected physical device. + public required ulong DeviceLocalHeapBytes { get; init; } + /// Highest colour sample count the framebuffer supports, as a plain count (1/2/4/8...). public required uint MaxColorSampleCount { get; init; } @@ -205,16 +232,22 @@ internal sealed record VulkanDeviceLimitSupport MaxPushConstantsSize = GpuBindingModel.MaxPushConstantBytes, MaxClipDistances = GpuBindingModel.ClipPlanesPerSlot, MaxBoundDescriptorSets = 4, - // Vulkan's guaranteed minimums. That the layout fits inside them is the - // point of slice V6g's split — see VulkanPipelineLayouts. + // The dynamic counts use Vulkan's guaranteed minimums. Total/per-stage + // counts use acdream's shared-layout requirement, which the startup + // capability gate verifies on the real device. MaxDescriptorSetStorageBuffersDynamic = 4, + MaxDescriptorSetStorageBuffers = GpuBindingModel.StorageBindingCount, + MaxPerStageDescriptorStorageBuffers = GpuBindingModel.StorageBindingCount, MaxDescriptorSetUniformBuffersDynamic = 8, MaxDescriptorSetUpdateAfterBindSampledImages = GpuBindingModel.TextureTableCapacity, MaxPerStageDescriptorUpdateAfterBindSampledImages = GpuBindingModel.TextureTableCapacity, TimestampComputeAndGraphics = true, MinStorageBufferOffsetAlignment = 256, + MaxStorageBufferRange = 128u * 1024u * 1024u, MinUniformBufferOffsetAlignment = 256, MaxImageDimension2D = 16384, + MaxImageArrayLayers = 2048, + DeviceLocalHeapBytes = 8UL * 1024 * 1024 * 1024, MaxColorSampleCount = 8, }; } @@ -236,6 +269,24 @@ internal sealed record VulkanFormatSupport /// The chosen depth+stencil format, or when none is usable. public required Format DepthStencilFormat { get; init; } + /// Whether the chosen combined depth/stencil format is sampleable through its depth aspect. + public required bool DepthStencilSampled { get; init; } + + /// RGBA16F supports optimal-tiling colour-attachment writes. + public required bool Rgba16FloatColorAttachment { get; init; } + + /// RGBA16F supports optimal-tiling sampled-image reads. + public required bool Rgba16FloatSampled { get; init; } + + /// RGBA16F supports linear filtering, required by scaled bloom/ray passes. + public required bool Rgba16FloatLinearFilter { get; init; } + + /// + /// Highest supported RGBA16F sample count for a colour-attachment image. + /// Zero means the format/usage combination is unavailable. + /// + public required uint MaxRgba16FloatSampleCount { get; init; } + /// BC1 (DXT1) sampled-image support with optimal tiling. public required bool Bc1Sampled { get; init; } @@ -249,6 +300,11 @@ internal sealed record VulkanFormatSupport { SwapchainUnormFormat = true, DepthStencilFormat = Format.D32SfloatS8Uint, + DepthStencilSampled = true, + Rgba16FloatColorAttachment = true, + Rgba16FloatSampled = true, + Rgba16FloatLinearFilter = true, + MaxRgba16FloatSampleCount = 8, Bc1Sampled = true, Bc2Sampled = true, Bc3Sampled = true, @@ -368,23 +424,36 @@ internal sealed record VulkanCapabilityRecord( Math.Min( Limits.MaxDescriptorSetUpdateAfterBindSampledImages, Limits.MaxPerStageDescriptorUpdateAfterBindSampledImages), - // Sets 0..2 give each binding its own namespace, so the storage - // bindings the model declares (nine, since Campaign V slice V11 - // deleted the GL-only StorageTextureTable binding) are always all - // available once the set count requirement passes. There is no - // per-set binding-count limit in Vulkan below - // maxPerStageDescriptorStorageBuffers, which is far higher. - MaxStorageBufferBindings = GpuBindingModel.StorageBindingCount, + MaxStorageBufferBindings = Math.Min( + Limits.MaxDescriptorSetStorageBuffers, + Limits.MaxPerStageDescriptorStorageBuffers), MaxPushConstantBytes = Limits.MaxPushConstantsSize, MinStorageBufferOffsetAlignment = Limits.MinStorageBufferOffsetAlignment, + MaxStorageBufferRangeBytes = Limits.MaxStorageBufferRange, MinUniformBufferOffsetAlignment = Limits.MinUniformBufferOffsetAlignment, MaxClipDistances = Limits.MaxClipDistances, MaxSampleCount = Limits.MaxColorSampleCount, + MaxImageDimension2D = Limits.MaxImageDimension2D, + MaxImageArrayLayers = Limits.MaxImageArrayLayers, + DeviceLocalMemoryBytes = Limits.DeviceLocalHeapBytes, SupportsMultiDrawIndirect = Features.MultiDrawIndirect, SupportsDrawParameters = Features.ShaderDrawParameters, SupportsTextureCompressionBc = Features.TextureCompressionBc, SupportsTimestampQueries = Limits.TimestampComputeAndGraphics, + SupportsMultiview = Features.Multiview, SupportsPersistentlyMappedRings = true, + SupportsRgba16FloatRenderTargets = + Formats.Rgba16FloatColorAttachment + && Formats.Rgba16FloatSampled + && Formats.Rgba16FloatLinearFilter + && Formats.MaxRgba16FloatSampleCount > 0, + MaxRgba16FloatSampleCount = + Formats.Rgba16FloatColorAttachment + && Formats.Rgba16FloatSampled + && Formats.Rgba16FloatLinearFilter + ? Math.Min(Formats.MaxRgba16FloatSampleCount, Limits.MaxColorSampleCount) + : 0u, + SupportsSampledDepth = Formats.DepthStencilSampled, }; } @@ -478,6 +547,18 @@ internal static class VulkanCapabilityRequirements $"set 0 declares {VulkanPipelineLayouts.DynamicStorageBindingCount} dynamic storage bindings " + $"(Vulkan guarantees 4); this device provides {limits.MaxDescriptorSetStorageBuffersDynamic}."); } + if (limits.MaxDescriptorSetStorageBuffers < GpuBindingModel.StorageBindingCount) + { + failures.Add( + $"set 0 declares {GpuBindingModel.StorageBindingCount} total storage bindings; " + + $"this device provides {limits.MaxDescriptorSetStorageBuffers} per set."); + } + if (limits.MaxPerStageDescriptorStorageBuffers < GpuBindingModel.StorageBindingCount) + { + failures.Add( + $"set 0 exposes {GpuBindingModel.StorageBindingCount} storage bindings to each shader stage; " + + $"this device provides {limits.MaxPerStageDescriptorStorageBuffers} per stage."); + } if (limits.MaxDescriptorSetUniformBuffersDynamic < VulkanFrameBindings.DynamicUniformBindingCount) { failures.Add( diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs index 8aad6b1a..55adab4b 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs @@ -1,6 +1,10 @@ +using System.Diagnostics; using System.Numerics; using AcDream.App.Rendering; using AcDream.App.Rendering.Vfx; +using AcDream.App.Rendering.Packs; +using AcDream.App.Rendering.Scene; +using AcDream.App.Rendering.Wb; using AcDream.App.Streaming; using AcDream.App.World; using AcDream.Core.World; @@ -68,8 +72,13 @@ internal sealed class VulkanRenderFrameClearPhase : IRenderFrameClearPhase Math.Clamp(atmosphere.FogColor.Z, 0f, 1f), 1f); + var foundation = new RenderFrameFoundation( + portalViewportVisible, + sky, + atmosphere); _clear.ClearColor = clear; - return new RenderFrameFoundation(portalViewportVisible, sky, atmosphere); + _clear.Foundation = foundation; + return foundation; } } @@ -108,19 +117,39 @@ internal sealed class VulkanWorldScenePhase : IWorldSceneFramePhase private readonly Func _sampleCount; private readonly VulkanWorldPassScope _scope; private readonly IWorldSceneFramePhase _world; + private readonly RenderPackController? _renderPacks; + private readonly AtmosphericFrameInputState? _atmosphere; + private readonly Func? + _applyRenderPackBoundary; + private readonly RenderSceneShadowRuntime? _renderScene; + private readonly WbDrawDispatcher? _worldMeshes; + private readonly TerrainModernRenderer? _terrain; public VulkanWorldScenePhase( ICurrentGpuFrameSource frames, VulkanBackbufferClearState clear, Func sampleCount, VulkanWorldPassScope scope, - IWorldSceneFramePhase world) + IWorldSceneFramePhase world, + RenderPackController? renderPacks = null, + AtmosphericFrameInputState? atmosphere = null, + Func? + applyRenderPackBoundary = null, + RenderSceneShadowRuntime? renderScene = null, + WbDrawDispatcher? worldMeshes = null, + TerrainModernRenderer? terrain = null) { _frames = frames ?? throw new ArgumentNullException(nameof(frames)); _clear = clear ?? throw new ArgumentNullException(nameof(clear)); _sampleCount = sampleCount ?? throw new ArgumentNullException(nameof(sampleCount)); _scope = scope ?? throw new ArgumentNullException(nameof(scope)); _world = world ?? throw new ArgumentNullException(nameof(world)); + _renderPacks = renderPacks; + _atmosphere = atmosphere; + _applyRenderPackBoundary = applyRenderPackBoundary; + _renderScene = renderScene; + _worldMeshes = worldMeshes; + _terrain = terrain; } public WorldRenderFrameOutcome Render(RenderFrameInput input) @@ -129,6 +158,264 @@ internal sealed class VulkanWorldScenePhase : IWorldSceneFramePhase ?? throw new InvalidOperationException( "The Vulkan world phase requires an open IGpuFrame (see GpuDeviceFrameLifetime)."); + int samples = _sampleCount(); + if (_renderPacks is not null) + { + var extent = new RenderPackActivationExtent( + input.ViewportWidth, + input.ViewportHeight, + samples); + _ = _applyRenderPackBoundary is not null + ? _applyRenderPackBoundary(extent) + : _renderPacks.ApplyAtFrameBoundary(extent); + } + if (_renderPacks?.ActiveRuntime is { } active) + { + if (active is IDefaultWorldPathRenderPackRuntime) + return RenderRetail(frame, input); + if (active is not IAtmosphericWorldGraphRuntime graph + || _atmosphere is null) + { + _renderPacks.OnRuntimeFailure( + "The selected pack has no compatible production world graph."); + return RenderRetail(frame, input); + } + + IAtmosphericCpuStageProfileRuntime? cpuStageProfile = + graph as IAtmosphericCpuStageProfileRuntime; + bool profileCpuStages = cpuStageProfile?.ShouldProfileCpuFrame(frame.Serial) == true; + long packCpuTicks = 0; + long targetPreparationTicks = 0; + IGpuRenderTarget target; + long packStarted = Stopwatch.GetTimestamp(); + try + { + target = graph.PrepareWorldTarget( + input.ViewportWidth, + input.ViewportHeight, + samples); + } + catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error)) + { + _renderPacks.OnRuntimeFailure( + "Atmospheric target creation failed: " + + error.GetBaseException().Message); + return RenderRetail(frame, input); + } + finally + { + long elapsed = Stopwatch.GetTimestamp() - packStarted; + packCpuTicks += elapsed; + if (profileCpuStages) + targetPreparationTicks = elapsed; + } + + _atmosphere.BeginFrame(in input, _clear.Foundation); + PreparedWorldSceneFrame? prepared = null; + if (graph is IDirectionalShadowWorldGraphRuntime directional) + { + if (_world is not IPreparedWorldSceneFramePhase preparedWorld + || _renderScene is null + || _worldMeshes is null + || _terrain is null) + { + _renderPacks.OnRuntimeFailure( + "The selected directional-shadow pack has no compatible world preparation seam."); + return RenderRetail(frame, input); + } + + PreparedWorldSceneFrame value; + try + { + value = preparedWorld.PrepareEnhanced(input); + } + catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error)) + { + _renderPacks.OnRuntimeFailure( + "Atmospheric world preparation failed: " + + error.GetBaseException().Message); + return RenderRetail(frame, input); + } + prepared = value; + if (value.ShouldRender) + { + packStarted = Stopwatch.GetTimestamp(); + try + { + RenderSceneQuery scene = _renderScene.Query; + RenderFrameFoundation preparedFoundation = value.Foundation; + WorldRenderFrame preparedWorldFrame = value.World; + directional.RenderDirectionalShadows( + frame, + in preparedFoundation, + in preparedWorldFrame, + value.ActiveDayGroup, + in scene, + _worldMeshes, + _terrain); + } + catch (Exception error) when (VulkanRenderFailurePolicy.IsFatal(error)) + { + preparedWorld.CancelPreparedEnhanced(in value); + throw; + } + catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error)) + { + preparedWorld.CancelPreparedEnhanced(in value); + // DirectionalShadowRenderer may have completed its depth + // pass and published the pack-owned retained transform + // prefix before a later graph check fails (notably the + // scene-dependent retained-VRAM ceiling). Cancel the + // dispatcher's borrowed same-frame slice before + // OnRuntimeFailure disposes the pack and its buffers; + // RenderRetail below must allocate its ordinary N.5 + // transforms from the frame ring, never append to that + // retired prefix. + _worldMeshes.CancelDirectionalShadowTransformFrame(frame); + _renderPacks.OnRuntimeFailure( + "Directional shadow rendering failed: " + + error.GetBaseException().Message); + return RenderRetail(frame, input); + } + finally + { + packCpuTicks += Stopwatch.GetTimestamp() - packStarted; + } + } + } + WorldRenderFrameOutcome outcome; + long receiverCpuTicks = 0; + try + { + using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription + { + Name = "atmospheric-world-hdr", + Color = new GpuColorAttachment( + target, + GpuLoadOp.Clear, + samples > 1 ? GpuStoreOp.Resolve : GpuStoreOp.Store, + _clear.ClearColor), + Depth = new GpuDepthAttachment( + GpuLoadOp.Clear, + GpuStoreOp.Store, + 1f, + 0), + SampleCount = samples, + }); + using IDisposable publication = prepared is { ShouldRender: true } + ? _scope.PublishPrepared(encoder) + : _scope.Publish(encoder); + if (prepared is { } value) + { + using IDisposable receiverTimer = encoder.BeginTimerScope( + RenderPackPerformanceScopeNames.EnhancedWorldReceiver); + long receiverStarted = Stopwatch.GetTimestamp(); + try + { + outcome = ((IPreparedWorldSceneFramePhase)_world) + .RenderPreparedEnhanced(input, in value); + } + finally + { + receiverCpuTicks += Stopwatch.GetTimestamp() - receiverStarted; + } + } + else + { + outcome = _world.Render(input); + } + } + catch (Exception error) when (VulkanRenderFailurePolicy.IsFatal(error)) + { + if (prepared is { } value + && _world is IPreparedWorldSceneFramePhase preparedWorld) + { + preparedWorld.CancelPreparedEnhanced(in value); + } + _worldMeshes?.CancelDirectionalShadowTransformFrame(frame); + throw; + } + catch (Exception error) + { + if (prepared is { } value + && _world is IPreparedWorldSceneFramePhase preparedWorld) + { + preparedWorld.CancelPreparedEnhanced(in value); + } + _worldMeshes?.CancelDirectionalShadowTransformFrame(frame); + // The HDR pass may already contain receiver commands, so it + // cannot be replayed through retail in this frame. Quarantine + // the pack, return an empty outcome for this one aborted frame, + // and let the next frame use the unchanged default renderer. + _renderPacks?.OnRuntimeFailure( + "Atmospheric world rendering failed: " + + error.GetBaseException().Message); + return default; + } + + try + { + AtmosphericFrameInputs atmospheric = _atmosphere.Snapshot(); + packStarted = Stopwatch.GetTimestamp(); + graph.RenderPostProcess(frame, in atmospheric); + packCpuTicks += Stopwatch.GetTimestamp() - packStarted; + var observation = new RenderPackFramePerformanceObservation( + PackAddedCpuMilliseconds: packCpuTicks * 1000d / Stopwatch.Frequency, + StableFrameBoundary: outcome.NormalWorldDrawn, + input.ViewportWidth, + input.ViewportHeight, + samples, + AbsoluteEnhancedWorldReceiverCpuMilliseconds: + receiverCpuTicks * 1000d / Stopwatch.Frequency); + bool observationSucceeded = false; + long observeStarted = profileCpuStages ? Stopwatch.GetTimestamp() : 0L; + try + { + _renderPacks.ObserveActiveFrame(in observation); + observationSucceeded = true; + } + catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error)) + { + _renderPacks.OnRuntimeFailure( + "Atmospheric performance observation failed: " + + error.GetBaseException().Message); + } + long observeBookkeepingTicks = profileCpuStages + ? Stopwatch.GetTimestamp() - observeStarted + : 0L; + if (observationSucceeded && profileCpuStages) + { + cpuStageProfile!.CompleteCpuProfile( + frame.Serial, + targetPreparationTicks, + packCpuTicks, + observeBookkeepingTicks, + outcome.NormalWorldDrawn); + } + return outcome; + } + catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error)) + { + // The canonical world transaction has already completed and + // cannot legally be replayed. Keep its outcome, quarantine the + // pack, and let the next frame use the unchanged default path. + _renderPacks.OnRuntimeFailure( + "Atmospheric post-processing failed: " + + error.GetBaseException().Message); + return outcome; + } + } + + return RenderRetail(frame, input); + } + + private WorldRenderFrameOutcome RenderRetail( + IGpuFrame frame, + RenderFrameInput input) + { + // This is the exact pre-pack pass/resource/pipeline path. Keep the branch + // whole so Retail selection does not create, touch, or query any pack + // object after ApplyAtFrameBoundary reports no active runtime. int samples = _sampleCount(); using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription { @@ -167,6 +454,8 @@ internal sealed class VulkanWorldScenePhase : IWorldSceneFramePhase internal sealed class VulkanBackbufferClearState { internal System.Numerics.Vector4 ClearColor { get; set; } = new(0f, 0f, 0f, 1f); + + internal RenderFrameFoundation Foundation { get; set; } } /// diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanDeviceMemoryAllocator.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanDeviceMemoryAllocator.cs index 207eb51b..17e084a4 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanDeviceMemoryAllocator.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanDeviceMemoryAllocator.cs @@ -12,6 +12,7 @@ internal readonly unsafe struct VulkanAllocation( ulong offsetBytes, ulong sizeBytes, uint memoryTypeIndex, + MemoryPropertyFlags memoryProperties, VulkanMemoryRange range, void* mapped) { @@ -19,6 +20,7 @@ internal readonly unsafe struct VulkanAllocation( internal ulong OffsetBytes { get; } = offsetBytes; internal ulong SizeBytes { get; } = sizeBytes; internal uint MemoryTypeIndex { get; } = memoryTypeIndex; + internal MemoryPropertyFlags MemoryProperties { get; } = memoryProperties; internal VulkanMemoryRange Range { get; } = range; /// First mapped byte of this allocation, or null on device-local memory. @@ -64,6 +66,7 @@ internal sealed unsafe class VulkanDeviceMemoryAllocator : IDisposable private readonly MemoryPropertyFlags[] _memoryTypeProperties; private readonly ulong _blockSizeBytes; private readonly ulong _dedicatedThresholdBytes; + private readonly object _sync = new(); private readonly Dictionary _pools = []; private readonly Dictionary<(uint TypeIndex, int BlockIndex), BlockMemory> _blockMemory = []; @@ -110,9 +113,11 @@ internal sealed unsafe class VulkanDeviceMemoryAllocator : IDisposable GpuMemoryResidency residency, string ownerName) { - ObjectDisposedException.ThrowIf(_disposed, this); + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); - uint typeIndex = VulkanMemoryTypeSelection.Choose( + uint typeIndex = VulkanMemoryTypeSelection.Choose( _memoryTypeProperties, requirements.MemoryTypeBits, residency) @@ -121,64 +126,69 @@ internal sealed unsafe class VulkanDeviceMemoryAllocator : IDisposable $"Allowed type bits 0x{requirements.MemoryTypeBits:X8}; the device exposes " + $"{_memoryTypeProperties.Length} memory types."); - if (!_pools.TryGetValue(typeIndex, out VulkanMemoryTypePool? pool)) - { - pool = new VulkanMemoryTypePool(typeIndex, _blockSizeBytes, _dedicatedThresholdBytes); - _pools.Add(typeIndex, pool); + if (!_pools.TryGetValue(typeIndex, out VulkanMemoryTypePool? pool)) + { + pool = new VulkanMemoryTypePool(typeIndex, _blockSizeBytes, _dedicatedThresholdBytes); + _pools.Add(typeIndex, pool); + } + + ulong size = requirements.Size; + ulong alignment = Math.Max(requirements.Alignment, 1); + if (!pool.TryAllocate(size, alignment, out VulkanMemoryRange range)) + { + bool dedicated = pool.IsDedicatedSize(size); + ulong capacity = Math.Max(pool.BlockCapacityFor(size), size); + int blockIndex = pool.AddBlock(capacity, dedicated); + CreateBlockMemory(typeIndex, blockIndex, capacity, ownerName); + + range = dedicated + ? pool.AllocateWholeBlock(blockIndex, size) + : pool.TryAllocate(size, alignment, out VulkanMemoryRange placed) + ? placed + : throw new InvalidOperationException( + $"A freshly created {capacity}-byte block could not satisfy a {size}-byte " + + $"allocation at alignment {alignment} for '{ownerName}'."); + } + + BlockMemory block = _blockMemory[(typeIndex, range.BlockIndex)]; + AllocatedBytes += range.SizeBytes; + void* mapped = block.Mapped == 0 + ? null + : (void*)(block.Mapped + (nint)range.OffsetBytes); + return new VulkanAllocation( + block.Memory, + range.OffsetBytes, + range.SizeBytes, + typeIndex, + _memoryTypeProperties[(int)typeIndex], + range, + mapped); } - - ulong size = requirements.Size; - ulong alignment = Math.Max(requirements.Alignment, 1); - if (!pool.TryAllocate(size, alignment, out VulkanMemoryRange range)) - { - bool dedicated = pool.IsDedicatedSize(size); - ulong capacity = Math.Max(pool.BlockCapacityFor(size), size); - int blockIndex = pool.AddBlock(capacity, dedicated); - CreateBlockMemory(typeIndex, blockIndex, capacity, ownerName); - - range = dedicated - ? pool.AllocateWholeBlock(blockIndex, size) - : pool.TryAllocate(size, alignment, out VulkanMemoryRange placed) - ? placed - : throw new InvalidOperationException( - $"A freshly created {capacity}-byte block could not satisfy a {size}-byte " + - $"allocation at alignment {alignment} for '{ownerName}'."); - } - - BlockMemory block = _blockMemory[(typeIndex, range.BlockIndex)]; - AllocatedBytes += range.SizeBytes; - void* mapped = block.Mapped == 0 - ? null - : (void*)(block.Mapped + (nint)range.OffsetBytes); - return new VulkanAllocation( - block.Memory, - range.OffsetBytes, - range.SizeBytes, - typeIndex, - range, - mapped); } /// Returns an allocation's bytes to its pool, freeing the block when a dedicated one empties. internal void Free(in VulkanAllocation allocation) { - if (_disposed || allocation.SizeBytes == 0) - return; - if (!_pools.TryGetValue(allocation.MemoryTypeIndex, out VulkanMemoryTypePool? pool)) - return; + lock (_sync) + { + if (_disposed || allocation.SizeBytes == 0) + return; + if (!_pools.TryGetValue(allocation.MemoryTypeIndex, out VulkanMemoryTypePool? pool)) + return; - AllocatedBytes -= Math.Min(AllocatedBytes, allocation.Range.SizeBytes); - if (!pool.Free(allocation.Range)) - return; + AllocatedBytes -= Math.Min(AllocatedBytes, allocation.Range.SizeBytes); + if (!pool.Free(allocation.Range)) + return; - var key = (allocation.MemoryTypeIndex, allocation.Range.BlockIndex); - if (!_blockMemory.Remove(key, out BlockMemory block)) - return; + var key = (allocation.MemoryTypeIndex, allocation.Range.BlockIndex); + if (!_blockMemory.Remove(key, out BlockMemory block)) + return; - if (block.Mapped != 0) - _vk.UnmapMemory(_device, block.Memory); - _vk.FreeMemory(_device, block.Memory, null); - CommittedBytes -= Math.Min(CommittedBytes, block.CapacityBytes); + if (block.Mapped != 0) + _vk.UnmapMemory(_device, block.Memory); + _vk.FreeMemory(_device, block.Memory, null); + CommittedBytes -= Math.Min(CommittedBytes, block.CapacityBytes); + } } private void CreateBlockMemory(uint typeIndex, int blockIndex, ulong capacityBytes, string ownerName) @@ -216,27 +226,35 @@ internal sealed unsafe class VulkanDeviceMemoryAllocator : IDisposable } /// Human-readable accounting for the diagnostics report and for teardown assertions. - internal string Describe() => - $"{DeviceMemoryObjectCount} device-memory object(s), " + - $"{CommittedBytes / (1024 * 1024)} MiB committed, " + - $"{AllocatedBytes / (1024 * 1024)} MiB allocated"; + internal string Describe() + { + lock (_sync) + { + return $"{DeviceMemoryObjectCount} device-memory object(s), " + + $"{CommittedBytes / (1024 * 1024)} MiB committed, " + + $"{AllocatedBytes / (1024 * 1024)} MiB allocated"; + } + } public void Dispose() { - if (_disposed) - return; - _disposed = true; - - foreach (BlockMemory block in _blockMemory.Values) + lock (_sync) { - if (block.Mapped != 0) - _vk.UnmapMemory(_device, block.Memory); - _vk.FreeMemory(_device, block.Memory, null); - } + if (_disposed) + return; + _disposed = true; - _blockMemory.Clear(); - _pools.Clear(); - AllocatedBytes = 0; - CommittedBytes = 0; + foreach (BlockMemory block in _blockMemory.Values) + { + if (block.Mapped != 0) + _vk.UnmapMemory(_device, block.Memory); + _vk.FreeMemory(_device, block.Memory, null); + } + + _blockMemory.Clear(); + _pools.Clear(); + AllocatedBytes = 0; + CommittedBytes = 0; + } } } diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanDirectionalDepthTarget.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanDirectionalDepthTarget.cs new file mode 100644 index 00000000..b72cfffc --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanDirectionalDepthTarget.cs @@ -0,0 +1,157 @@ +using Silk.NET.Vulkan; + +namespace AcDream.App.Rendering.Gpu.Vk; + +internal readonly record struct VulkanDirectionalMultiviewRange(uint BaseLayer, uint LayerCount); + +internal static class VulkanDirectionalMultiviewContract +{ + internal static VulkanDirectionalMultiviewRange Resolve(uint viewMask, int targetLayerCount) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(targetLayerCount); + if (targetLayerCount > 31) + throw new ArgumentOutOfRangeException(nameof(targetLayerCount)); + uint expected = (1u << targetLayerCount) - 1u; + if (viewMask != expected) + throw new NotSupportedException("Directional multiview must cover every contiguous target layer."); + return new VulkanDirectionalMultiviewRange(0u, (uint)targetLayerCount); + } +} + +/// +/// One sampleable depth array plus a 2-D attachment view for every cascade. +/// Layout is tracked per layer because cascades are produced in distinct +/// dynamic-rendering passes and become shader-readable independently. +/// +internal sealed unsafe class VulkanDirectionalDepthTarget : IGpuDirectionalDepthTarget +{ + private readonly Silk.NET.Vulkan.Vk _vk; + private readonly Device _device; + private readonly IGpuResourceRetirementQueue _retirement; + private readonly ImageView[] _layerViews; + private readonly ImageLayout[] _layerLayouts; + private bool _disposed; + + internal VulkanDirectionalDepthTarget( + Silk.NET.Vulkan.Vk vk, + Device device, + VulkanDeviceMemoryAllocator allocator, + VulkanUploadQueue uploads, + IGpuResourceRetirementQueue retirement, + VulkanDebugNames debugNames, + in GpuDirectionalDepthTargetDescription description, + Format depthStencilFormat) + { + _vk = vk ?? throw new ArgumentNullException(nameof(vk)); + _device = device; + _retirement = retirement ?? throw new ArgumentNullException(nameof(retirement)); + Description = description; + + var textureDescription = new GpuTextureDescription( + description.Name, + GpuTextureKind.Texture2DArray, + description.DepthFormat, + description.Resolution, + description.Resolution, + description.LayerCount, + MipLevelCount: 1); + Texture = new VulkanGpuTexture( + vk, + device, + allocator, + uploads, + retirement, + debugNames, + textureDescription, + sampleCount: 1, + renderTarget: true, + sampleable: true, + formatOverride: depthStencilFormat); + + _layerViews = new ImageView[description.LayerCount]; + _layerLayouts = new ImageLayout[description.LayerCount]; + try + { + for (int layer = 0; layer < _layerViews.Length; layer++) + { + var create = new ImageViewCreateInfo + { + SType = StructureType.ImageViewCreateInfo, + Image = Texture.Image, + ViewType = ImageViewType.Type2D, + Format = Texture.VkFormat, + SubresourceRange = new ImageSubresourceRange + { + AspectMask = ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit, + BaseMipLevel = 0, + LevelCount = 1, + BaseArrayLayer = (uint)layer, + LayerCount = 1, + }, + }; + VulkanInterop.Check( + vk.CreateImageView(device, &create, null, out ImageView view), + $"vkCreateImageView ('{description.Name}', layer {layer})"); + _layerViews[layer] = view; + debugNames.NameImageView(view, $"{description.Name}-layer-{layer}"); + } + } + catch + { + foreach (ImageView view in _layerViews) + { + if (view.Handle != 0) + vk.DestroyImageView(device, view, null); + } + Texture.Dispose(); + throw; + } + } + + public GpuDirectionalDepthTargetDescription Description { get; } + + public IGpuTexture DepthTexture => Texture; + + internal VulkanGpuTexture Texture { get; } + + internal ImageView ViewAt(int layer) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentOutOfRangeException.ThrowIfNegative(layer); + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(layer, _layerViews.Length); + return _layerViews[layer]; + } + + internal ImageView MultiviewView(uint viewMask) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _ = VulkanDirectionalMultiviewContract.Resolve(viewMask, Description.LayerCount); + return Texture.View; + } + + internal int LayerCountForViewMask(uint viewMask) + { + return checked((int)VulkanDirectionalMultiviewContract.Resolve( + viewMask, + Description.LayerCount).LayerCount); + } + + internal ImageLayout LayoutAt(int layer) => _layerLayouts[layer]; + + internal void MarkLayout(int layer, ImageLayout layout) => _layerLayouts[layer] = layout; + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + ImageView[] views = [.. _layerViews]; + _retirement.Retire(() => + { + foreach (ImageView view in views) + _vk.DestroyImageView(_device, view, null); + }); + Texture.Dispose(); + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanDrawBindingState.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanDrawBindingState.cs new file mode 100644 index 00000000..7e6c6331 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanDrawBindingState.cs @@ -0,0 +1,34 @@ +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Per-pass descriptor-bind state. Vulkan descriptor bindings survive pipeline +/// changes and remain valid until their layout or dynamic offsets change, so a +/// draw can omit an identical second vkCmdBindDescriptorSets command. +/// A new pass receives a fresh state and therefore always binds before its +/// first draw. +/// +internal struct VulkanDrawBindingState +{ + private ulong _pipelineLayout; + private int _packGeneration; + private bool _hasBinding; + private bool _dirty; + + internal readonly bool RequiresBind( + ulong pipelineLayout, + int packGeneration) => + !_hasBinding + || _dirty + || _pipelineLayout != pipelineLayout + || _packGeneration != packGeneration; + + internal void MarkDirty() => _dirty = true; + + internal void MarkBound(ulong pipelineLayout, int packGeneration) + { + _pipelineLayout = pipelineLayout; + _packGeneration = packGeneration; + _hasBinding = true; + _dirty = false; + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameBindings.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameBindings.cs index f1f6fbea..d60480db 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameBindings.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameBindings.cs @@ -26,10 +26,9 @@ namespace AcDream.App.Rendering.Gpu.Vk; /// /// Every binding is always bound, whether a renderer uses it or /// not. Bindings a shader does not declare still need a live descriptor, so -/// unused ones point at a shared dummy range. That is what lets there be ONE -/// descriptor set layout and one pipeline layout rather than a permutation per -/// renderer — plan §4.4's requirement, and the thing that makes switching -/// pipelines mid-pass free. +/// unused ones point at a shared dummy range. Retail keeps its one common +/// layout; opt-in render packs add exactly one compatible set rather than +/// changing these sets or creating renderer permutations. /// /// Slice V6i: one set pair per renderer scope. There is no longer a /// single (set 0, set 1) pair per flight slot; there is an arena of them, and @@ -45,8 +44,16 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable private readonly Device _device; private readonly VulkanPipelineLayouts.Created _layouts; private readonly VulkanBindingScopeArena _arena; + private readonly uint _maxStorageBufferRangeBytes; private readonly List _pools = []; private readonly List<(DescriptorSet Storage, DescriptorSet Uniform)> _sets = []; + private readonly ulong[] _packBuffers = new ulong[VulkanPipelineLayouts.PackUniformBindingCount]; + private readonly uint[] _packOffsets = new uint[VulkanPipelineLayouts.PackUniformBindingCount]; + private readonly uint[] _packRanges = new uint[VulkanPipelineLayouts.PackUniformBindingCount]; + private readonly Dictionary _packSlotsByState = []; + private readonly List _packSets = []; + private int _packLiveCount; + private int _packGeneration = -1; private bool _disposed; @@ -76,8 +83,7 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable } /// - /// Bindings 0..4 of set 1. Slice V6i-2 raised this from 4 when the layout - /// gained binding 4 (sky params); binding 0 remains unused and is counted + /// Bindings 0..4 of retail set 1. Binding 0 remains unused and is counted /// only so the bookkeeping arrays stay index-aligned with the binding number. /// Which of them the layout DECLARES is /// . @@ -87,45 +93,46 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable /// /// How many of set 1's bindings the layout actually declares, all dynamic. /// Asserted against maxDescriptorSetUniformBuffersDynamic by the - /// capability gate; Vulkan guarantees 8, so this is comfortable. + /// capability gate; Vulkan guarantees exactly the four bindings declared. /// internal static uint DynamicUniformBindingCount { get; } = (uint)VulkanPipelineLayouts.DeclaredUniformBindings.Length; - /// - /// Widest range any single binding may address. Dynamic descriptors take a - /// static range at write time and slide it with an offset, so this bounds - /// how much of the ring one binding can see at once. - /// - internal const uint MaxBindingRangeBytes = 4 * 1024 * 1024; - internal VulkanFrameBindings( Silk.NET.Vulkan.Vk vk, Device device, VulkanPipelineLayouts.Created layouts, VulkanGpuBuffer ring, - VulkanGpuBuffer dummy) + VulkanGpuBuffer dummy, + uint maxStorageBufferRangeBytes) { _vk = vk ?? throw new ArgumentNullException(nameof(vk)); _device = device; _layouts = layouts ?? throw new ArgumentNullException(nameof(layouts)); ArgumentNullException.ThrowIfNull(ring); ArgumentNullException.ThrowIfNull(dummy); + ArgumentOutOfRangeException.ThrowIfLessThan(maxStorageBufferRangeBytes, 16u); Ring = ring; Dummy = dummy; + _maxStorageBufferRangeBytes = maxStorageBufferRangeBytes; _arena = new VulkanBindingScopeArena( (int)GpuBindingModel.StorageBindingCount, UniformBindingCount, VulkanPipelineLayouts.IsDynamicStorageBinding); - uint dummyStorageRange = (uint)Math.Min(dummy.SizeBytes, MaxBindingRangeBytes); + uint dummyStorageRange = (uint)Math.Min(dummy.SizeBytes, _maxStorageBufferRangeBytes); for (uint binding = 0; binding < GpuBindingModel.StorageBindingCount; binding++) _arena.SeedStorage(binding, dummy.Handle.Handle, offsetBytes: 0, dummyStorageRange); uint dummyUniformRange = (uint)Math.Min(dummy.SizeBytes, 65536); for (uint binding = 0; binding < UniformBindingCount; binding++) _arena.SeedUniform(binding, dummy.Handle.Handle, dummyUniformRange); + for (int binding = 0; binding < _packBuffers.Length; binding++) + { + _packBuffers[binding] = dummy.Handle.Handle; + _packRanges[binding] = dummyUniformRange; + } } internal VulkanGpuBuffer Ring { get; } @@ -144,7 +151,12 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable /// previous submission has retired before BeginFrame returns, which is /// the same guarantee that lets the ring rewind. /// - internal void BeginFrame() => _arena.BeginFrame(); + internal void BeginFrame() + { + _arena.BeginFrame(); + _packSlotsByState.Clear(); + _packLiveCount = 0; + } internal void SetStorage(uint binding, VulkanGpuBuffer buffer, uint offsetBytes, uint sizeBytes) { @@ -159,16 +171,33 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable internal void SetUniform(uint binding, VulkanGpuBuffer buffer, uint offsetBytes, uint sizeBytes) { - ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(binding, (uint)UniformBindingCount); - _arena.SetUniform( - binding, - buffer.Handle.Handle, - offsetBytes, - Math.Min(ClampRange(buffer, sizeBytes, offsetBytes: 0), 65536)); + if (binding < UniformBindingCount) + { + _arena.SetUniform( + binding, + buffer.Handle.Handle, + offsetBytes, + Math.Min(ClampRange(buffer, sizeBytes, offsetBytes: 0), 65536)); + return; + } + + ArgumentOutOfRangeException.ThrowIfLessThan(binding, GpuBindingModel.UniformAtmosphericFrame); + ArgumentOutOfRangeException.ThrowIfGreaterThan(binding, GpuBindingModel.UniformPackSettings); + int packBinding = (int)(binding - GpuBindingModel.UniformAtmosphericFrame); + _packBuffers[packBinding] = buffer.Handle.Handle; + _packOffsets[packBinding] = offsetBytes; + _packRanges[packBinding] = Math.Min(ClampRange(buffer, sizeBytes, offsetBytes: 0), 65536); } - /// Binds all three sets with the current dynamic offsets. - internal void Bind(CommandBuffer commands, VulkanGpuDevice device) + /// + /// Binds retail sets 0..2. A flagged pipeline additionally supplies its + /// live pack state, which lazily materialises and binds set 3. + /// + internal void Bind( + CommandBuffer commands, + VulkanGpuDevice device, + PipelineLayout pipelineLayout, + VulkanPipelineLayouts.Created.PackState? packState = null) { (int index, int slot, bool needsWrite) = _arena.Resolve(); if (slot < 0) @@ -199,12 +228,71 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable _vk.CmdBindDescriptorSets( commands, PipelineBindPoint.Graphics, - device.Layouts.PipelineLayout, + pipelineLayout, 0, 3, sets, (uint)dynamicCount, offsets); + + if (packState is not null) + BindPackSet(commands, pipelineLayout, packState); + } + + private void BindPackSet( + CommandBuffer commands, + PipelineLayout pipelineLayout, + VulkanPipelineLayouts.Created.PackState state) + { + if (_packGeneration != state.Generation) + { + _packGeneration = state.Generation; + _packSets.Clear(); + _packSlotsByState.Clear(); + _packLiveCount = 0; + } + + PackBindingKey key = CurrentPackKey(); + if (!_packSlotsByState.TryGetValue(key, out int slot)) + { + slot = _packLiveCount++; + _packSlotsByState.Add(key, slot); + if (slot == _packSets.Count) + _packSets.Add(state.AllocateDescriptorSet()); + WritePackSet(_packSets[slot]); + } + + DescriptorSet set = _packSets[slot]; + uint* offsets = stackalloc uint[(int)VulkanPipelineLayouts.PackUniformBindingCount]; + for (int i = 0; i < _packOffsets.Length; i++) + offsets[i] = _packOffsets[i]; + _vk.CmdBindDescriptorSets( + commands, + PipelineBindPoint.Graphics, + pipelineLayout, + GpuBindingModel.RenderPackUniformSet, + 1, + &set, + VulkanPipelineLayouts.PackUniformBindingCount, + offsets); + } + + private PackBindingKey CurrentPackKey() => new( + _packBuffers[0], _packRanges[0], + _packBuffers[1], _packRanges[1], + _packBuffers[2], _packRanges[2], + _packBuffers[3], _packRanges[3]); + + private void WritePackSet(DescriptorSet set) + { + for (int i = 0; i < _packBuffers.Length; i++) + { + WriteUniform( + set, + GpuBindingModel.UniformAtmosphericFrame + (uint)i, + new Silk.NET.Vulkan.Buffer(_packBuffers[i]), + _packRanges[i]); + } } private void WritePair((DescriptorSet Storage, DescriptorSet Uniform) pair) @@ -273,7 +361,7 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable return pool; } - private static uint ClampRange(VulkanGpuBuffer buffer, uint requested, uint offsetBytes) + private uint ClampRange(VulkanGpuBuffer buffer, uint requested, uint offsetBytes) { long remaining = buffer.SizeBytes - offsetBytes; if (remaining <= 0) @@ -285,7 +373,7 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable "A descriptor range of zero is not representable in Vulkan."); } - uint available = (uint)Math.Min(remaining, MaxBindingRangeBytes); + uint available = (uint)Math.Min(remaining, _maxStorageBufferRangeBytes); return requested == 0 ? available : Math.Min(Math.Max(requested, 16), available); } @@ -370,5 +458,17 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable _pools.Clear(); _sets.Clear(); + _packSlotsByState.Clear(); + _packSets.Clear(); } + + private readonly record struct PackBindingKey( + ulong Buffer0, + uint Range0, + ulong Buffer1, + uint Range1, + ulong Buffer2, + uint Range2, + ulong Buffer3, + uint Range3); } diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameFlightController.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameFlightController.cs index 5ef2a7b6..cd6c234a 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameFlightController.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameFlightController.cs @@ -81,6 +81,7 @@ internal sealed class VulkanFrameFlightController : IGpuResourceRetirementQueue, private readonly IVulkanTimelineApi _timeline; private readonly SortedDictionary> _retirements = []; + private readonly object _sync = new(); private long _openSerial; private long _submittedSerial; @@ -99,15 +100,43 @@ internal sealed class VulkanFrameFlightController : IGpuResourceRetirementQueue, internal int SlotCount { get; } /// Serial of the frame currently being recorded, or 0 when none is open. - internal long OpenSerial => _openSerial; + internal long OpenSerial + { + get + { + lock (_sync) + return _openSerial; + } + } /// Highest serial handed to . - internal long SubmittedSerial => _submittedSerial; + internal long SubmittedSerial + { + get + { + lock (_sync) + return _submittedSerial; + } + } /// Flight slot index of the currently open frame. - internal int CurrentSlot => SlotIndexOf(_openSerial); + internal int CurrentSlot + { + get + { + lock (_sync) + return SlotIndexOf(_openSerial); + } + } - internal int PendingRetirementCount => _retirements.Sum(entry => entry.Value.Count); + internal int PendingRetirementCount + { + get + { + lock (_sync) + return _retirements.Sum(entry => entry.Value.Count); + } + } /// Maps a frame serial onto its flight slot. Serials are 1-based. internal int SlotIndexOf(long serial) => @@ -120,30 +149,36 @@ internal sealed class VulkanFrameFlightController : IGpuResourceRetirementQueue, /// internal long BeginFrame() { - ObjectDisposedException.ThrowIf(_disposed, this); - if (_openSerial != 0) + lock (_sync) { - throw new InvalidOperationException( - $"Frame {_openSerial} is still open; call EndFrame before beginning another."); + ObjectDisposedException.ThrowIf(_disposed, this); + if (_openSerial != 0) + { + throw new InvalidOperationException( + $"Frame {_openSerial} is still open; call EndFrame before beginning another."); + } + + long serial = _submittedSerial + 1; + long mustComplete = serial - SlotCount; + if (mustComplete > 0) + _timeline.Wait((ulong)mustComplete); + + _openSerial = serial; + RunRetirements(); + return serial; } - - long serial = _submittedSerial + 1; - long mustComplete = serial - SlotCount; - if (mustComplete > 0) - _timeline.Wait((ulong)mustComplete); - - _openSerial = serial; - RunRetirements(); - return serial; } /// Records that the open frame has been submitted with its serial as the timeline signal value. internal void EndFrame() { - if (_openSerial == 0) - return; - _submittedSerial = _openSerial; - _openSerial = 0; + lock (_sync) + { + if (_openSerial == 0) + return; + _submittedSerial = _openSerial; + _openSerial = 0; + } } /// @@ -154,68 +189,83 @@ internal sealed class VulkanFrameFlightController : IGpuResourceRetirementQueue, public void Retire(Action release) { ArgumentNullException.ThrowIfNull(release); - if (_disposed) + lock (_sync) { - // Teardown already drained the ledger; running immediately is the - // only way this release ever happens, and by then the device is idle. - release(); - return; - } + if (_disposed) + { + // Teardown already drained the ledger; running immediately is the + // only way this release ever happens, and by then the device is idle. + release(); + return; + } - long key = _openSerial != 0 ? _openSerial : _submittedSerial + 1; - if (!_retirements.TryGetValue(key, out List? actions)) - { - actions = []; - _retirements.Add(key, actions); - } + long key = _openSerial != 0 ? _openSerial : _submittedSerial + 1; + if (!_retirements.TryGetValue(key, out List? actions)) + { + actions = []; + _retirements.Add(key, actions); + } - actions.Add(release); + actions.Add(release); + } } /// Runs every retirement whose frame the GPU has completed. internal void RunRetirements() { - if (_retirements.Count == 0) - return; - - var completed = (long)_timeline.CurrentValue; - while (_retirements.Count > 0) + lock (_sync) { - KeyValuePair> first = _retirements.First(); - if (first.Key > completed) - break; + if (_retirements.Count == 0) + return; - _retirements.Remove(first.Key); - foreach (Action release in first.Value) - release(); + var completed = (long)_timeline.CurrentValue; + while (_retirements.Count > 0) + { + KeyValuePair> first = _retirements.First(); + if (first.Key > completed) + break; + + _retirements.Remove(first.Key); + foreach (Action release in first.Value) + release(); + } } } /// Blocks until every submitted frame has completed, then drains the whole ledger. internal void WaitForSubmittedWork() { - if (_submittedSerial > 0) - _timeline.Wait((ulong)_submittedSerial); - DrainAll(); + lock (_sync) + { + if (_submittedSerial > 0) + _timeline.Wait((ulong)_submittedSerial); + DrainAll(); + } } /// Runs every pending retirement regardless of serial. Only legal when the device is idle. internal void DrainAll() { - while (_retirements.Count > 0) + lock (_sync) { - KeyValuePair> first = _retirements.First(); - _retirements.Remove(first.Key); - foreach (Action release in first.Value) - release(); + while (_retirements.Count > 0) + { + KeyValuePair> first = _retirements.First(); + _retirements.Remove(first.Key); + foreach (Action release in first.Value) + release(); + } } } public void Dispose() { - if (_disposed) - return; - DrainAll(); - _disposed = true; + lock (_sync) + { + if (_disposed) + return; + DrainAll(); + _disposed = true; + } } } diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuBuffer.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuBuffer.cs index c2e9cad2..9bc334db 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuBuffer.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuBuffer.cs @@ -91,6 +91,8 @@ internal sealed unsafe class VulkanGpuBuffer : IGpuBuffer public long SizeBytes { get; } public GpuBufferUsage Usage { get; } public GpuMemoryResidency Residency { get; } + public bool HostWritesAreCoherent => + _allocation.MemoryProperties.HasFlag(MemoryPropertyFlags.HostCoherentBit); internal Buffer Handle { get; } diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs index 71af91f4..3804644f 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs @@ -26,6 +26,9 @@ internal sealed unsafe partial class VulkanGpuDevice private readonly Dictionary _samplers = []; private readonly Dictionary _shaderModules = []; + private readonly HashSet _pipelines = []; + private readonly Dictionary _pipelineFormatLeaseCounts = []; + private readonly object _resourceCreationSync = new(); private string _shaderSpirvDirectory = string.Empty; private float _maxSamplerAnisotropy = 1f; @@ -127,7 +130,8 @@ internal sealed unsafe partial class VulkanGpuDevice _device, _layouts, _ringBuffers[slot], - _bindingDummy); + _bindingDummy, + Capabilities.MaxStorageBufferRangeBytes); } } @@ -228,6 +232,8 @@ internal sealed unsafe partial class VulkanGpuDevice } _shaderModules.Clear(); + _pipelines.Clear(); + _pipelineFormatLeaseCounts.Clear(); foreach (VulkanGpuSampler sampler in _samplers.Values) sampler.Dispose(); @@ -290,6 +296,18 @@ internal sealed unsafe partial class VulkanGpuDevice public IGpuTexture CreateTexture(in GpuTextureDescription description) { ThrowIfDisposed(); + if (description.Format == GpuTextureFormat.Rgba16FloatRenderTarget + && !Capabilities.SupportsRgba16FloatRenderTargets) + { + throw new NotSupportedException( + "RGBA16F colour-attachment, sampling, and linear filtering are unavailable."); + } + if (description.Format == GpuTextureFormat.Depth24Stencil8 + && !Capabilities.SupportsSampledDepth) + { + throw new NotSupportedException( + "The selected combined depth/stencil format cannot expose a sampled depth aspect."); + } return new VulkanGpuTexture( _vk, _device, @@ -303,9 +321,16 @@ internal sealed unsafe partial class VulkanGpuDevice } public IGpuSampler CreateSampler(in GpuSamplerDescription description) + { + lock (_resourceCreationSync) + return CreateSamplerLocked(in description); + } + + private IGpuSampler CreateSamplerLocked(in GpuSamplerDescription description) { ThrowIfDisposed(); - if (_samplers.TryGetValue(description, out VulkanGpuSampler? existing)) + if (_samplers.TryGetValue(description, out VulkanGpuSampler? existing) + && !existing.IsDisposed) return existing; var created = new VulkanGpuSampler( @@ -315,13 +340,39 @@ internal sealed unsafe partial class VulkanGpuDevice _debugNames, description, _maxSamplerAnisotropy); - _samplers.Add(description, created); + _samplers[description] = created; return created; } public IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description) { ThrowIfDisposed(); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.SampleCount); + if ((uint)description.SampleCount > Capabilities.MaxSampleCount) + { + throw new NotSupportedException( + $"The device supports at most {Capabilities.MaxSampleCount} colour/depth samples; " + + $"'{description.Name}' requested {description.SampleCount}."); + } + if (description.ColorFormat == GpuTextureFormat.Rgba16FloatRenderTarget) + { + if (!Capabilities.SupportsRgba16FloatRenderTargets) + { + throw new NotSupportedException( + "RGBA16F colour-attachment, sampling, and linear filtering are required by this render target."); + } + if ((uint)description.SampleCount > Capabilities.MaxRgba16FloatSampleCount) + { + throw new NotSupportedException( + $"RGBA16F supports at most {Capabilities.MaxRgba16FloatSampleCount} samples on this device; " + + $"'{description.Name}' requested {description.SampleCount}."); + } + } + if (description.SampleableDepth && !Capabilities.SupportsSampledDepth) + { + throw new NotSupportedException( + "The selected combined depth/stencil format cannot expose a sampled depth aspect."); + } return new VulkanGpuRenderTarget( _vk, _device, @@ -333,6 +384,39 @@ internal sealed unsafe partial class VulkanGpuDevice DepthStencilFormat); } + public IGpuDirectionalDepthTarget CreateDirectionalDepthTarget( + in GpuDirectionalDepthTargetDescription description) + { + ThrowIfDisposed(); + ArgumentException.ThrowIfNullOrWhiteSpace(description.Name); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Resolution); + if (description.LayerCount is < 2 or > 4) + { + throw new ArgumentOutOfRangeException( + nameof(description), + description.LayerCount, + "Directional depth targets require 2-4 cascade layers."); + } + if (description.DepthFormat != GpuTextureFormat.Depth24Stencil8) + { + throw new ArgumentException( + "Directional depth targets currently require Depth24Stencil8.", + nameof(description)); + } + if (!Capabilities.SupportsSampledDepth) + throw new NotSupportedException("Sampled depth is unavailable on this device."); + + return new VulkanDirectionalDepthTarget( + _vk, + _device, + _allocator, + _uploads, + _flights, + _debugNames, + description, + DepthStencilFormat); + } + public GpuTextureSlot RegisterTexture(IGpuTexture texture, IGpuSampler sampler) { ThrowIfDisposed(); @@ -342,6 +426,12 @@ internal sealed unsafe partial class VulkanGpuDevice throw new ArgumentException("The Vulkan backend can only register a Vulkan texture.", nameof(texture)); if (sampler is not VulkanGpuSampler vulkanSampler) throw new ArgumentException("The Vulkan backend can only register a Vulkan sampler.", nameof(sampler)); + if (!vulkanTexture.IsSampleable || vulkanTexture.SampledView.Handle == 0) + { + throw new ArgumentException( + $"Texture '{vulkanTexture.Name}' is an attachment-only image and has no sampled view.", + nameof(texture)); + } // Campaign V slice V6k made this a loud refusal, and V6l is the slice // that serves it. A render-target image is viewed as @@ -352,7 +442,10 @@ internal sealed unsafe partial class VulkanGpuDevice // layered view over the same image for exactly this, and every texture // that is not an attachment has always had one; SampledView is that view // in both cases, so the question disappears rather than being answered. - return TextureTable.Register(vulkanTexture.SampledView, vulkanSampler.Handle); + return TextureTable.Register( + vulkanTexture.SampledView, + vulkanSampler.Handle, + vulkanTexture.SampledLayout); } public void ReleaseTextureSlot(GpuTextureSlot slot) @@ -375,11 +468,20 @@ internal sealed unsafe partial class VulkanGpuDevice /// frame ever pays a shader compile or a driver state revalidation. /// public IGpuPipeline CreatePipeline(GpuPipelineDescription description) + { + lock (_resourceCreationSync) + return CreatePipelineLocked(description); + } + + private IGpuPipeline CreatePipelineLocked(GpuPipelineDescription description) { ThrowIfDisposed(); ArgumentNullException.ThrowIfNull(description); + if (description.ViewMask != 0 && !Capabilities.SupportsMultiview) + throw new NotSupportedException("The selected Vulkan device does not support multiview pipelines."); - (ShaderModule vertex, ShaderModule fragment) = LoadShaderModules(description.Shaders.Name); + (ShaderModule vertex, ShaderModule fragment, bool ownsModules) = + LoadShaderModules(description.Shaders); // Slice V6d: the pipeline names the format it renders into, rather than // every pipeline being hard-coded to one. Rgba8UnormRenderTarget — the // default — still maps to the swapchain's format; see @@ -387,29 +489,164 @@ internal sealed unsafe partial class VulkanGpuDevice // offscreen targets adopt the swapchain's format rather than the other // way round. Format colorFormat = VulkanTextureFormatMapping.FormatOf(description.ColorFormat); - return new VulkanGpuPipeline( - _vk, - _device, - _flights, - _debugNames, - Layouts.PipelineLayout, - _pipelineCache?.Handle ?? default, - vertex, - fragment, - description, - colorFormat, - DepthStencilFormat); + VulkanGpuPipeline pipeline; + VulkanPipelineLayouts.Created.PackLayoutLease? packLease = null; + try + { + packLease = description.UsesRenderPackShaderAbi + ? Layouts.AcquirePackLayout() + : null; + pipeline = new VulkanGpuPipeline( + _vk, + _device, + _flights, + _debugNames, + Layouts, + packLease, + packLease?.PipelineLayout ?? Layouts.PipelineLayout, + _pipelineCache?.Handle ?? default, + vertex, + fragment, + ownsModules, + description, + colorFormat, + DepthStencilFormat); + } + catch + { + packLease?.Dispose(); + if (ownsModules) + { + _vk.DestroyShaderModule(_device, fragment, null); + _vk.DestroyShaderModule(_device, vertex, null); + } + throw; + } + try + { + foreach (GpuTextureFormat format in _pipelineFormatLeaseCounts.Keys) + pipeline.AddColorFormatVariant(format); + _pipelines.Add(pipeline); + return pipeline; + } + catch + { + pipeline.Dispose(); + throw; + } } - private (ShaderModule Vertex, ShaderModule Fragment) LoadShaderModules(string name) + public IDisposable AcquirePipelineColorFormat(GpuTextureFormat format) { + lock (_resourceCreationSync) + return AcquirePipelineColorFormatLocked(format); + } + + private IDisposable AcquirePipelineColorFormatLocked(GpuTextureFormat format) + { + ThrowIfDisposed(); + if (!VulkanTextureFormatMapping.IsRenderTarget(format) + || VulkanTextureFormatMapping.IsDepthStencil(format)) + { + throw new ArgumentException( + $"{format} is not a colour render-target format.", + nameof(format)); + } + if (format == GpuTextureFormat.Rgba16FloatRenderTarget + && !Capabilities.SupportsRgba16FloatRenderTargets) + { + throw new NotSupportedException( + "RGBA16F colour-attachment, sampling, and linear filtering are unavailable."); + } + + _pipelines.RemoveWhere(static pipeline => pipeline.IsDisposed); + if (!_pipelineFormatLeaseCounts.TryGetValue(format, out int count)) + { + var added = new List(_pipelines.Count); + try + { + foreach (VulkanGpuPipeline pipeline in _pipelines) + { + if (pipeline.AddColorFormatVariant(format)) + added.Add(pipeline); + } + } + catch + { + foreach (VulkanGpuPipeline pipeline in added) + pipeline.RemoveColorFormatVariant(format); + throw; + } + _pipelineFormatLeaseCounts.Add(format, 1); + } + else + { + _pipelineFormatLeaseCounts[format] = checked(count + 1); + } + + return new PipelineColorFormatLease(this, format); + } + + private void ReleasePipelineColorFormat(GpuTextureFormat format) + { + lock (_resourceCreationSync) + { + if (_disposed || !_pipelineFormatLeaseCounts.TryGetValue(format, out int count)) + return; + if (count > 1) + { + _pipelineFormatLeaseCounts[format] = count - 1; + return; + } + + _pipelineFormatLeaseCounts.Remove(format); + _pipelines.RemoveWhere(static pipeline => pipeline.IsDisposed); + foreach (VulkanGpuPipeline pipeline in _pipelines) + pipeline.RemoveColorFormatVariant(format); + } + } + + private sealed class PipelineColorFormatLease( + VulkanGpuDevice device, + GpuTextureFormat format) : IDisposable + { + private VulkanGpuDevice? _device = device; + + public void Dispose() => + Interlocked.Exchange(ref _device, null)?.ReleasePipelineColorFormat(format); + } + + private (ShaderModule Vertex, ShaderModule Fragment, bool OwnsModules) LoadShaderModules( + in GpuShaderSet shaders) + { + if (shaders.HasEmbeddedSpirv) + { + ShaderModule embeddedVertex = CreateShaderModule( + shaders.Name, + "vert", + shaders.VertexSpirv.Span); + try + { + return ( + embeddedVertex, + CreateShaderModule(shaders.Name, "frag", shaders.FragmentSpirv.Span), + true); + } + catch + { + _vk.DestroyShaderModule(_device, embeddedVertex, null); + throw; + } + } + + string name = shaders.Name; if (_shaderModules.TryGetValue(name, out (ShaderModule Vertex, ShaderModule Fragment) existing)) - return existing; + return (existing.Vertex, existing.Fragment, false); ShaderModule vertex = CreateShaderModule(name, "vert"); ShaderModule fragment = CreateShaderModule(name, "frag"); _shaderModules[name] = (vertex, fragment); - return (vertex, fragment); + return (vertex, fragment, false); } private ShaderModule CreateShaderModule(string name, string stage) @@ -424,9 +661,19 @@ internal sealed unsafe partial class VulkanGpuDevice path); } - byte[] code = File.ReadAllBytes(path); - if (code.Length % 4 != 0) - throw new InvalidDataException($"'{path}' is {code.Length} bytes, which is not a whole number of SPIR-V words."); + return CreateShaderModule(name, stage, File.ReadAllBytes(path)); + } + + private ShaderModule CreateShaderModule( + string name, + string stage, + ReadOnlySpan code) + { + if (code.Length < 4 || code.Length % 4 != 0) + throw new InvalidDataException( + $"'{name}.{stage}' is {code.Length} bytes, which is not valid word-aligned SPIR-V."); + if (System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(code) != 0x07230203u) + throw new InvalidDataException($"'{name}.{stage}' has no SPIR-V header."); fixed (byte* first = code) { @@ -505,12 +752,50 @@ internal sealed unsafe partial class VulkanGpuDevice uint width; uint height; - ImageView colorView; + ImageView colorView = default; ImageView resolveView = default; ImageView depthView = default; - bool backbuffer = description.Color.Target is null; + ImageView depthResolveView = default; + bool hasColorAttachment = description.HasColorAttachment; + bool backbuffer = hasColorAttachment && description.Color.Target is null; + uint viewMask = description.ViewMask; + GpuTextureFormat passColorFormat = GpuTextureFormat.Rgba8UnormRenderTarget; - if (backbuffer) + if (!hasColorAttachment) + { + if (description.SampleCount != 1) + throw new InvalidOperationException("Directional depth passes are single-sampled."); + if (description.Depth is not { DirectionalTarget: VulkanDirectionalDepthTarget target } depth) + { + throw new ArgumentException( + "A colour-less pass requires a Vulkan directional-depth target.", + nameof(description)); + } + if (depth.Store != GpuStoreOp.Store) + throw new InvalidOperationException("Directional depth must be stored for later sampling."); + if (depth.Layer < 0 || depth.Layer >= target.Description.LayerCount) + throw new ArgumentOutOfRangeException(nameof(description), "Directional depth layer is outside the target."); + + width = (uint)target.Description.Resolution; + height = (uint)target.Description.Resolution; + if (viewMask != 0) + { + if (!Capabilities.SupportsMultiview) + throw new NotSupportedException("The selected Vulkan device does not support multiview."); + depthView = target.MultiviewView(viewMask); + TransitionDirectionalDepthForRendering( + commands, + target, + baseLayer: 0, + layerCount: target.LayerCountForViewMask(viewMask)); + } + else + { + depthView = target.ViewAt(depth.Layer); + TransitionDirectionalDepthForRendering(commands, target, depth.Layer, 1); + } + } + else if (backbuffer) { if (_backbuffer is null || _acquiredImageIndex is not { } imageIndex) { @@ -545,52 +830,106 @@ internal sealed unsafe partial class VulkanGpuDevice { if (description.Color.Target is not VulkanGpuRenderTarget target) throw new ArgumentException("The Vulkan backend can only render into a Vulkan render target."); + if (target.Description.SampleCount != description.SampleCount) + { + throw new InvalidOperationException( + $"Pass '{description.Name}' declares {description.SampleCount} samples but target " + + $"'{target.Description.Name}' was created for {target.Description.SampleCount}."); + } width = (uint)target.Description.Width; height = (uint)target.Description.Height; - colorView = target.Color.View; + passColorFormat = target.Description.ColorFormat; + colorView = target.ColorAttachment.View; + if (target.ColorResolve is { } colorResolve) + { + if (description.Color.Load == GpuLoadOp.Load) + { + throw new InvalidOperationException( + $"Multisampled target '{target.Description.Name}' cannot Load a prior resolved image; " + + "its transient multisample attachment has no preserved contents."); + } + if (description.Color.Store != GpuStoreOp.Resolve) + { + throw new InvalidOperationException( + $"Multisampled target '{target.Description.Name}' must use Store=Resolve so its " + + "single-sampled ColorTexture receives this pass."); + } + resolveView = colorResolve.View; + } + else if (description.Color.Store == GpuStoreOp.Resolve) + { + throw new InvalidOperationException( + $"Single-sampled target '{target.Description.Name}' cannot use Store=Resolve."); + } TransitionRenderTargetForRendering(commands, target); - if (description.Depth is not null && target.Depth is { } depth) + if (description.Depth is not null && target.DepthAttachment is { } depth) + { depthView = depth.View; + if (target.Description.SampleCount > 1 + && description.Depth.Value.Load == GpuLoadOp.Load) + { + throw new InvalidOperationException( + $"Multisampled depth target '{target.Description.Name}' cannot Load transient depth."); + } + if (target.Description.SampleableDepth + && description.Depth.Value.Store != GpuStoreOp.Store) + { + throw new InvalidOperationException( + $"Sampleable depth on '{target.Description.Name}' requires Store=Store."); + } + if (target.DepthResolve is { } depthResolve) + { + depthResolveView = depthResolve.View; + } + } } - Vector4 clear = description.Color.ClearColor; - var colorAttachment = new RenderingAttachmentInfo + RenderingAttachmentInfo colorAttachment = default; + if (hasColorAttachment) { - SType = StructureType.RenderingAttachmentInfo, - ImageView = colorView, - ImageLayout = ImageLayout.ColorAttachmentOptimal, - LoadOp = VulkanViewportMapping.ToVulkan(description.Color.Load), - StoreOp = description.Color.Store == GpuStoreOp.Resolve - ? AttachmentStoreOp.DontCare - : VulkanViewportMapping.ToVulkan(description.Color.Store), - ClearValue = new ClearValue + Vector4 clear = description.Color.ClearColor; + colorAttachment = new RenderingAttachmentInfo { - Color = new ClearColorValue + SType = StructureType.RenderingAttachmentInfo, + ImageView = colorView, + ImageLayout = ImageLayout.ColorAttachmentOptimal, + LoadOp = VulkanViewportMapping.ToVulkan(description.Color.Load), + StoreOp = description.Color.Store == GpuStoreOp.Resolve + ? AttachmentStoreOp.DontCare + : VulkanViewportMapping.ToVulkan(description.Color.Store), + ClearValue = new ClearValue { - Float32_0 = clear.X, - Float32_1 = clear.Y, - Float32_2 = clear.Z, - Float32_3 = clear.W, + Color = new ClearColorValue + { + Float32_0 = clear.X, + Float32_1 = clear.Y, + Float32_2 = clear.Z, + Float32_3 = clear.W, + }, }, - }, - }; - if (resolveView.Handle != 0) - { - colorAttachment.ResolveMode = ResolveModeFlags.AverageBit; - colorAttachment.ResolveImageView = resolveView; - colorAttachment.ResolveImageLayout = ImageLayout.ColorAttachmentOptimal; + }; + if (resolveView.Handle != 0) + { + colorAttachment.ResolveMode = ResolveModeFlags.AverageBit; + colorAttachment.ResolveImageView = resolveView; + colorAttachment.ResolveImageLayout = ImageLayout.ColorAttachmentOptimal; + } } RenderingAttachmentInfo depthAttachment = default; + RenderingAttachmentInfo stencilAttachment = default; if (description.Depth is { } depthDescription && depthView.Handle != 0) { + bool resolveDepth = depthResolveView.Handle != 0; depthAttachment = new RenderingAttachmentInfo { SType = StructureType.RenderingAttachmentInfo, ImageView = depthView, ImageLayout = ImageLayout.DepthStencilAttachmentOptimal, LoadOp = VulkanViewportMapping.ToVulkan(depthDescription.Load), - StoreOp = VulkanViewportMapping.ToVulkan(depthDescription.Store), + StoreOp = resolveDepth + ? AttachmentStoreOp.DontCare + : VulkanViewportMapping.ToVulkan(depthDescription.Store), ClearValue = new ClearValue { DepthStencil = new ClearDepthStencilValue( @@ -598,6 +937,19 @@ internal sealed unsafe partial class VulkanGpuDevice depthDescription.ClearStencil), }, }; + stencilAttachment = depthAttachment; + if (resolveDepth) + { + // SAMPLE_ZERO is guaranteed for both depth and stencil by the + // Vulkan 1.3 depth/stencil-resolve contract. Resolving both + // aspects avoids depending on independentResolveNone. + depthAttachment.ResolveMode = ResolveModeFlags.SampleZeroBit; + depthAttachment.ResolveImageView = depthResolveView; + depthAttachment.ResolveImageLayout = ImageLayout.DepthStencilAttachmentOptimal; + stencilAttachment.ResolveMode = ResolveModeFlags.SampleZeroBit; + stencilAttachment.ResolveImageView = depthResolveView; + stencilAttachment.ResolveImageLayout = ImageLayout.DepthStencilAttachmentOptimal; + } } var rendering = new RenderingInfo @@ -605,13 +957,14 @@ internal sealed unsafe partial class VulkanGpuDevice SType = StructureType.RenderingInfo, RenderArea = new Rect2D(new Offset2D(0, 0), new Extent2D(width, height)), LayerCount = 1, - ColorAttachmentCount = 1, - PColorAttachments = &colorAttachment, + ViewMask = viewMask, + ColorAttachmentCount = hasColorAttachment ? 1u : 0u, + PColorAttachments = hasColorAttachment ? &colorAttachment : null, PDepthAttachment = depthAttachment.SType == StructureType.RenderingAttachmentInfo ? &depthAttachment : null, - PStencilAttachment = depthAttachment.SType == StructureType.RenderingAttachmentInfo - ? &depthAttachment + PStencilAttachment = stencilAttachment.SType == StructureType.RenderingAttachmentInfo + ? &stencilAttachment : null, }; _vk.CmdBeginRendering(commands, &rendering); @@ -625,7 +978,9 @@ internal sealed unsafe partial class VulkanGpuDevice description, width, height, - hasDepthAttachment: depthView.Handle != 0); + hasDepthAttachment: depthView.Handle != 0, + hasColorAttachment, + colorFormat: passColorFormat); _openPass = encoder; return encoder; } @@ -640,7 +995,29 @@ internal sealed unsafe partial class VulkanGpuDevice _debugNames.EndLabel(commands); if (!_openPassIsBackbuffer && encoder.Pass.Color.Target is VulkanGpuRenderTarget target) - TransitionRenderTargetForSampling(commands, target); + { + TransitionRenderTargetForSampling( + commands, + target, + colorStored: encoder.Pass.Color.Store != GpuStoreOp.DontCare, + depthStored: encoder.Pass.Depth?.Store == GpuStoreOp.Store); + } + else if (encoder.Pass.Depth is + { DirectionalTarget: VulkanDirectionalDepthTarget directionalTarget } depth) + { + if (encoder.Pass.ViewMask != 0) + { + TransitionDirectionalDepthForSampling( + commands, + directionalTarget, + 0, + directionalTarget.LayerCountForViewMask(encoder.Pass.ViewMask)); + } + else + { + TransitionDirectionalDepthForSampling(commands, directionalTarget, depth.Layer, 1); + } + } _openPass = null; } @@ -766,19 +1143,35 @@ internal sealed unsafe partial class VulkanGpuDevice private void TransitionRenderTargetForRendering(CommandBuffer commands, VulkanGpuRenderTarget target) { + VulkanGpuTexture colorAttachment = target.ColorAttachment; TransitionImage( commands, - target.Color.Image, + colorAttachment.Image, ImageAspectFlags.ColorBit, - target.Color.CurrentLayout, + colorAttachment.CurrentLayout, ImageLayout.ColorAttachmentOptimal, PipelineStageFlags2.AllCommandsBit, AccessFlags2.None, PipelineStageFlags2.ColorAttachmentOutputBit, AccessFlags2.ColorAttachmentWriteBit); - target.Color.MarkLayout(ImageLayout.ColorAttachmentOptimal); + colorAttachment.MarkLayout(ImageLayout.ColorAttachmentOptimal); - if (target.Depth is { } depth) + if (target.ColorResolve is { } colorResolve) + { + TransitionImage( + commands, + colorResolve.Image, + ImageAspectFlags.ColorBit, + colorResolve.CurrentLayout, + ImageLayout.ColorAttachmentOptimal, + PipelineStageFlags2.AllCommandsBit, + AccessFlags2.None, + PipelineStageFlags2.ColorAttachmentOutputBit, + AccessFlags2.ColorAttachmentWriteBit); + colorResolve.MarkLayout(ImageLayout.ColorAttachmentOptimal); + } + + if (target.DepthAttachment is { } depth) { TransitionImage( commands, @@ -788,25 +1181,157 @@ internal sealed unsafe partial class VulkanGpuDevice ImageLayout.DepthStencilAttachmentOptimal, PipelineStageFlags2.AllCommandsBit, AccessFlags2.None, - PipelineStageFlags2.EarlyFragmentTestsBit, + PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit, AccessFlags2.DepthStencilAttachmentWriteBit); depth.MarkLayout(ImageLayout.DepthStencilAttachmentOptimal); } + + if (target.DepthResolve is { } depthResolve) + { + TransitionImage( + commands, + depthResolve.Image, + ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit, + depthResolve.CurrentLayout, + ImageLayout.DepthStencilAttachmentOptimal, + PipelineStageFlags2.AllCommandsBit, + AccessFlags2.None, + PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit, + AccessFlags2.DepthStencilAttachmentWriteBit); + depthResolve.MarkLayout(ImageLayout.DepthStencilAttachmentOptimal); + } } - private void TransitionRenderTargetForSampling(CommandBuffer commands, VulkanGpuRenderTarget target) + /// + /// Makes retained mapped-storage writes visible to vertex-shader SSBO + /// reads. The buffer belongs to the current flight slot, whose prior use has + /// retired before the host write; this barrier supplies the in-submission + /// HOST_WRITE to SHADER_READ dependency before the shadow pass consumes it. + /// + internal void PublishHostStorageWrites( + VulkanGpuFrame frame, + IGpuBuffer buffer) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(frame); + ArgumentNullException.ThrowIfNull(buffer); + if (!ReferenceEquals(_openFrame, frame)) + throw new InvalidOperationException("Host writes require the open Vulkan frame."); + if (_openPass is not null) + { + throw new InvalidOperationException( + "Retained host writes must be published before opening a rendering pass."); + } + if (buffer is not VulkanGpuBuffer vkBuffer + || buffer.Residency != GpuMemoryResidency.HostWritable + || !buffer.Usage.HasFlag(GpuBufferUsage.Storage)) + { + throw new ArgumentException( + "Published host writes require a Vulkan host-writable storage buffer.", + nameof(buffer)); + } + + CommandBuffer commands = _commandBuffers[frame.SlotIndex]; + BufferMemoryBarrier2 barrier = VulkanHostStorageVisibility.Create( + vkBuffer.Handle, + checked((ulong)vkBuffer.SizeBytes)); + var dependency = new DependencyInfo + { + SType = StructureType.DependencyInfo, + BufferMemoryBarrierCount = 1, + PBufferMemoryBarriers = &barrier, + }; + _vk.CmdPipelineBarrier2(commands, &dependency); + } + + private void TransitionRenderTargetForSampling( + CommandBuffer commands, + VulkanGpuRenderTarget target, + bool colorStored, + bool depthStored) + { + if (colorStored) + { + VulkanGpuTexture color = target.ColorResult; + TransitionImage( + commands, + color.Image, + ImageAspectFlags.ColorBit, + color.CurrentLayout, + ImageLayout.ShaderReadOnlyOptimal, + PipelineStageFlags2.ColorAttachmentOutputBit, + AccessFlags2.ColorAttachmentWriteBit, + PipelineStageFlags2.FragmentShaderBit, + AccessFlags2.ShaderReadBit); + color.MarkLayout(ImageLayout.ShaderReadOnlyOptimal); + } + + if (depthStored && target.Description.SampleableDepth && target.DepthResult is { } depth) + { + TransitionImage( + commands, + depth.Image, + ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit, + depth.CurrentLayout, + ImageLayout.DepthStencilReadOnlyOptimal, + PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit, + AccessFlags2.DepthStencilAttachmentWriteBit, + PipelineStageFlags2.FragmentShaderBit, + AccessFlags2.ShaderReadBit); + depth.MarkLayout(ImageLayout.DepthStencilReadOnlyOptimal); + } + } + + private void TransitionDirectionalDepthForRendering( + CommandBuffer commands, + VulkanDirectionalDepthTarget target, + int baseLayer, + int layerCount) + { + const PipelineStageFlags2 DepthStages = + PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit; + ImageLayout oldLayout = target.LayoutAt(baseLayer); + for (int i = 1; i < layerCount; i++) + { + if (target.LayoutAt(baseLayer + i) != oldLayout) + throw new InvalidOperationException("Multiview directional layers must share one layout."); + } + TransitionImage( + commands, + target.Texture.Image, + ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit, + oldLayout, + ImageLayout.DepthStencilAttachmentOptimal, + oldLayout == ImageLayout.Undefined ? PipelineStageFlags2.TopOfPipeBit : PipelineStageFlags2.FragmentShaderBit, + oldLayout == ImageLayout.Undefined ? AccessFlags2.None : AccessFlags2.ShaderReadBit, + DepthStages, + AccessFlags2.DepthStencilAttachmentWriteBit, + baseArrayLayer: (uint)baseLayer, + layerCount: (uint)layerCount); + for (int i = 0; i < layerCount; i++) + target.MarkLayout(baseLayer + i, ImageLayout.DepthStencilAttachmentOptimal); + } + + private void TransitionDirectionalDepthForSampling( + CommandBuffer commands, + VulkanDirectionalDepthTarget target, + int baseLayer, + int layerCount) { TransitionImage( commands, - target.Color.Image, - ImageAspectFlags.ColorBit, - ImageLayout.ColorAttachmentOptimal, - ImageLayout.ShaderReadOnlyOptimal, - PipelineStageFlags2.ColorAttachmentOutputBit, - AccessFlags2.ColorAttachmentWriteBit, + target.Texture.Image, + ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit, + target.LayoutAt(baseLayer), + ImageLayout.DepthStencilReadOnlyOptimal, + PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit, + AccessFlags2.DepthStencilAttachmentWriteBit, PipelineStageFlags2.FragmentShaderBit, - AccessFlags2.ShaderReadBit); - target.Color.MarkLayout(ImageLayout.ShaderReadOnlyOptimal); + AccessFlags2.ShaderReadBit, + baseArrayLayer: (uint)baseLayer, + layerCount: (uint)layerCount); + for (int i = 0; i < layerCount; i++) + target.MarkLayout(baseLayer + i, ImageLayout.DepthStencilReadOnlyOptimal); } private void TransitionImage( @@ -818,7 +1343,9 @@ internal sealed unsafe partial class VulkanGpuDevice PipelineStageFlags2 sourceStage, AccessFlags2 sourceAccess, PipelineStageFlags2 destinationStage, - AccessFlags2 destinationAccess) + AccessFlags2 destinationAccess, + uint baseArrayLayer = 0, + uint layerCount = Silk.NET.Vulkan.Vk.RemainingArrayLayers) { var barrier = new ImageMemoryBarrier2 { @@ -837,8 +1364,8 @@ internal sealed unsafe partial class VulkanGpuDevice AspectMask = aspect, BaseMipLevel = 0, LevelCount = Silk.NET.Vulkan.Vk.RemainingMipLevels, - BaseArrayLayer = 0, - LayerCount = Silk.NET.Vulkan.Vk.RemainingArrayLayers, + BaseArrayLayer = baseArrayLayer, + LayerCount = layerCount, }, }; var dependency = new DependencyInfo diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs index 31b4daec..b2556e49 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs @@ -67,7 +67,7 @@ internal interface IVulkanBackbuffer /// signalling both the per-image render-complete semaphore and the timeline at /// this frame's serial, present. /// -internal sealed unsafe partial class VulkanGpuDevice : IGpuDevice +internal sealed unsafe partial class VulkanGpuDevice : IGpuDevice, IGpuPipelineFormatVariantHost { /// Per-flight-slot ring capacity, matching the GL backend's 16 MiB. internal const int DefaultRingCapacityBytesPerSlot = 16 * 1024 * 1024; @@ -150,17 +150,34 @@ internal sealed unsafe partial class VulkanGpuDevice : IGpuDevice MaxStorageBufferBindings = GpuBindingModel.StorageBindingCount, MaxPushConstantBytes = limits.MaxPushConstantsSize, MinStorageBufferOffsetAlignment = Math.Max(limits.MinStorageBufferOffsetAlignment, 1), + MaxStorageBufferRangeBytes = limits.MaxStorageBufferRange, MinUniformBufferOffsetAlignment = Math.Max(limits.MinUniformBufferOffsetAlignment, 1), MaxClipDistances = limits.MaxClipDistances, MaxSampleCount = limits.MaxColorSampleCount, + MaxImageDimension2D = limits.MaxImageDimension2D, + MaxImageArrayLayers = limits.MaxImageArrayLayers, + DeviceLocalMemoryBytes = limits.DeviceLocalHeapBytes, SupportsMultiDrawIndirect = features.MultiDrawIndirect, SupportsDrawParameters = features.ShaderDrawParameters, SupportsTextureCompressionBc = features.TextureCompressionBc && formats.Bc1Sampled && formats.Bc2Sampled && formats.Bc3Sampled, SupportsTimestampQueries = limits.TimestampComputeAndGraphics, + SupportsMultiview = features.Multiview, // The one capability that is true here and false on GL, and the // mechanism behind the campaign's CPU-cost target. SupportsPersistentlyMappedRings = true, + SupportsRgba16FloatRenderTargets = + formats.Rgba16FloatColorAttachment + && formats.Rgba16FloatSampled + && formats.Rgba16FloatLinearFilter + && formats.MaxRgba16FloatSampleCount > 0, + MaxRgba16FloatSampleCount = + formats.Rgba16FloatColorAttachment + && formats.Rgba16FloatSampled + && formats.Rgba16FloatLinearFilter + ? Math.Min(formats.MaxRgba16FloatSampleCount, limits.MaxColorSampleCount) + : 0u, + SupportsSampledDepth = formats.DepthStencilSampled, }; var timelineType = new SemaphoreTypeCreateInfo diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuFrame.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuFrame.cs index 3ec2eec2..82f64378 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuFrame.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuFrame.cs @@ -34,6 +34,9 @@ internal sealed class VulkanGpuFrame : IGpuFrame public GpuRingAllocation AllocateRing(int byteCount, GpuRingUsage usage) => _device.AllocateRing(SlotIndex, byteCount, usage); + public void PublishHostStorageWrites(IGpuBuffer buffer) => + _device.PublishHostStorageWrites(this, buffer); + public IGpuPassEncoder BeginPass(GpuPassDescription description) { ArgumentNullException.ThrowIfNull(description); diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs index 14778fc8..ea3b1a20 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs @@ -17,10 +17,9 @@ namespace AcDream.App.Rendering.Gpu.Vk; /// Storage and uniform bindings go through a dynamic descriptor /// set. The contract lets a renderer bind an arbitrary buffer range per /// draw, and ring allocations mean that range moves every frame. Rather than -/// writing descriptors mid-frame, set 0 and set 1 are allocated per flight slot +/// writing descriptors mid-frame, retail sets 0 and 1 are allocated per flight slot /// with DYNAMIC descriptor types and the per-draw offset is supplied at bind -/// time — which is what keeps the campaign's "zero descriptor writes per frame" -/// property true for buffers as well as for textures. +/// time. Opt-in set 3 uses the same rule from a separately owned lazy pool. /// internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder { @@ -31,6 +30,8 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder private readonly uint _attachmentWidth; private readonly uint _attachmentHeight; private readonly bool _hasDepthAttachment; + private readonly bool _hasColorAttachment; + private readonly GpuTextureFormat _colorFormat; /// /// Extent of the attachments vkCmdBeginRendering was handed. Campaign V @@ -47,6 +48,7 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder internal bool HasDepthAttachment => _hasDepthAttachment; private VulkanGpuPipeline? _pipeline; + private VulkanDrawBindingState _drawBindingState; private bool _closed; internal VulkanGpuPassEncoder( @@ -57,7 +59,9 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder GpuPassDescription pass, uint attachmentWidth, uint attachmentHeight, - bool hasDepthAttachment) + bool hasDepthAttachment, + bool hasColorAttachment, + GpuTextureFormat colorFormat) { _device = device; _frame = frame; @@ -70,6 +74,8 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder // the attachments exist gets none, and the pipeline variant has to agree // with the command buffer rather than with the intent. _hasDepthAttachment = hasDepthAttachment; + _hasColorAttachment = hasColorAttachment; + _colorFormat = colorFormat; Pass = pass; // A pass always starts with the whole attachment drawable. GL's @@ -80,23 +86,15 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder SetViewport(0, 0, (int)attachmentWidth, (int)attachmentHeight); SetScissor(0, 0, (int)attachmentWidth, (int)attachmentHeight); - // Campaign V slice V6h: and for the same reason, the descriptor sets. + // Campaign V slice V6h requires every pass to be self-contained rather + // than inheriting descriptor state from an earlier renderer. The first + // draw now establishes that state through FlushBindings. Deferring it + // until a draw exists avoids recording an unused initial binding and + // lets later draws reuse an identical binding safely. // - // Before this, sets 0/1/2 were bound only as a side effect of - // BindStorageBuffer/BindUniformBuffer, so a pass whose pipeline reads the - // texture table but binds no buffer — every retained-UI and debug-line - // pass, because their per-draw data travels in push constants and a - // vertex buffer — issued vkCmdDraw with set 2 unbound. That is - // VUID-vkCmdDraw-None-08600 and, on the RX 9070 XT, an immediate - // ErrorDeviceLost at submit. - // - // It went unseen through V6c–V6g because the bring-up host always drew - // VulkanRhiScene first: its storage binds left all three sets bound in - // the same command buffer, so the UI pass that followed inherited them. - // The composition host has no 3-D scene, so its UI pass is the first - // thing in the buffer and inherits nothing. Binding here makes a pass - // self-contained rather than dependent on what preceded it in the frame. - _bindings.Bind(_commands, _device); + // FlushBindings is called by every draw verb, including passes such as + // retained UI and debug lines that bind no buffers themselves. Thus set + // 2 is still guaranteed before vkCmdDraw and VUID 08600 stays closed. } public GpuPassDescription Pass { get; } @@ -107,17 +105,27 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder ThrowIfClosed(); if (pipeline is not VulkanGpuPipeline vulkanPipeline) throw new ArgumentException("The Vulkan backend can only bind a Vulkan pipeline.", nameof(pipeline)); + if (vulkanPipeline.Description.HasColorAttachment != _hasColorAttachment) + { + throw new InvalidOperationException( + $"Pipeline '{vulkanPipeline.Description.Name}' colour-attachment intent does not match pass '{Pass.Name}'."); + } + if (vulkanPipeline.Description.ViewMask != Pass.ViewMask) + { + throw new InvalidOperationException( + $"Pipeline '{vulkanPipeline.Description.Name}' view mask does not match pass '{Pass.Name}'."); + } _pipeline = vulkanPipeline; _device.Api.CmdBindPipeline( _commands, PipelineBindPoint.Graphics, - vulkanPipeline.HandleFor(_hasDepthAttachment)); + vulkanPipeline.HandleFor(_hasDepthAttachment, _colorFormat)); - // Every pipeline shares one layout, so the descriptor sets and push - // constants bound earlier in the pass survive this call. That is the - // whole reason for the shared layout, and it is why a bucketed world - // pass can change pipeline per bucket for free. + // Retail and pack pipelines share sets 0..2 and the same 96-byte push + // range, but a pack pipeline has one additional set. Bind against the + // exact layout used to create the active pipeline so set 3 can never + // leak onto the authoritative retail path. _device.CmdBindPipelineDefaults(_commands, vulkanPipeline.Description); } @@ -125,12 +133,14 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder { ThrowIfClosed(); _bindings.SetStorage(binding, RequireBuffer(buffer), offsetBytes, sizeBytes); + _drawBindingState.MarkDirty(); } public void BindUniformBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes) { ThrowIfClosed(); _bindings.SetUniform(binding, RequireBuffer(buffer), offsetBytes, sizeBytes); + _drawBindingState.MarkDirty(); } /// @@ -149,10 +159,23 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder /// what the arena was designed to produce. /// /// Legal because descriptor-set binding is independent of pipeline - /// binding when the layouts are compatible, and acdream has ONE pipeline - /// layout by design (§4.4). + /// binding when the layouts are compatible. Retail and pack layouts share + /// identical sets 0..2; the active pipeline supplies the optional set 3. /// - private void FlushBindings() => _bindings.Bind(_commands, _device); + private void FlushBindings() + { + VulkanGpuPipeline pipeline = RequirePipeline(); + ulong pipelineLayout = pipeline.PipelineLayout.Handle; + int packGeneration = pipeline.PackState?.Generation ?? 0; + if (!_drawBindingState.RequiresBind(pipelineLayout, packGeneration)) + return; + _bindings.Bind( + _commands, + _device, + pipeline.PipelineLayout, + pipeline.PackState); + _drawBindingState.MarkBound(pipelineLayout, packGeneration); + } /// /// A scoped clear inside the live render-pass instance — retail's interior @@ -194,7 +217,7 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder { _device.Api.CmdPushConstants( _commands, - _device.Layouts.PipelineLayout, + _pipeline?.PipelineLayout ?? _device.Layouts.PipelineLayout, ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, 0, (uint)GpuBindingModel.PushConstantBytes, @@ -313,10 +336,11 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder return vulkanBuffer; } - private void RequirePipeline() + private VulkanGpuPipeline RequirePipeline() { if (_pipeline is null) throw new InvalidOperationException("BindPipeline must be called before drawing."); + return _pipeline; } private void ThrowIfClosed() => ObjectDisposedException.ThrowIf(_closed, this); diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPipeline.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPipeline.cs index becc067e..2012ab08 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPipeline.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPipeline.cs @@ -43,8 +43,18 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline private readonly Silk.NET.Vulkan.Vk _vk; private readonly Device _device; private readonly IGpuResourceRetirementQueue _retirement; + private readonly VulkanDebugNames _debugNames; + private readonly VulkanPipelineLayouts.Created _layouts; + private readonly VulkanPipelineLayouts.Created.PackLayoutLease? _packLayoutLease; + private readonly PipelineLayout _layout; + private readonly PipelineCache _cache; + private readonly ShaderModule _vertexModule; + private readonly ShaderModule _fragmentModule; + private readonly bool _ownsShaderModules; + private readonly Format _depthStencilFormat; private readonly Pipeline _withDepthAttachment; private readonly Pipeline _withoutDepthAttachment; + private readonly Dictionary _colorVariants = []; private bool _disposed; internal VulkanGpuPipeline( @@ -52,10 +62,13 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline Device device, IGpuResourceRetirementQueue retirement, VulkanDebugNames debugNames, + VulkanPipelineLayouts.Created layouts, + VulkanPipelineLayouts.Created.PackLayoutLease? packLayoutLease, PipelineLayout layout, PipelineCache cache, ShaderModule vertexModule, ShaderModule fragmentModule, + bool ownsShaderModules, GpuPipelineDescription description, Format colorFormat, Format depthStencilFormat) @@ -63,6 +76,15 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline _vk = vk ?? throw new ArgumentNullException(nameof(vk)); _device = device; _retirement = retirement ?? throw new ArgumentNullException(nameof(retirement)); + _debugNames = debugNames ?? throw new ArgumentNullException(nameof(debugNames)); + _layouts = layouts ?? throw new ArgumentNullException(nameof(layouts)); + _packLayoutLease = packLayoutLease; + _layout = layout; + _cache = cache; + _vertexModule = vertexModule; + _fragmentModule = fragmentModule; + _ownsShaderModules = ownsShaderModules; + _depthStencilFormat = depthStencilFormat; Description = description ?? throw new ArgumentNullException(nameof(description)); nint entryPoint = SilkMarshal.StringToPtr("main"); @@ -211,8 +233,8 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline { SType = StructureType.PipelineColorBlendStateCreateInfo, LogicOpEnable = false, - AttachmentCount = 1, - PAttachments = &attachment, + AttachmentCount = description.HasColorAttachment ? 1u : 0u, + PAttachments = description.HasColorAttachment ? &attachment : null, }; DynamicState* dynamicStates = stackalloc DynamicState[9]; @@ -246,8 +268,9 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline var rendering = new PipelineRenderingCreateInfo { SType = StructureType.PipelineRenderingCreateInfo, - ColorAttachmentCount = 1, - PColorAttachmentFormats = &color, + ViewMask = description.ViewMask, + ColorAttachmentCount = description.HasColorAttachment ? 1u : 0u, + PColorAttachmentFormats = description.HasColorAttachment ? &color : null, DepthAttachmentFormat = depthStencilFormat, StencilAttachmentFormat = depthStencilFormat, }; @@ -312,17 +335,107 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline internal Pipeline HandleFor(bool passHasDepthAttachment) => passHasDepthAttachment ? _withDepthAttachment : _withoutDepthAttachment; + /// + /// Selects the prebuilt attachment-format variant required by the live pass. + /// Missing variants fail before a draw can record undefined Vulkan usage. + /// + internal Pipeline HandleFor( + bool passHasDepthAttachment, + GpuTextureFormat colorFormat) + { + if (colorFormat == Description.ColorFormat) + return HandleFor(passHasDepthAttachment); + if (_colorVariants.TryGetValue(colorFormat, out VulkanGpuPipeline? variant)) + return variant.HandleFor(passHasDepthAttachment); + throw new InvalidOperationException( + $"Pipeline '{Description.Name}' has no prebuilt {colorFormat} attachment variant."); + } + + internal bool IsDisposed => _disposed; + + /// The exact layout this pipeline was created against. + internal PipelineLayout PipelineLayout => _layout; + + /// Non-null only for a pipeline flagged for render-pack ABI v1. + internal VulkanPipelineLayouts.Created.PackState? PackState => _packLayoutLease?.State; + + internal bool AddColorFormatVariant(GpuTextureFormat format) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!Description.HasColorAttachment + || !Description.AllowColorFormatVariants + || format == Description.ColorFormat + || _colorVariants.ContainsKey(format)) + return false; + + var variantDescription = Description with + { + Name = $"{Description.Name}-{format.ToString().ToLowerInvariant()}", + ColorFormat = format, + AllowColorFormatVariants = false, + }; + VulkanPipelineLayouts.Created.PackLayoutLease? packLease = + Description.UsesRenderPackShaderAbi ? _layouts.AcquirePackLayout() : null; + VulkanGpuPipeline variant; + try + { + variant = new VulkanGpuPipeline( + _vk, + _device, + _retirement, + _debugNames, + _layouts, + packLease, + packLease?.PipelineLayout ?? _layouts.PipelineLayout, + _cache, + _vertexModule, + _fragmentModule, + ownsShaderModules: false, + variantDescription, + VulkanTextureFormatMapping.FormatOf(format), + _depthStencilFormat); + } + catch + { + packLease?.Dispose(); + throw; + } + _colorVariants.Add(format, variant); + return true; + } + + internal void RemoveColorFormatVariant(GpuTextureFormat format) + { + if (_colorVariants.Remove(format, out VulkanGpuPipeline? variant)) + variant.Dispose(); + } + public void Dispose() { if (_disposed) return; _disposed = true; + foreach (VulkanGpuPipeline variant in _colorVariants.Values) + variant.Dispose(); + _colorVariants.Clear(); Pipeline withDepth = _withDepthAttachment; Pipeline withoutDepth = _withoutDepthAttachment; + ShaderModule vertex = _vertexModule; + ShaderModule fragment = _fragmentModule; + bool destroyModules = _ownsShaderModules; + VulkanPipelineLayouts.Created.PackLayoutLease? packLease = _packLayoutLease; _retirement.Retire(() => { _vk.DestroyPipeline(_device, withDepth, null); _vk.DestroyPipeline(_device, withoutDepth, null); + if (destroyModules) + { + _vk.DestroyShaderModule(_device, fragment, null); + _vk.DestroyShaderModule(_device, vertex, null); + } + // The optional set-3 and four-set layout cannot be destroyed until + // every pipeline that names them has actually retired. + packLease?.Dispose(); }); } } diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuRenderTarget.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuRenderTarget.cs index 4f679a94..f1e540ba 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuRenderTarget.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuRenderTarget.cs @@ -7,11 +7,10 @@ namespace AcDream.App.Rendering.Gpu.Vk; /// offscreen colour(+depth) bundle behind the paperdoll, the creature-appraisal /// viewport and the portal mask. /// -/// Offscreen targets stay single-sampled, matching the contract. Their -/// colour image carries SAMPLED as well as COLOR_ATTACHMENT usage -/// so it can be registered into the texture table and drawn by the retained UI -/// the moment its pass ends — which is the whole reason these exist rather than -/// rendering those views onto the backbuffer. +/// The textures exposed through are always +/// single-sampled. When the requested attachment sample count is greater than +/// one, separate transient multisample attachments resolve into those textures; +/// the global table never receives an illegal multisampled view. /// /// Slice V6l made both halves of that sentence true. The colour image now /// carries a second, LAYERED view for the table to sample (see @@ -22,7 +21,9 @@ namespace AcDream.App.Rendering.Gpu.Vk; internal sealed class VulkanGpuRenderTarget : IGpuRenderTarget { private readonly VulkanGpuTexture _color; + private readonly VulkanGpuTexture? _multisampleColor; private readonly VulkanGpuTexture? _depth; + private readonly VulkanGpuTexture? _multisampleDepth; private bool _disposed; internal VulkanGpuRenderTarget( @@ -38,8 +39,18 @@ internal sealed class VulkanGpuRenderTarget : IGpuRenderTarget ArgumentException.ThrowIfNullOrWhiteSpace(description.Name); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Width); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Height); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.SampleCount); + if (description.SampleableDepth && description.DepthFormat is null) + { + throw new ArgumentException( + "SampleableDepth requires a depth format.", + nameof(description)); + } Description = description; + // The public colour texture is the single-sampled result even when the + // pass itself is multisampled. Post-process and retained-UI consumers + // always register this image, never the transient attachment below. _color = new VulkanGpuTexture( vk, device, @@ -55,11 +66,36 @@ internal sealed class VulkanGpuRenderTarget : IGpuRenderTarget description.Height, LayerCount: 1, MipLevelCount: 1), - Math.Max(1, description.SampleCount), - renderTarget: true); + sampleCount: 1, + renderTarget: true, + sampleable: true); + + if (description.SampleCount > 1) + { + _multisampleColor = new VulkanGpuTexture( + vk, + device, + allocator, + uploads, + retirement, + debugNames, + new GpuTextureDescription( + $"{description.Name}-color-msaa", + GpuTextureKind.Texture2D, + description.ColorFormat, + description.Width, + description.Height, + LayerCount: 1, + MipLevelCount: 1), + description.SampleCount, + renderTarget: true, + sampleable: false); + } if (description.DepthFormat is { } depthFormat) { + int retainedDepthSamples = + description.SampleableDepth ? 1 : description.SampleCount; _depth = new VulkanGpuTexture( vk, device, @@ -75,14 +111,38 @@ internal sealed class VulkanGpuRenderTarget : IGpuRenderTarget description.Height, LayerCount: 1, MipLevelCount: 1), - Math.Max(1, description.SampleCount), + retainedDepthSamples, renderTarget: true, + sampleable: description.SampleableDepth, // Slice V6l: the DEVICE's combined depth/stencil format, not the // contract enum's literal one. Every pipeline bakes one // depth/stencil format under dynamic rendering and the same // pipelines draw in both the backbuffer pass and this one, so a // second format here would make one of the two undefined. formatOverride: deviceDepthStencilFormat); + + if (description.SampleableDepth && description.SampleCount > 1) + { + _multisampleDepth = new VulkanGpuTexture( + vk, + device, + allocator, + uploads, + retirement, + debugNames, + new GpuTextureDescription( + $"{description.Name}-depth-msaa", + GpuTextureKind.Texture2D, + depthFormat, + description.Width, + description.Height, + LayerCount: 1, + MipLevelCount: 1), + description.SampleCount, + renderTarget: true, + sampleable: false, + formatOverride: deviceDepthStencilFormat); + } } } @@ -90,16 +150,33 @@ internal sealed class VulkanGpuRenderTarget : IGpuRenderTarget public IGpuTexture ColorTexture => _color; - internal VulkanGpuTexture Color => _color; + public IGpuTexture? DepthTexture => Description.SampleableDepth ? _depth : null; - internal VulkanGpuTexture? Depth => _depth; + /// The image written as the pass's colour attachment. + internal VulkanGpuTexture ColorAttachment => _multisampleColor ?? _color; + + /// The single-sampled resolve destination, or null at one sample. + internal VulkanGpuTexture? ColorResolve => _multisampleColor is null ? null : _color; + + /// The image written as the pass's depth/stencil attachment. + internal VulkanGpuTexture? DepthAttachment => _multisampleDepth ?? _depth; + + /// The sampleable depth resolve destination, or null when no resolve is required. + internal VulkanGpuTexture? DepthResolve => + Description.SampleableDepth && _multisampleDepth is not null ? _depth : null; + + internal VulkanGpuTexture ColorResult => _color; + + internal VulkanGpuTexture? DepthResult => _depth; public void Dispose() { if (_disposed) return; _disposed = true; + _multisampleDepth?.Dispose(); _depth?.Dispose(); + _multisampleColor?.Dispose(); _color.Dispose(); } } diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTexture.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTexture.cs index 31264aae..296e7207 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTexture.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTexture.cs @@ -41,6 +41,7 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture in GpuTextureDescription description, int sampleCount = 1, bool renderTarget = false, + bool sampleable = true, // Fully qualified: in a parameter-default expression the simple name // `Format` binds to this type's own GpuTextureFormat property first. Format formatOverride = Silk.NET.Vulkan.Format.Undefined) @@ -55,6 +56,13 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Height); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.LayerCount); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.MipLevelCount); + if (sampleCount > 1 && sampleable) + { + throw new ArgumentException( + "A multisampled image cannot be registered in acdream's single-sampled texture table; " + + "create a separate single-sampled resolve image.", + nameof(sampleable)); + } Name = description.Name; Kind = description.Kind; @@ -64,6 +72,7 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture LayerCount = description.LayerCount; MipLevelCount = description.MipLevelCount; SampleCount = sampleCount; + IsSampleable = sampleable; // Slice V6l: an offscreen target's DEPTH attachment takes the format the // device already chose for the backbuffer, because a pipeline bakes one // depth/stencil format and draws in both kinds of pass. The contract's @@ -77,7 +86,9 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture bool depthStencil = VulkanTextureFormatMapping.IsDepthStencil(description.Format); ImageUsageFlags usage = depthStencil ? ImageUsageFlags.DepthStencilAttachmentBit - : ImageUsageFlags.SampledBit | ImageUsageFlags.TransferDstBit | ImageUsageFlags.TransferSrcBit; + : ImageUsageFlags.TransferDstBit | ImageUsageFlags.TransferSrcBit; + if (sampleable) + usage |= ImageUsageFlags.SampledBit; if (renderTarget && !depthStencil) usage |= ImageUsageFlags.ColorAttachmentBit; if (sampleCount > 1) @@ -142,9 +153,9 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture _vk.CreateImageView(_device, &viewCreate, null, out ImageView view), $"vkCreateImageView ('{description.Name}')"); View = view; - SampledView = view; + SampledView = renderTarget && !sampleable ? default : view; - // Campaign V slice V6l: a colour render target needs TWO views. + // Campaign V slice V6l: a sampleable render target needs TWO views. // // An ATTACHMENT view must be VK_IMAGE_VIEW_TYPE_2D, and the global // texture table's descriptor array is declared sampler2DArray, so the @@ -155,9 +166,14 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture // fix: one image, one allocation, two ways of looking at it. Legal // without any creation flag — a 2D_ARRAY view over an imageType-2D // image with arrayLayers >= 1 is exactly what the spec permits. - if (renderTarget && !depthStencil) + if (renderTarget && sampleable) { viewCreate.ViewType = VulkanTextureFormatMapping.SampledViewTypeOf(description.Kind); + // Combined depth/stencil remains one attachment for #117, but + // sampling exposes only depth. A sampled view containing the + // stencil aspect is invalid for sampler2DArray. + if (depthStencil) + viewCreate.SubresourceRange.AspectMask = ImageAspectFlags.DepthBit; VulkanInterop.Check( _vk.CreateImageView(_device, &viewCreate, null, out ImageView sampled), $"vkCreateImageView ('{description.Name}', sampled)"); @@ -184,6 +200,7 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture public int MipLevelCount { get; } internal int SampleCount { get; } + internal bool IsSampleable { get; } internal Image Image { get; } /// The view a pass names as an attachment, and the only view a non-attachment has. @@ -196,6 +213,10 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture /// is sampler2DArray (slice V6l, plan §5.5.7). /// internal ImageView SampledView { get; } + internal ImageLayout SampledLayout => + VulkanTextureFormatMapping.IsDepthStencil(Format) + ? ImageLayout.DepthStencilReadOnlyOptimal + : ImageLayout.ShaderReadOnlyOptimal; internal Format VkFormat { get; } internal ImageAspectFlags Aspect { get; } @@ -264,7 +285,7 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture VulkanAllocation allocation = _allocation; _retirement.Retire(() => { - if (sampledView.Handle != view.Handle) + if (sampledView.Handle != 0 && sampledView.Handle != view.Handle) _vk.DestroyImageView(_device, sampledView, null); _vk.DestroyImageView(_device, view, null); _vk.DestroyImage(_device, image, null); @@ -337,6 +358,8 @@ internal sealed unsafe class VulkanGpuSampler : IGpuSampler internal Sampler Handle { get; } + internal bool IsDisposed => _disposed; + public void Dispose() { if (_disposed) diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTimerPool.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTimerPool.cs index 6f08a3ec..799bd77c 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTimerPool.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTimerPool.cs @@ -176,7 +176,7 @@ internal sealed unsafe class VulkanGpuTimerPool : IGpuTimerPool, IDisposable /// deliberately reports the last known value /// forever — right for a diagnostic readout, wrong for a percentile. /// - internal bool TryTakeResolved(string scopeName, out double milliseconds) + public bool TryTakeResolved(string scopeName, out double milliseconds) { if (!_resolved.TryGetValue(scopeName, out milliseconds)) return false; diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGraphicsContext.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGraphicsContext.cs index cedef45b..4db64348 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGraphicsContext.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGraphicsContext.cs @@ -214,6 +214,13 @@ internal sealed unsafe class VulkanGraphicsContext : IDisposable _physicalDevice = handles[choice.Device.Index]; + // The logical-device feature chain consumes this exact probe result. + // Keep the probe owned by the selected physical device and publish it + // before VulkanLogicalDeviceFactory.Create: probing later leaves the + // production Acquire path with no safe value from which to decide + // whether the optional Vulkan 1.1 multiview feature may be enabled. + _features = VulkanPhysicalDeviceInspector.ReadFeatures(vk, _physicalDevice); + IReadOnlyList queueFamilies = VulkanPhysicalDeviceInspector.ReadQueueFamilies( vk, @@ -233,7 +240,8 @@ internal sealed unsafe class VulkanGraphicsContext : IDisposable vk, _physicalDevice, families, - requireSwapchain: true); + requireSwapchain: true, + availableFeatures: _features); _device = created.Device; _graphicsQueue = created.GraphicsQueue; _presentQueue = created.PresentQueue; @@ -287,7 +295,6 @@ internal sealed unsafe class VulkanGraphicsContext : IDisposable _graphicsQueue, families.GraphicsFamily); - _features = VulkanPhysicalDeviceInspector.ReadFeatures(vk, _physicalDevice); _limits = VulkanPhysicalDeviceInspector.ReadLimits(vk, _physicalDevice); _formats = VulkanPhysicalDeviceInspector.ReadFormats( vk, diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanHostStorageVisibility.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanHostStorageVisibility.cs new file mode 100644 index 00000000..db95808d --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanHostStorageVisibility.cs @@ -0,0 +1,30 @@ +using Silk.NET.Vulkan; +using Buffer = Silk.NET.Vulkan.Buffer; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Exact sync2 dependency for CPU writes into a retained mapped SSBO before +/// shadow vertex shaders read it. Kept pure so driverless contract tests can +/// assert the stage/access/ownership/range tuple Vulkan receives. +/// +internal static class VulkanHostStorageVisibility +{ + internal static BufferMemoryBarrier2 Create(Buffer buffer, ulong sizeBytes) + { + ArgumentOutOfRangeException.ThrowIfZero(sizeBytes); + return new BufferMemoryBarrier2 + { + SType = StructureType.BufferMemoryBarrier2, + SrcStageMask = PipelineStageFlags2.HostBit, + SrcAccessMask = AccessFlags2.HostWriteBit, + DstStageMask = PipelineStageFlags2.VertexShaderBit, + DstAccessMask = AccessFlags2.ShaderReadBit, + SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, + Buffer = buffer, + Offset = 0, + Size = sizeBytes, + }; + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanInterop.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanInterop.cs index 1f74bf7b..3dae155b 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanInterop.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanInterop.cs @@ -314,6 +314,7 @@ internal static unsafe class VulkanPhysicalDeviceInspector TextureCompressionBc = core.TextureCompressionBC, SamplerAnisotropy = core.SamplerAnisotropy, ShaderDrawParameters = vulkan11.ShaderDrawParameters, + Multiview = vulkan11.Multiview, TimelineSemaphore = vulkan12.TimelineSemaphore, HostQueryReset = vulkan12.HostQueryReset, RuntimeDescriptorArray = vulkan12.RuntimeDescriptorArray, @@ -363,6 +364,8 @@ internal static unsafe class VulkanPhysicalDeviceInspector MaxClipDistances = limits.MaxClipDistances, MaxBoundDescriptorSets = limits.MaxBoundDescriptorSets, MaxDescriptorSetStorageBuffersDynamic = limits.MaxDescriptorSetStorageBuffersDynamic, + MaxDescriptorSetStorageBuffers = limits.MaxDescriptorSetStorageBuffers, + MaxPerStageDescriptorStorageBuffers = limits.MaxPerStageDescriptorStorageBuffers, MaxDescriptorSetUniformBuffersDynamic = limits.MaxDescriptorSetUniformBuffersDynamic, MaxDescriptorSetUpdateAfterBindSampledImages = indexing.MaxDescriptorSetUpdateAfterBindSampledImages, @@ -370,8 +373,11 @@ internal static unsafe class VulkanPhysicalDeviceInspector indexing.MaxPerStageDescriptorUpdateAfterBindSampledImages, TimestampComputeAndGraphics = limits.TimestampComputeAndGraphics, MinStorageBufferOffsetAlignment = (uint)limits.MinStorageBufferOffsetAlignment, + MaxStorageBufferRange = limits.MaxStorageBufferRange, MinUniformBufferOffsetAlignment = (uint)limits.MinUniformBufferOffsetAlignment, MaxImageDimension2D = limits.MaxImageDimension2D, + MaxImageArrayLayers = limits.MaxImageArrayLayers, + DeviceLocalHeapBytes = LargestDeviceLocalHeap(vk, device), MaxColorSampleCount = HighestSampleCount( limits.FramebufferColorSampleCounts & limits.FramebufferDepthSampleCounts), }; @@ -398,10 +404,28 @@ internal static unsafe class VulkanPhysicalDeviceInspector { ArgumentNullException.ThrowIfNull(vk); + Format depthStencil = ChooseDepthStencilFormat(vk, device); + FormatProperties rgba16; + vk.GetPhysicalDeviceFormatProperties(device, Format.R16G16B16A16Sfloat, &rgba16); + FormatFeatureFlags rgba16Features = rgba16.OptimalTilingFeatures; + return new VulkanFormatSupport { SwapchainUnormFormat = surfaceOffersUnorm, - DepthStencilFormat = ChooseDepthStencilFormat(vk, device), + DepthStencilFormat = depthStencil, + DepthStencilSampled = + depthStencil != Format.Undefined + && SupportsOptimalSampling(vk, device, depthStencil), + Rgba16FloatColorAttachment = + rgba16Features.HasFlag(FormatFeatureFlags.ColorAttachmentBit), + Rgba16FloatSampled = + rgba16Features.HasFlag(FormatFeatureFlags.SampledImageBit), + Rgba16FloatLinearFilter = + rgba16Features.HasFlag(FormatFeatureFlags.SampledImageFilterLinearBit), + MaxRgba16FloatSampleCount = ReadOptimalColorSampleCount( + vk, + device, + Format.R16G16B16A16Sfloat), Bc1Sampled = SupportsOptimalSampling(vk, device, Format.BC1RgbaUnormBlock), Bc2Sampled = SupportsOptimalSampling(vk, device, Format.BC2UnormBlock), Bc3Sampled = SupportsOptimalSampling(vk, device, Format.BC3UnormBlock), @@ -436,6 +460,32 @@ internal static unsafe class VulkanPhysicalDeviceInspector return properties.OptimalTilingFeatures.HasFlag(FormatFeatureFlags.SampledImageBit); } + /// + /// Query the sample-count mask for an optimal-tiling colour attachment. + /// Physical-device framebuffer limits are only an upper bound; the concrete + /// RGBA16F format may support fewer samples. Sampling is checked separately + /// because the multisample image is transient and only its one-sample + /// resolve target carries SAMPLED usage. + /// + internal static uint ReadOptimalColorSampleCount( + Silk.NET.Vulkan.Vk vk, + PhysicalDevice device, + Format format) + { + ImageFormatProperties properties; + Result result = vk.GetPhysicalDeviceImageFormatProperties( + device, + format, + ImageType.Type2D, + ImageTiling.Optimal, + ImageUsageFlags.ColorAttachmentBit, + ImageCreateFlags.None, + &properties); + return result == Result.Success + ? HighestSampleCount(properties.SampleCounts) + : 0u; + } + /// /// Enumerate queue families, reporting present support only when a surface /// is supplied. The headless probe passes null and takes the @@ -517,10 +567,12 @@ internal sealed unsafe class VulkanLogicalDeviceFactory Silk.NET.Vulkan.Vk vk, PhysicalDevice physicalDevice, VulkanQueueFamilyChoice families, - bool requireSwapchain) + bool requireSwapchain, + VulkanDeviceFeatureSupport availableFeatures) { ArgumentNullException.ThrowIfNull(vk); ArgumentNullException.ThrowIfNull(families); + ArgumentNullException.ThrowIfNull(availableFeatures); IReadOnlyList available = VulkanInterop.EnumerateDeviceExtensions(vk, physicalDevice); @@ -577,6 +629,9 @@ internal sealed unsafe class VulkanLogicalDeviceFactory SType = StructureType.PhysicalDeviceVulkan11Features, PNext = &vulkan12, ShaderDrawParameters = true, + // Core 1.1 optional feature: enable it iff the physical-device + // Features2 chain reported it. No extension path is attempted. + Multiview = availableFeatures.Multiview, }; var core = new PhysicalDeviceFeatures { diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanPipelineLayouts.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanPipelineLayouts.cs index b9503b13..3da35bd7 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanPipelineLayouts.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanPipelineLayouts.cs @@ -3,8 +3,8 @@ using Silk.NET.Vulkan; namespace AcDream.App.Rendering.Gpu.Vk; /// -/// Campaign V, plan §3.4 and §4.4: the three descriptor set layouts and the ONE -/// pipeline layout every acdream pipeline shares. +/// Campaign V, plan §3.4 and §4.4: retail's three descriptor set layouts and +/// shared pipeline layout, plus the strictly opt-in render-pack extension. /// /// Extracted from slice V5's active capability probe at V6b so the probe /// and the live backend build the same objects from the same code. The probe's @@ -12,33 +12,110 @@ namespace AcDream.App.Rendering.Gpu.Vk; /// device; a second, similar-looking definition would quietly destroy that /// property the first time one of them changed. /// -/// One pipeline layout is a decision, not an economy. Because every -/// pipeline shares it, switching pipelines mid-pass does not invalidate bound -/// descriptor sets or push constants — which is what lets the world dispatcher -/// bind the texture table once per frame and then change pipeline per bucket. -/// The single 96-byte push-constant block exists for the same reason. +/// Retail remains authoritative. Its layout is exactly sets 0, 1, +/// and 2 with the original 96-byte push block. A flagged render-pack pipeline +/// lazily acquires a compatible four-set layout whose additional set 3 owns +/// bindings 5..8. The pack objects are reference-counted through pipeline +/// retirement, so selecting retail creates no Vulkan pack object at all. /// internal static unsafe class VulkanPipelineLayouts { - /// The three sets plus the shared layout, owned together and destroyed together. - internal sealed class Created( - DescriptorSetLayout storage, - DescriptorSetLayout uniform, - DescriptorSetLayout textureTable, - PipelineLayout pipelineLayout) : IDisposable + /// The retail layouts and the lazy lifetime of the optional pack layout. + internal sealed class Created : IDisposable { + private readonly Silk.NET.Vulkan.Vk _vk; + private readonly Device _device; + private readonly object _packLock = new(); + private PackState? _pack; + private int _packReferences; + private int _nextPackGeneration; private bool _disposed; - internal DescriptorSetLayout Storage { get; } = storage; - internal DescriptorSetLayout Uniform { get; } = uniform; - internal DescriptorSetLayout TextureTable { get; } = textureTable; - internal PipelineLayout PipelineLayout { get; } = pipelineLayout; + internal Created( + Silk.NET.Vulkan.Vk vk, + Device device, + DescriptorSetLayout storage, + DescriptorSetLayout uniform, + DescriptorSetLayout textureTable, + PipelineLayout pipelineLayout) + { + _vk = vk; + _device = device; + Storage = storage; + Uniform = uniform; + TextureTable = textureTable; + PipelineLayout = pipelineLayout; + } + + internal DescriptorSetLayout Storage { get; } + internal DescriptorSetLayout Uniform { get; } + internal DescriptorSetLayout TextureTable { get; } + internal PipelineLayout PipelineLayout { get; } + + /// + /// Acquires the opt-in layout. The first acquisition creates set 3 and + /// its compatible four-set pipeline layout; the last retired pipeline + /// destroys them together with every descriptor pool allocated from it. + /// + internal PackLayoutLease AcquirePackLayout() + { + lock (_packLock) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _pack ??= CreatePackState(++_nextPackGeneration); + _packReferences++; + return new PackLayoutLease(this, _pack); + } + } + + private PackState CreatePackState(int generation) + { + DescriptorSetLayout packUniform = default; + try + { + packUniform = CreatePackUniformSetLayout(_vk, _device); + PipelineLayout packPipeline = CreatePackPipelineLayout( + _vk, + _device, + Storage, + Uniform, + TextureTable, + packUniform); + return new PackState(_vk, _device, generation, packUniform, packPipeline); + } + catch + { + if (packUniform.Handle != 0) + _vk.DestroyDescriptorSetLayout(_device, packUniform, null); + throw; + } + } + + private void ReleasePackLayout(PackState state) + { + lock (_packLock) + { + if (_pack != state || _packReferences <= 0) + return; + _packReferences--; + if (_packReferences != 0) + return; + _pack = null; + state.Destroy(); + } + } internal void Destroy(Silk.NET.Vulkan.Vk vk, Device device) { if (_disposed) return; _disposed = true; + lock (_packLock) + { + _pack?.Destroy(); + _pack = null; + _packReferences = 0; + } if (PipelineLayout.Handle != 0) vk.DestroyPipelineLayout(device, PipelineLayout, null); if (TextureTable.Handle != 0) @@ -51,6 +128,114 @@ internal static unsafe class VulkanPipelineLayouts /// Destruction needs the device, so is the real disposer. public void Dispose() => _disposed = true; + + internal sealed class PackLayoutLease : IDisposable + { + private Created? _owner; + + internal PackLayoutLease(Created owner, PackState state) + { + _owner = owner; + State = state; + } + + internal PackState State { get; } + internal PipelineLayout PipelineLayout => State.PipelineLayout; + + public void Dispose() + { + Created? owner = Interlocked.Exchange(ref _owner, null); + owner?.ReleasePackLayout(State); + } + } + + /// + /// One generation of the pack layout and all descriptor pools allocated + /// against it. Keeping the pools here prevents a stale per-flight set + /// from outliving the descriptor-set layout it was allocated from. + /// + internal sealed unsafe class PackState + { + private const int SetsPerPool = 32; + private readonly Silk.NET.Vulkan.Vk _vk; + private readonly Device _device; + private readonly List _pools = []; + private int _setCount; + private bool _destroyed; + + internal PackState( + Silk.NET.Vulkan.Vk vk, + Device device, + int generation, + DescriptorSetLayout descriptorSetLayout, + PipelineLayout pipelineLayout) + { + _vk = vk; + _device = device; + Generation = generation; + DescriptorSetLayout = descriptorSetLayout; + PipelineLayout = pipelineLayout; + } + + internal int Generation { get; } + internal DescriptorSetLayout DescriptorSetLayout { get; } + internal PipelineLayout PipelineLayout { get; } + + internal DescriptorSet AllocateDescriptorSet() + { + ObjectDisposedException.ThrowIf(_destroyed, this); + if (_setCount % SetsPerPool == 0) + _pools.Add(CreatePool()); + + DescriptorSetLayout layout = DescriptorSetLayout; + var allocate = new DescriptorSetAllocateInfo + { + SType = StructureType.DescriptorSetAllocateInfo, + DescriptorPool = _pools[^1], + DescriptorSetCount = 1, + PSetLayouts = &layout, + }; + VulkanInterop.Check( + _vk.AllocateDescriptorSets(_device, &allocate, out DescriptorSet set), + "vkAllocateDescriptorSets (render-pack set 3)"); + _setCount++; + return set; + } + + private DescriptorPool CreatePool() + { + var size = new DescriptorPoolSize + { + Type = DescriptorType.UniformBufferDynamic, + DescriptorCount = PackUniformBindingCount * SetsPerPool, + }; + var create = new DescriptorPoolCreateInfo + { + SType = StructureType.DescriptorPoolCreateInfo, + MaxSets = SetsPerPool, + PoolSizeCount = 1, + PPoolSizes = &size, + }; + VulkanInterop.Check( + _vk.CreateDescriptorPool(_device, &create, null, out DescriptorPool pool), + "vkCreateDescriptorPool (render-pack set 3)"); + return pool; + } + + internal void Destroy() + { + if (_destroyed) + return; + _destroyed = true; + foreach (DescriptorPool pool in _pools) + _vk.DestroyDescriptorPool(_device, pool, null); + _pools.Clear(); + if (PipelineLayout.Handle != 0) + _vk.DestroyPipelineLayout(_device, PipelineLayout, null); + if (DescriptorSetLayout.Handle != 0) + _vk.DestroyDescriptorSetLayout(_device, DescriptorSetLayout, null); + } + } } /// Creates all four objects, cleaning up whatever succeeded if a later one fails. @@ -67,7 +252,7 @@ internal static unsafe class VulkanPipelineLayouts uniform = CreateUniformSetLayout(vk, device); table = CreateTextureTableSetLayout(vk, device); PipelineLayout layout = CreatePipelineLayout(vk, device, storage, uniform, table); - return new Created(storage, uniform, table, layout); + return new Created(vk, device, storage, uniform, table, layout); } catch { @@ -105,11 +290,10 @@ internal static unsafe class VulkanPipelineLayouts /// at all can fail this layout. That matters for slice V9's lavapipe row and /// for whatever Linux driver the deferred physical row eventually uses. /// - /// Binding 9 is the clearest case. The texture table is the - /// GL-only uvec2 handle-buffer emulation; the Vulkan backend binds set - /// 2 instead and never touches binding 9 at all, so a dynamic descriptor for - /// it would be a device resource spent on a binding that is provably never - /// bound. + /// Binding 9 is a plain descriptor. #226 uploads one + /// per-instance detail-category array at a stable ring range for each + /// submission. It does not need to be re-pointed between draw calls, so a + /// scarce dynamic descriptor buys it nothing. /// /// What to do if V4c disagrees. Bindings 6, 7 and 8 are /// per-instance arrays grouped here with the frame-global tables because @@ -153,8 +337,8 @@ internal static unsafe class VulkanPipelineLayouts /// /// Set 0 — the storage - /// bindings pins (nine, since Campaign V - /// slice V11 deleted the GL-only StorageTextureTable binding), split + /// bindings pins (ten after #226 reclaimed + /// binding 9 from the deleted GL-only texture table), split /// between dynamic and plain by . /// internal static DescriptorSetLayout CreateStorageSetLayout(Silk.NET.Vulkan.Vk vk, Device device) @@ -199,6 +383,13 @@ internal static unsafe class VulkanPipelineLayouts /// internal const uint UniformTerrainClip = 2; + /// Bindings 5..8 in opt-in set 3. + internal const uint PackUniformBindingCount = 4; + + internal static bool IsDeclaredPackUniformBinding(uint binding) => + binding >= GpuBindingModel.UniformAtmosphericFrame + && binding <= GpuBindingModel.UniformPackSettings; + /// /// Which of set 1's bindings the layout declares — the uniform-side twin of /// , and for the same reason: the @@ -247,12 +438,9 @@ internal static unsafe class VulkanPipelineLayouts } /// - /// Set 1 — the SceneLighting, terrain-clip, terrain-tiling and sky-params - /// uniform blocks. All four are dynamic: each is fed from the per-frame ring, - /// so its offset moves every frame and a dynamic descriptor is exactly what - /// spares the write. Four is half Vulkan's guaranteed - /// maxDescriptorSetUniformBuffersDynamic of 8, and the capability gate - /// asserts it. + /// Set 1 — only retail frame blocks. All four are dynamic: each is fed from + /// the per-frame ring, so its offset moves every frame and a dynamic + /// descriptor is exactly what spares the write. /// internal static DescriptorSetLayout CreateUniformSetLayout(Silk.NET.Vulkan.Vk vk, Device device) { @@ -281,6 +469,38 @@ internal static unsafe class VulkanPipelineLayouts return layout; } + /// + /// Opt-in set 3 — sparse dynamic uniform bindings 5..8 from render-pack ABI + /// v1. It is deliberately not created by . + /// + internal static DescriptorSetLayout CreatePackUniformSetLayout( + Silk.NET.Vulkan.Vk vk, + Device device) + { + DescriptorSetLayoutBinding* bindings = stackalloc DescriptorSetLayoutBinding[(int)PackUniformBindingCount]; + for (uint i = 0; i < PackUniformBindingCount; i++) + { + bindings[i] = new DescriptorSetLayoutBinding + { + Binding = GpuBindingModel.UniformAtmosphericFrame + i, + DescriptorType = DescriptorType.UniformBufferDynamic, + DescriptorCount = 1, + StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, + }; + } + + var create = new DescriptorSetLayoutCreateInfo + { + SType = StructureType.DescriptorSetLayoutCreateInfo, + BindingCount = PackUniformBindingCount, + PBindings = bindings, + }; + VulkanInterop.Check( + vk.CreateDescriptorSetLayout(device, &create, null, out DescriptorSetLayout layout), + "vkCreateDescriptorSetLayout (set 3, render-pack uniforms)"); + return layout; + } + /// /// Set 2 — the production texture table exactly as §4.4 specifies it: one /// combined-image-sampler binding of @@ -323,7 +543,7 @@ internal static unsafe class VulkanPipelineLayouts } /// - /// One shared pipeline layout: three sets plus the single 96-byte + /// Retail's shared pipeline layout: three sets plus the single 96-byte /// push-constant block. Creating it proves maxBoundDescriptorSets and /// maxPushConstantsSize for real rather than by reading a limit. /// @@ -358,4 +578,42 @@ internal static unsafe class VulkanPipelineLayouts "vkCreatePipelineLayout"); return layout; } + + /// + /// Render-pack layout. Sets 0..2 are byte-for-byte the retail layouts; set 3 + /// adds only the pack uniform ABI, and the push range remains 96 bytes. + /// + internal static PipelineLayout CreatePackPipelineLayout( + Silk.NET.Vulkan.Vk vk, + Device device, + DescriptorSetLayout storage, + DescriptorSetLayout uniform, + DescriptorSetLayout table, + DescriptorSetLayout packUniform) + { + DescriptorSetLayout* sets = stackalloc DescriptorSetLayout[4]; + sets[0] = storage; + sets[1] = uniform; + sets[2] = table; + sets[3] = packUniform; + + var pushConstants = new PushConstantRange + { + StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, + Offset = 0, + Size = GpuBindingModel.PushConstantBytes, + }; + var create = new PipelineLayoutCreateInfo + { + SType = StructureType.PipelineLayoutCreateInfo, + SetLayoutCount = 4, + PSetLayouts = sets, + PushConstantRangeCount = 1, + PPushConstantRanges = &pushConstants, + }; + VulkanInterop.Check( + vk.CreatePipelineLayout(device, &create, null, out PipelineLayout layout), + "vkCreatePipelineLayout (render-pack ABI)"); + return layout; + } } diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanRenderFailurePolicy.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanRenderFailurePolicy.cs new file mode 100644 index 00000000..cca4e5ae --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanRenderFailurePolicy.cs @@ -0,0 +1,43 @@ +using Silk.NET.Vulkan; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Separates process/device-terminal Vulkan failures from faults contributed by +/// an optional render pack. Only the latter may be quarantined to the default +/// renderer; a lost device or exhausted host/device memory cannot be made safe +/// by changing render graphs. +/// +internal static class VulkanRenderFailurePolicy +{ + internal static bool IsFatal(Exception error) + { + ArgumentNullException.ThrowIfNull(error); + + if (error is AggregateException aggregate) + { + foreach (Exception inner in aggregate.Flatten().InnerExceptions) + { + if (IsFatal(inner)) + return true; + } + } + + for (Exception? current = error; current is not null; current = current.InnerException) + { + if (current is OutOfMemoryException) + return true; + if (current is VulkanCallException vulkan && IsFatal(vulkan.Result)) + return true; + } + return false; + } + + private static bool IsFatal(Result result) => result is + Result.ErrorDeviceLost + or Result.ErrorOutOfHostMemory + or Result.ErrorOutOfDeviceMemory + // The swapchain policy already treats a lost surface as terminal. It + // cannot be repaired by falling back from a pack to the default graph. + or Result.ErrorSurfaceLostKhr; +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureFormatMapping.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureFormatMapping.cs index 3d84dda6..d787bad5 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureFormatMapping.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureFormatMapping.cs @@ -6,14 +6,11 @@ namespace AcDream.App.Rendering.Gpu.Vk; /// Campaign V slice V6b, plan §4.3: to /// , and the byte arithmetic each format implies. /// -/// Every format here is UNORM, and that is a finding rather than a -/// default. The V3 audit (plan §4.10) established that acdream has no sRGB -/// anywhere: not on upload, not in a shader, not at the framebuffer. The plan -/// previously specified an sRGB swapchain "matching the GL FramebufferSrgb -/// contract" — a contract that does not exist. Shipping an sRGB format would -/// have applied an unwanted encode to already-display-space values, brightening -/// every frame, and it would have passed silently until the V7 differential. -/// +/// The retail path's formats are UNORM, and that is a finding rather than +/// a default. The V3 audit (plan §4.10) established that acdream has no sRGB +/// anywhere: not on upload, not in a shader, not at the framebuffer. The one +/// float format in this mapping is an opt-in HDR intermediate; it does not alter +/// the retail swapchain or texture decode convention. /// internal static class VulkanTextureFormatMapping { @@ -54,6 +51,7 @@ internal static class VulkanTextureFormatMapping // Deliberately the same 32-bit UNORM order as the swapchain rather than // literal RGBA — see CanonicalColorAttachmentFormat. GpuTextureFormat.Rgba8UnormRenderTarget => CanonicalColorAttachmentFormat, + GpuTextureFormat.Rgba16FloatRenderTarget => Format.R16G16B16A16Sfloat, GpuTextureFormat.Depth24Stencil8 => Format.D24UnormS8Uint, _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown texture format."), }; @@ -62,12 +60,15 @@ internal static class VulkanTextureFormatMapping format == GpuTextureFormat.Depth24Stencil8; internal static bool IsRenderTarget(GpuTextureFormat format) => - format is GpuTextureFormat.Rgba8UnormRenderTarget or GpuTextureFormat.Depth24Stencil8; + format is GpuTextureFormat.Rgba8UnormRenderTarget + or GpuTextureFormat.Rgba16FloatRenderTarget + or GpuTextureFormat.Depth24Stencil8; /// Bytes one texel occupies. Only meaningful for uncompressed formats. internal static int BytesPerTexel(GpuTextureFormat format) => format switch { GpuTextureFormat.Rgba8Unorm or GpuTextureFormat.Rgba8UnormRenderTarget => 4, + GpuTextureFormat.Rgba16FloatRenderTarget => 8, GpuTextureFormat.R8Unorm => 1, GpuTextureFormat.Depth24Stencil8 => 4, _ => throw new ArgumentOutOfRangeException(nameof(format), format, "A block-compressed format has no texel size."), diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureTable.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureTable.cs index c41d12e8..0bc6bcc4 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureTable.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureTable.cs @@ -110,9 +110,11 @@ internal sealed unsafe class VulkanTextureTable : IDisposable private readonly VulkanTextureSlotAllocator _slots; private readonly DescriptorPool _pool; private readonly DescriptorSet _set; + private readonly object _sync = new(); private ImageView _defaultView; private Sampler _defaultSampler; + private ImageLayout _defaultLayout = ImageLayout.ShaderReadOnlyOptimal; private bool _disposed; internal VulkanTextureTable( @@ -171,27 +173,51 @@ internal sealed unsafe class VulkanTextureTable : IDisposable internal DescriptorSet Set => _set; - internal int LiveSlotCount => _slots.LiveCount; + internal int LiveSlotCount + { + get + { + lock (_sync) + return _slots.LiveCount; + } + } - internal uint HighWater => _slots.HighWater; + internal uint HighWater + { + get + { + lock (_sync) + return _slots.HighWater; + } + } /// /// Records the (view, sampler) pair written into a slot when it is scrubbed. /// Supplied after the default texture exists, which is necessarily after the /// table itself. /// - internal void SetScrubTarget(ImageView view, Sampler sampler) + internal void SetScrubTarget( + ImageView view, + Sampler sampler, + ImageLayout layout = ImageLayout.ShaderReadOnlyOptimal) { _defaultView = view; _defaultSampler = sampler; + _defaultLayout = layout; } - internal GpuTextureSlot Register(ImageView view, Sampler sampler) + internal GpuTextureSlot Register( + ImageView view, + Sampler sampler, + ImageLayout layout = ImageLayout.ShaderReadOnlyOptimal) { - ObjectDisposedException.ThrowIf(_disposed, this); - uint slot = _slots.Allocate(); - Write(slot, view, sampler); - return new GpuTextureSlot(slot); + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + uint slot = _slots.Allocate(); + Write(slot, view, sampler, layout); + return new GpuTextureSlot(slot); + } } /// @@ -201,22 +227,33 @@ internal sealed unsafe class VulkanTextureTable : IDisposable /// internal void ReleaseNow(GpuTextureSlot slot) { - if (_disposed || !slot.IsAssigned) - return; - if (_defaultView.Handle != 0 && _defaultSampler.Handle != 0) - Write(slot.Index, _defaultView, _defaultSampler); - _slots.Release(slot.Index); + lock (_sync) + { + if (_disposed || !slot.IsAssigned) + return; + if (_defaultView.Handle != 0 && _defaultSampler.Handle != 0) + Write(slot.Index, _defaultView, _defaultSampler, _defaultLayout); + _slots.Release(slot.Index); + } } - internal bool IsLive(GpuTextureSlot slot) => slot.IsAssigned && _slots.IsLive(slot.Index); + internal bool IsLive(GpuTextureSlot slot) + { + lock (_sync) + return slot.IsAssigned && _slots.IsLive(slot.Index); + } - private void Write(uint slot, ImageView view, Sampler sampler) + private void Write( + uint slot, + ImageView view, + Sampler sampler, + ImageLayout layout) { var info = new DescriptorImageInfo { ImageView = view, Sampler = sampler, - ImageLayout = ImageLayout.ShaderReadOnlyOptimal, + ImageLayout = layout, }; var write = new WriteDescriptorSet { @@ -233,10 +270,13 @@ internal sealed unsafe class VulkanTextureTable : IDisposable public void Dispose() { - if (_disposed) - return; - _disposed = true; - if (_pool.Handle != 0) - _vk.DestroyDescriptorPool(_device, _pool, null); + lock (_sync) + { + if (_disposed) + return; + _disposed = true; + if (_pool.Handle != 0) + _vk.DestroyDescriptorPool(_device, _pool, null); + } } } diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs index 11bf73ab..d736a9ef 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs @@ -175,6 +175,9 @@ internal static class VulkanViewportMapping GpuBlendMode.Additive => (BlendFactor.SrcAlpha, BlendFactor.One), // Retail's third mode, found at slice V4c in WbDrawDispatcher.ApplyRetailBlend. GpuBlendMode.InverseAlpha => (BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha), + // ACRender::SetDetailSurfaceInternal with DrawBuilding/DrawEnvCell's + // category state: D3DBLEND_DESTCOLOR + D3DBLEND_INVSRCALPHA. + GpuBlendMode.RetailDetail => (BlendFactor.DstColor, BlendFactor.OneMinusSrcAlpha), GpuBlendMode.None => (BlendFactor.One, BlendFactor.Zero), _ => throw new ArgumentOutOfRangeException(nameof(blend), blend, "Unknown blend mode."), }; diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanWorldPassScope.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanWorldPassScope.cs index 7d20e4da..23da2962 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanWorldPassScope.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanWorldPassScope.cs @@ -52,7 +52,24 @@ internal sealed unsafe class VulkanWorldPassScope : IWorldPassScope /// per frame, so a nested publication could only mean two phases believe they /// own the frame. /// - public IDisposable Publish(IGpuPassEncoder encoder) + public IDisposable Publish(IGpuPassEncoder encoder) => + PublishCore(encoder, preservePreparedSections: false); + + /// + /// Publishes a world pass after + /// has already built the frame. That preparation writes the authoritative + /// scene-lighting ring section before the shadow passes can run; clearing it + /// here would make every HDR receiver bind the zero fallback. The preceding + /// publication's disposal (or construction for the first frame) already + /// established an empty section set, so this preserves only values prepared + /// for the current GPU frame. + /// + internal IDisposable PublishPrepared(IGpuPassEncoder encoder) => + PublishCore(encoder, preservePreparedSections: true); + + private IDisposable PublishCore( + IGpuPassEncoder encoder, + bool preservePreparedSections) { ArgumentNullException.ThrowIfNull(encoder); if (_encoder is not null) @@ -62,7 +79,8 @@ internal sealed unsafe class VulkanWorldPassScope : IWorldPassScope } _encoder = encoder; - Sections.Reset(); + if (!preservePreparedSections) + Sections.Reset(); return _publication; } diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericAutoQualityController.cs b/src/AcDream.App/Rendering/Packs/AtmosphericAutoQualityController.cs new file mode 100644 index 00000000..b94613e2 --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/AtmosphericAutoQualityController.cs @@ -0,0 +1,228 @@ +namespace AcDream.App.Rendering.Packs; + +using AcDream.Plugin.Abstractions.Rendering; + +internal enum AtmosphericQualityLevel : byte +{ + Low, + Medium, + High, +} + +internal readonly record struct AtmosphericQualityMeasurement( + double InclusivePackGpuMillisecondsP99, + double IncrementalCpuMillisecondsP99, + long ResidentGpuBytes, + bool StableFrameBoundary); + +internal readonly record struct AtmosphericAutoQualitySnapshot( + AtmosphericQualityLevel Current, + int ConsecutiveOverBudgetFrames, + int ConsecutiveHeadroomFrames, + int CooldownFramesRemaining, + long ChangeGeneration, + bool SafeFallbackToRetailRequested); + +internal readonly record struct AtmosphericQualityBudget( + double GpuMillisecondsP99, + double CpuMillisecondsP99, + long ResidentGpuBytes) +{ + internal static AtmosphericQualityBudget FromPreset(RenderQualityPreset preset) => new( + preset.MaxIncrementalGpuMillisecondsP99, + preset.MaxIncrementalCpuMillisecondsP99, + preset.MaxResidentGpuBytes); +} + +/// +/// Long-hysteresis automatic quality policy. It changes only resolution, +/// cascade count/reach, and post-process sampling through one stable preset +/// swap. If even Low remains over its declared budget, it requests an atomic +/// whole-pack fallback to retail instead of silently dropping caster classes. +/// +internal sealed class AtmosphericAutoQualityController +{ + internal const int DowngradeHysteresisFrames = 180; + internal const int UpgradeHysteresisFrames = 900; + internal const int ChangeCooldownFrames = 300; + + private AtmosphericQualityLevel _current; + private readonly AtmosphericQualityLevel _minimum; + private readonly AtmosphericQualityLevel _maximum; + private readonly AtmosphericQualityBudget[] _budgets; + private int _overBudget; + private int _headroom; + private int _cooldown; + private long _generation; + private bool _safeFallbackToRetailRequested; + + internal AtmosphericAutoQualityController( + AtmosphericQualityLevel initial = AtmosphericQualityLevel.Medium, + AtmosphericQualityLevel minimum = AtmosphericQualityLevel.Low, + AtmosphericQualityLevel maximum = AtmosphericQualityLevel.High) + : this(DefaultBudgets(), initial, minimum, maximum) + { + } + + internal AtmosphericAutoQualityController( + IReadOnlyList budgets, + AtmosphericQualityLevel initial = AtmosphericQualityLevel.Medium, + AtmosphericQualityLevel minimum = AtmosphericQualityLevel.Low, + AtmosphericQualityLevel maximum = AtmosphericQualityLevel.High) + { + ArgumentNullException.ThrowIfNull(budgets); + if (budgets.Count != 3) + throw new ArgumentException("Auto quality requires Low, Medium, and High budgets.", nameof(budgets)); + if (minimum > initial || initial > maximum) + throw new ArgumentOutOfRangeException(nameof(initial)); + _budgets = budgets.ToArray(); + foreach (AtmosphericQualityBudget budget in _budgets) + { + if (!double.IsFinite(budget.GpuMillisecondsP99) + || budget.GpuMillisecondsP99 < 0d + || !double.IsFinite(budget.CpuMillisecondsP99) + || budget.CpuMillisecondsP99 < 0d + || budget.ResidentGpuBytes < 0) + { + throw new ArgumentOutOfRangeException( + nameof(budgets), + "Automatic-quality budgets must be finite and non-negative."); + } + } + _minimum = minimum; + _maximum = maximum; + _current = initial; + } + + internal AtmosphericAutoQualitySnapshot Snapshot => new( + _current, + _overBudget, + _headroom, + _cooldown, + _generation, + _safeFallbackToRetailRequested); + + internal AtmosphericQualityBudget CurrentBudget => _budgets[(int)_current]; + + internal AtmosphericAutoQualitySnapshot Observe( + in AtmosphericQualityMeasurement measurement) + { + Validate(in measurement); + if (!measurement.StableFrameBoundary) + return Snapshot; + if (_safeFallbackToRetailRequested) + return Snapshot; + if (_cooldown > 0) + { + _cooldown--; + _overBudget = 0; + _headroom = 0; + return Snapshot; + } + + AtmosphericQualityBudget budget = _budgets[(int)_current]; + bool over = measurement.InclusivePackGpuMillisecondsP99 + > budget.GpuMillisecondsP99 + || measurement.IncrementalCpuMillisecondsP99 + > budget.CpuMillisecondsP99 + || measurement.ResidentGpuBytes > budget.ResidentGpuBytes; + if (over) + { + _overBudget++; + _headroom = 0; + if (_overBudget >= DowngradeHysteresisFrames) + { + if (_current != _minimum) + Change((AtmosphericQualityLevel)((int)_current - 1)); + else + RequestSafeFallback(); + } + return Snapshot; + } + + _overBudget = 0; + if (_current == _maximum) + { + _headroom = 0; + return Snapshot; + } + + AtmosphericQualityLevel next = + (AtmosphericQualityLevel)((int)_current + 1); + AtmosphericQualityBudget nextBudget = _budgets[(int)next]; + bool hasHeadroom = measurement.InclusivePackGpuMillisecondsP99 + <= nextBudget.GpuMillisecondsP99 * 0.70 + && measurement.IncrementalCpuMillisecondsP99 + <= nextBudget.CpuMillisecondsP99 * 0.70 + && measurement.ResidentGpuBytes + <= (long)(nextBudget.ResidentGpuBytes * 0.70); + if (!hasHeadroom) + { + _headroom = 0; + return Snapshot; + } + + _headroom++; + if (_headroom >= UpgradeHysteresisFrames) + Change(next); + return Snapshot; + } + + internal void Reset(AtmosphericQualityLevel level) + { + _current = level; + _overBudget = 0; + _headroom = 0; + _cooldown = 0; + _safeFallbackToRetailRequested = false; + _generation = checked(_generation + 1); + } + + private void Change(AtmosphericQualityLevel value) + { + _current = value; + _overBudget = 0; + _headroom = 0; + _cooldown = ChangeCooldownFrames; + _generation = checked(_generation + 1); + } + + private void RequestSafeFallback() + { + _overBudget = DowngradeHysteresisFrames; + _headroom = 0; + _cooldown = 0; + _safeFallbackToRetailRequested = true; + _generation = checked(_generation + 1); + } + + private static AtmosphericQualityBudget[] DefaultBudgets() => + [ + From(DirectionalShadowPreset.Low), + From(DirectionalShadowPreset.Medium), + From(DirectionalShadowPreset.High), + ]; + + private static AtmosphericQualityBudget From(DirectionalShadowPreset preset) + { + DirectionalShadowQuality quality = DirectionalShadowQuality.For(preset); + return new AtmosphericQualityBudget( + quality.IncrementalGpuP99BudgetMilliseconds, + quality.IncrementalCpuP99BudgetMilliseconds, + quality.PackResidentGpuByteBudget); + } + + private static void Validate(in AtmosphericQualityMeasurement value) + { + if (!double.IsFinite(value.InclusivePackGpuMillisecondsP99) + || value.InclusivePackGpuMillisecondsP99 < 0 + || !double.IsFinite(value.IncrementalCpuMillisecondsP99) + || value.IncrementalCpuMillisecondsP99 < 0 + || value.ResidentGpuBytes < 0) + { + throw new ArgumentOutOfRangeException( + nameof(value), + "Atmospheric quality measurements must be finite and non-negative."); + } + } +} diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericCpuStageProfiler.cs b/src/AcDream.App/Rendering/Packs/AtmosphericCpuStageProfiler.cs new file mode 100644 index 00000000..d6912243 --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/AtmosphericCpuStageProfiler.cs @@ -0,0 +1,153 @@ +using System.Diagnostics; +using AcDream.App.Diagnostics; + +namespace AcDream.App.Rendering.Packs; + +internal readonly record struct AtmosphericCpuStageFrame( + long FrameSerial, + long ShadowCasterBuildTicks, + long ShadowEnvironmentTicks, + long ShadowPreparedDrawsAndTransformsTicks, + long ShadowFitAndUniformTicks, + long ShadowLayeredPassRecordingTicks, + long ShadowBookkeepingTicks, + long PostSetupAndOtherTicks, + long PostSunRaysTicks, + long PostFilmicTicks); + +internal readonly record struct RenderPackCpuStageDiagnostics( + string Stage, + int SampleCount, + double CpuMillisecondsP50, + double CpuMillisecondsP95, + double CpuMillisecondsP99); + +/// +/// Temporary Low-only structural profiler for the incremental CPU budget. It +/// samples the same one-in-four frames as Low GPU timestamps, keeping fewer +/// than half of the ordinary performance window instrumented while retaining +/// enough observations for a short physical run. Every hot-path buffer is +/// fixed at construction and observation is allocation-free. +/// +internal sealed class AtmosphericCpuStageProfiler +{ + internal const int SampleIntervalFrames = AtmosphericGpuTimerSampling.LowIntervalFrames; + + private static readonly string[] StageNames = + [ + "target-preparation", + "shadow-caster-build", + "shadow-environment", + "shadow-prepared-draws-and-transforms", + "shadow-fit-and-uniform", + "shadow-layered-pass-recording", + "shadow-bookkeeping", + "post-setup-and-other", + "post-sun-rays", + "post-filmic", + "performance-observe-bookkeeping", + "measured-pack-total", + "measured-pack-unattributed", + ]; + + private readonly FrameStatsBuffer[] _microseconds; + + internal AtmosphericCpuStageProfiler(int capacity = RenderPackPerformanceWindow.DefaultCapacity) + { + _microseconds = new FrameStatsBuffer[StageNames.Length]; + for (int i = 0; i < _microseconds.Length; i++) + _microseconds[i] = new FrameStatsBuffer(capacity); + } + + internal static bool ShouldMeasure(long frameSerial) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(frameSerial); + return frameSerial % SampleIntervalFrames == 0; + } + + internal void Observe( + in AtmosphericCpuStageFrame frame, + long targetPreparationTicks, + long measuredPackTotalTicks, + long observeBookkeepingTicks) + { + if (frame.FrameSerial <= 0) + throw new ArgumentOutOfRangeException(nameof(frame)); + ArgumentOutOfRangeException.ThrowIfNegative(targetPreparationTicks); + ArgumentOutOfRangeException.ThrowIfNegative(measuredPackTotalTicks); + ArgumentOutOfRangeException.ThrowIfNegative(observeBookkeepingTicks); + + long attributedTicks = checked( + targetPreparationTicks + + frame.ShadowCasterBuildTicks + + frame.ShadowEnvironmentTicks + + frame.ShadowPreparedDrawsAndTransformsTicks + + frame.ShadowFitAndUniformTicks + + frame.ShadowLayeredPassRecordingTicks + + frame.ShadowBookkeepingTicks + + frame.PostSetupAndOtherTicks + + frame.PostSunRaysTicks + + frame.PostFilmicTicks); + long unattributedTicks = Math.Max(0L, measuredPackTotalTicks - attributedTicks); + + Push(0, targetPreparationTicks); + Push(1, frame.ShadowCasterBuildTicks); + Push(2, frame.ShadowEnvironmentTicks); + Push(3, frame.ShadowPreparedDrawsAndTransformsTicks); + Push(4, frame.ShadowFitAndUniformTicks); + Push(5, frame.ShadowLayeredPassRecordingTicks); + Push(6, frame.ShadowBookkeepingTicks); + Push(7, frame.PostSetupAndOtherTicks); + Push(8, frame.PostSunRaysTicks); + Push(9, frame.PostFilmicTicks); + Push(10, observeBookkeepingTicks); + Push(11, measuredPackTotalTicks); + Push(12, unattributedTicks); + } + + internal IReadOnlyList Snapshot() + { + var result = new RenderPackCpuStageDiagnostics[StageNames.Length]; + for (int i = 0; i < result.Length; i++) + { + FrameStatsBuffer samples = _microseconds[i]; + result[i] = new RenderPackCpuStageDiagnostics( + StageNames[i], + samples.Count, + samples.Percentile(0.50) / 1000d, + samples.Percentile(0.95) / 1000d, + samples.Percentile(0.99) / 1000d); + } + return result; + } + + internal void Reset() + { + for (int i = 0; i < _microseconds.Length; i++) + _microseconds[i].Reset(); + } + + private void Push(int stage, long ticks) + { + long microseconds = checked((long)Math.Round( + ticks * 1_000_000d / Stopwatch.Frequency, + MidpointRounding.AwayFromZero)); + _microseconds[stage].Push(microseconds); + } +} + +/// +/// Optional production-frame seam. Only Low's built-in graph implements it; +/// retail, Medium, High, and declared graphs never enter the profiling path. +/// +internal interface IAtmosphericCpuStageProfileRuntime +{ + bool ShouldProfileCpuFrame(long frameSerial); + + void CompleteCpuProfile( + long frameSerial, + long targetPreparationTicks, + long measuredPackTotalTicks, + long observeBookkeepingTicks, + bool stableFrameBoundary); +} diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericFrameInputs.cs b/src/AcDream.App/Rendering/Packs/AtmosphericFrameInputs.cs new file mode 100644 index 00000000..e61ef0f7 --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/AtmosphericFrameInputs.cs @@ -0,0 +1,229 @@ +using System.Numerics; +using System.Runtime.InteropServices; +using System.Runtime.CompilerServices; +using AcDream.Core.World; +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.App.Rendering.Packs; + +/// +/// Immutable authored atmosphere and exact camera projection captured from the +/// normal world frame. This value owns no gameplay or renderer objects and is +/// valid after the wrapped world renderer returns. +/// +internal readonly record struct AtmosphericFrameInputs( + Vector2 SunScreenUv, + bool SunIsOnScreen, + float SunElevationDegrees, + Vector3 SunColor, + Vector3 SunDirection, + float SunDirectionalBrightness, + Matrix4x4 InverseViewProjection, + int ActiveDayGroup, + WeatherKind Weather, + float WeatherIntensity, + double DeltaSeconds, + int ViewportWidth, + int ViewportHeight, + bool IsOutdoor); + +internal interface IAtmosphericWorldFrameSink +{ + void Publish( + in RenderFrameFoundation foundation, + in WorldRenderFrame world, + int activeDayGroup); +} + +/// +/// One-frame handoff between , which owns the +/// canonical camera build, and the post graph. Reset happens before the world +/// pass so an intentionally skipped world can never reuse a prior camera. +/// +internal sealed class AtmosphericFrameInputState : IAtmosphericWorldFrameSink +{ + private RenderFrameInput _host; + private RenderFrameFoundation _foundation; + private AtmosphericFrameInputs _current; + private bool _published; + + internal void BeginFrame( + in RenderFrameInput host, + in RenderFrameFoundation foundation) + { + _host = host; + _foundation = foundation; + _current = default; + _published = false; + } + + public void Publish( + in RenderFrameFoundation foundation, + in WorldRenderFrame world, + int activeDayGroup) + { + Vector3 direction = SkyStateProvider.SunDirectionFromKeyframe(foundation.Sky); + Vector3 sunPoint = world.Camera.Position + (direction * 10_000f); + Vector4 clip = Vector4.Transform( + new Vector4(sunPoint, 1f), + world.Camera.ViewProjection); + bool finite = float.IsFinite(clip.X) + && float.IsFinite(clip.Y) + && float.IsFinite(clip.W) + && clip.W > 1e-5f; + Vector2 uv = finite + ? new Vector2( + (clip.X / clip.W * 0.5f) + 0.5f, + 0.5f - (clip.Y / clip.W * 0.5f)) + : new Vector2(-1f, -1f); + bool onScreen = finite + && uv.X >= 0f && uv.X <= 1f + && uv.Y >= 0f && uv.Y <= 1f; + Matrix4x4 inverseViewProjection = Matrix4x4.Invert( + world.Camera.ViewProjection, + out Matrix4x4 inverse) + ? inverse + : Matrix4x4.Identity; + + _current = new AtmosphericFrameInputs( + uv, + onScreen, + foundation.Sky.SunPitchDeg, + foundation.Sky.SunColor, + direction, + foundation.Sky.DirBright, + inverseViewProjection, + activeDayGroup, + foundation.Atmosphere.Kind, + Math.Clamp(foundation.Atmosphere.Intensity, 0f, 1f), + _host.DeltaSeconds, + _host.ViewportWidth, + _host.ViewportHeight, + IsOutdoor: world.Roots.RenderSky && !world.Roots.CameraInsideCell); + _published = true; + } + + internal AtmosphericFrameInputs Snapshot() + { + if (_published) + return _current; + + // A portal/login frame deliberately skipped the normal world. Preserve + // its authored colour inputs but suppress every directional effect. + return new AtmosphericFrameInputs( + new Vector2(-1f, -1f), + SunIsOnScreen: false, + _foundation.Sky.SunPitchDeg, + _foundation.Sky.SunColor, + SkyStateProvider.SunDirectionFromKeyframe(_foundation.Sky), + _foundation.Sky.DirBright, + Matrix4x4.Identity, + -1, + _foundation.Atmosphere.Kind, + Math.Clamp(_foundation.Atmosphere.Intensity, 0f, 1f), + _host.DeltaSeconds, + _host.ViewportWidth, + _host.ViewportHeight, + IsOutdoor: false); + } +} + +/// +/// Shader ABI SSOT for opt-in set 3 binding 5. Six std140 vec4 values followed by one +/// mat4, 160 bytes. +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal readonly struct AtmosphericFrameUniforms +{ + internal const int SizeInBytes = 160; + + internal AtmosphericFrameUniforms( + Vector4 sunScreen, + Vector4 sunColor, + Vector4 viewport, + Vector4 weather, + Vector4 sunDirection, + Vector4 policy, + Matrix4x4 inverseViewProjection) + { + SunScreen = sunScreen; + SunColor = sunColor; + Viewport = viewport; + Weather = weather; + SunDirection = sunDirection; + Policy = policy; + InverseViewProjection = inverseViewProjection; + } + + internal readonly Vector4 SunScreen; + internal readonly Vector4 SunColor; + internal readonly Vector4 Viewport; + internal readonly Vector4 Weather; + internal readonly Vector4 SunDirection; + internal readonly Vector4 Policy; + internal readonly Matrix4x4 InverseViewProjection; +} + +/// +/// Shader ABI SSOT for opt-in set 3 binding 7. Passes assign meanings to four std140 +/// vec4 values without changing the shared descriptor layout. +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal readonly struct AtmosphericPackPassUniforms +{ + internal const int SizeInBytes = 64; + + internal AtmosphericPackPassUniforms( + Vector4 params0, + Vector4 params1, + Vector4 params2, + Vector4 params3) + { + Params0 = params0; + Params1 = params1; + Params2 = params2; + Params3 = params3; + } + + internal readonly Vector4 Params0; + internal readonly Vector4 Params1; + internal readonly Vector4 Params2; + internal readonly Vector4 Params3; + + internal static AtmosphericPackPassUniforms From(Vector4 params0) => + new(params0, Vector4.Zero, Vector4.Zero, Vector4.Zero); +} + +/// +/// Shader ABI SSOT for opt-in set 3 binding 8. API v1 exposes 64 scalar values in +/// descriptor declaration order, physically grouped as sixteen std140 vec4s. +/// +[InlineArray(RenderPackShaderAbi.PackSettingScalarCapacity)] +internal struct PackSettingsUniforms +{ + internal const int SizeInBytes = RenderPackShaderAbi.PackSettingsSizeBytes; + private float _element0; + + internal static PackSettingsUniforms Create( + RenderPackDescriptor descriptor, + RenderQualityPreset preset, + IReadOnlyDictionary? userSettingOverrides = null) + { + var result = new PackSettingsUniforms(); + int count = Math.Min( + descriptor.Settings.Count, + RenderPackShaderAbi.PackSettingScalarCapacity); + for (int i = 0; i < count; i++) + { + RenderSettingDeclaration setting = descriptor.Settings[i]; + string value = RenderPackSettingResolution.Resolve( + setting, + preset, + userSettingOverrides); + result[i] = RenderPackSettingValueCodec.TryEncode(setting, value, out float encoded) + ? encoded + : 0f; + } + return result; + } +} diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericGpuTimerSampling.cs b/src/AcDream.App/Rendering/Packs/AtmosphericGpuTimerSampling.cs new file mode 100644 index 00000000..aea19c4b --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/AtmosphericGpuTimerSampling.cs @@ -0,0 +1,24 @@ +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.App.Rendering.Packs; + +/// +/// Detailed per-pass GPU timestamps are diagnostic commands, not visual work. +/// Low samples one complete frame in four so its tight median CPU budget is not +/// dominated by instrumentation; sampled frames still include every receiver, +/// shadow, and post-process scope and therefore preserve the inclusive GPU +/// measurement contract. Medium and High retain continuous measurement. +/// +internal static class AtmosphericGpuTimerSampling +{ + internal const int LowIntervalFrames = 4; + + internal static bool ShouldMeasure( + RenderQualitySemantic quality, + long frameSerial) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(frameSerial); + return quality is not RenderQualitySemantic.Low + || frameSerial % LowIntervalFrames == 0; + } +} diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs new file mode 100644 index 00000000..1009ff77 --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs @@ -0,0 +1,1731 @@ +using System.Diagnostics; +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Scene; +using AcDream.App.Rendering.Wb; +using AcDream.Core.World; +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.App.Rendering.Packs; + +internal readonly record struct AtmosphericPostProcessSettings( + float BloomStrength, + float FilmicStrength, + float Exposure, + float Saturation, + float Contrast, + float VignetteStrength, + float SunRayStrength) +{ + internal static AtmosphericPostProcessSettings Neutral { get; } = new( + BloomStrength: 0f, + FilmicStrength: 0f, + Exposure: 1f, + Saturation: 1f, + Contrast: 1f, + VignetteStrength: 0f, + SunRayStrength: 0f); + + internal static AtmosphericPostProcessSettings FromDescriptor( + RenderPackDescriptor descriptor, + RenderQualityPreset preset, + IReadOnlyDictionary? userSettingOverrides = null) + { + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(preset); + return new AtmosphericPostProcessSettings( + Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.BloomStrength, 0.65f), + Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.FilmicStrength, 1f), + Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.Exposure, 1f), + Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.GradeSaturation, 1f), + Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.GradeContrast, 1f), + Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.VignetteStrength, 0.12f), + Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.SunRayStrength, 0.55f)); + } + + private static float Read( + RenderPackDescriptor descriptor, + RenderQualityPreset preset, + IReadOnlyDictionary? userSettingOverrides, + RenderSettingSemantic semantic, + float fallback) + { + RenderSettingDeclaration? setting = descriptor.Settings.FirstOrDefault(value => + value.Semantic == semantic); + if (setting is null) + return fallback; + string value = RenderPackSettingResolution.Resolve( + setting, + preset, + userSettingOverrides); + return RenderPackSettingValueCodec.TryEncode(setting, value, out float encoded) + ? encoded + : fallback; + } +} + +internal interface IAtmosphericWorldGraphRuntime : IRenderPackRuntime +{ + IGpuRenderTarget PrepareWorldTarget(int width, int height, int sampleCount); + + void RenderPostProcess( + IGpuFrame frame, + in AtmosphericFrameInputs inputs); +} + +internal interface IDirectionalShadowWorldGraphRuntime : + IAtmosphericWorldGraphRuntime +{ + IDirectionalShadowReceiverSource DirectionalShadowReceivers { get; } + + DirectionalSunShadowDiagnostics RenderDirectionalShadows( + IGpuFrame frame, + in RenderFrameFoundation foundation, + in WorldRenderFrame world, + int activeDayGroup, + in RenderSceneQuery scene, + WbDrawDispatcher worldMeshes, + TerrainModernRenderer terrain); +} + +/// +/// Built-in Tier-1 graph. It owns every enhancement image, texture-table slot, +/// fullscreen pipeline, and the temporary HDR variants of existing world +/// pipelines. Retail selection constructs none of these objects. +/// +internal sealed class AtmosphericPostProcessGraph : + IDirectionalShadowWorldGraphRuntime, + IRenderPackRuntimeDiagnosticsSource, + IRenderPackRuntimePerformanceSource, + IAtmosphericCpuStageProfileRuntime +{ + private readonly IGpuDevice _device; + private readonly IDisposable _hdrPipelineLease; + private readonly IGpuSampler _linearSampler; + private readonly IGpuSampler _nearestSampler; + private readonly IGpuPipeline _sunOcclusion; + private readonly IGpuPipeline _sunRays; + private readonly IGpuPipeline _bloomDownsample; + private readonly IGpuPipeline _bloomBlur; + private readonly IGpuPipeline _filmic; + private readonly AtmosphericPostProcessSettings _settings; + private readonly float _shadowStrength; + private readonly PackSettingsUniforms _packSettings; + private readonly DirectionalSunShadowRenderer _directionalShadows; + private readonly VolumetricShaftRenderer? _volumetric; + private readonly bool _fuseLowPostProcess; + private readonly AtmosphericCpuStageProfiler? _cpuStageProfiler; + private readonly DirectionalShadowCasterFrame _shadowCasters = new(); + private TargetSet? _targets; + private AtmosphericFrameInputs _lastInputs; + private DirectionalSunShadowDiagnostics _lastShadowDiagnostics; + private int _lastShadowCasterCount; + private int _lastShadowClassificationCalls; + private long _lastShadowFrameSerial = -1; + private WbDrawDispatcher? _lastShadowWorldMeshes; + private AtmosphericCpuStageFrame _cpuStageFrame; + private long _residentGpuBudgetBytes; + private bool _renderedFrame; + private bool _disposed; + + internal AtmosphericPostProcessGraph( + IGpuDevice device, + RenderPackDescriptor descriptor, + IRenderPackAssets assets, + RenderQualityPreset preset, + AtmosphericPostProcessSettings? settings = null, + IReadOnlyDictionary? userSettingOverrides = null) + : this( + device, + descriptor, + RenderPackShaderAssets.Validate(descriptor, assets), + preset, + settings, + userSettingOverrides) + { + } + + internal AtmosphericPostProcessGraph( + IGpuDevice device, + RenderPackDescriptor descriptor, + ValidatedRenderPackShaderAssets assets, + RenderQualityPreset preset, + AtmosphericPostProcessSettings? settings = null, + IReadOnlyDictionary? userSettingOverrides = null) + { + _device = device ?? throw new ArgumentNullException(nameof(device)); + Descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor)); + ArgumentNullException.ThrowIfNull(assets); + Preset = preset ?? throw new ArgumentNullException(nameof(preset)); + if (device is not IGpuPipelineFormatVariantHost variants) + { + throw new NotSupportedException( + "The active RHI cannot prebuild HDR variants of the normal world pipelines."); + } + + IDisposable? lease = null; + DirectionalSunShadowRenderer? directionalShadows = null; + VolumetricShaftRenderer? volumetric = null; + var created = new List(capacity: 5); + try + { + lease = variants.AcquirePipelineColorFormat( + GpuTextureFormat.Rgba16FloatRenderTarget); + _linearSampler = device.CreateSampler(GpuSamplerDescription.WorldClamp); + _nearestSampler = device.CreateSampler(GpuSamplerDescription.UiNearest); + _sunOcclusion = CreatePipeline( + device, + "atmospheric-sun-occlusion", + ShaderSet(descriptor, assets, RenderPassSemantic.SunOcclusion), + GpuTextureFormat.Rgba8UnormRenderTarget); + created.Add(_sunOcclusion); + _sunRays = CreatePipeline( + device, + "atmospheric-sun-rays", + ShaderSet(descriptor, assets, RenderPassSemantic.SunRays), + GpuTextureFormat.Rgba16FloatRenderTarget); + created.Add(_sunRays); + _bloomDownsample = CreatePipeline( + device, + "atmospheric-bloom-downsample", + ShaderSet(descriptor, assets, RenderPassSemantic.BloomDownsample), + GpuTextureFormat.Rgba16FloatRenderTarget); + created.Add(_bloomDownsample); + _bloomBlur = CreatePipeline( + device, + "atmospheric-bloom-blur", + ShaderSet(descriptor, assets, RenderPassSemantic.BloomBlurHorizontal), + GpuTextureFormat.Rgba16FloatRenderTarget); + created.Add(_bloomBlur); + _filmic = CreatePipeline( + device, + "atmospheric-filmic", + ShaderSet(descriptor, assets, RenderPassSemantic.FilmicComposite), + GpuTextureFormat.Rgba8UnormRenderTarget); + created.Add(_filmic); + _settings = settings + ?? AtmosphericPostProcessSettings.FromDescriptor( + descriptor, + preset, + userSettingOverrides); + _packSettings = PackSettingsUniforms.Create( + descriptor, + preset, + userSettingOverrides); + _fuseLowPostProcess = (preset.ExecutionHints + & RenderQualityExecutionHints.FusedAtmosphericPostProcess) != 0; + _cpuStageProfiler = preset.Semantic is RenderQualitySemantic.Low + ? new AtmosphericCpuStageProfiler() + : null; + _shadowStrength = ReadSemanticSetting( + descriptor, + preset, + userSettingOverrides, + RenderSettingSemantic.DirectionalShadowStrength, + 0.72f); + directionalShadows = new DirectionalSunShadowRenderer( + device, + ResolveShadowQuality( + descriptor, + preset, + userSettingOverrides), + atmospherePolicy: + RenderPackAtmospherePolicyEvaluation.NeutralDirectionalShadowElevation, + pipelineShaders: LoadDirectionalShadowShaders(descriptor, assets), + multiviewCascades: (preset.ExecutionHints + & RenderQualityExecutionHints + .MultiviewDirectionalShadowCascades) != 0); + if (HasPass(descriptor, RenderPassSemantic.VolumetricShafts)) + { + volumetric = new VolumetricShaftRenderer( + device, + descriptor, + assets, + preset, + userSettingOverrides); + } + _directionalShadows = directionalShadows; + directionalShadows = null; + _volumetric = volumetric; + volumetric = null; + _hdrPipelineLease = lease; + lease = null; + } + catch + { + volumetric?.Dispose(); + directionalShadows?.Dispose(); + for (int i = created.Count - 1; i >= 0; i--) + created[i].Dispose(); + lease?.Dispose(); + throw; + } + } + + public RenderPackDescriptor Descriptor { get; } + + public RenderQualityPreset Preset { get; } + + internal int ResourceGeneration { get; private set; } + + internal AtmosphericPostProcessSettings Settings => _settings; + + internal VolumetricShaftQuality? VolumetricQuality => _volumetric?.Quality; + + public IDirectionalShadowReceiverSource DirectionalShadowReceivers => + _directionalShadows; + + public DirectionalSunShadowDiagnostics RenderDirectionalShadows( + IGpuFrame frame, + in RenderFrameFoundation foundation, + in WorldRenderFrame world, + int activeDayGroup, + in RenderSceneQuery scene, + WbDrawDispatcher worldMeshes, + TerrainModernRenderer terrain) + { + ObjectDisposedException.ThrowIf(_disposed, this); + bool measureCpuStages = _cpuStageProfiler is not null + && AtmosphericCpuStageProfiler.ShouldMeasure(frame.Serial); + long stageStarted = measureCpuStages ? Stopwatch.GetTimestamp() : 0L; + _shadowCasters.Build(in scene); + long casterBuildFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L; + AuthoredCelestialShadowSource source = world.CelestialShadowSource; + var environment = new DirectionalShadowEnvironmentInput( + PackEnabled: true, + PortalOrLoginCoverVisible: foundation.PortalViewportVisible, + PlayerInsideCell: world.Roots.PlayerInsideCell + || world.Roots.CameraInsideCell, + source, + foundation.Atmosphere, + ActiveDayGroupMultiplier: Math.Clamp( + EvaluateDayGroupPolicy(activeDayGroup) + * RenderPackAtmospherePolicyEvaluation.DirectionalShadowFromSin( + Descriptor.AtmospherePolicy! + .DirectionalShadowLightElevationResponse, + source.ElevationSin) + * _shadowStrength, + 0f, + 1f)); + var input = new DirectionalSunShadowRenderInput( + environment, + world.Camera.Camera.View, + world.Camera.Projection, + _shadowCasters, + ResidentMaximumReachMeters: + world.ResidentStreamingWindow.MaximumReachMeters, + MeasureGpuTimers: AtmosphericGpuTimerSampling.ShouldMeasure( + Preset.Semantic, + frame.Serial), + MeasureCpuStages: measureCpuStages); + long environmentFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L; + _lastShadowCasterCount = _shadowCasters.Stats.Accepted; + _lastShadowClassificationCalls = _shadowCasters.Stats.TopologyRebuilt ? 1 : 0; + _lastShadowDiagnostics = _directionalShadows.Render( + frame, + in input, + worldMeshes, + terrain); + _lastShadowWorldMeshes = worldMeshes; + if (measureCpuStages) + { + DirectionalSunShadowCpuStageTicks shadow = _lastShadowDiagnostics.CpuStages; + _cpuStageFrame = new AtmosphericCpuStageFrame( + frame.Serial, + casterBuildFinished - stageStarted, + checked(environmentFinished - casterBuildFinished + + shadow.EnvironmentGateTicks), + shadow.PreparedDrawsAndTransformsTicks, + shadow.FitAndUniformTicks, + shadow.LayeredPassRecordingTicks, + shadow.BookkeepingTicks, + 0L, + 0L, + 0L); + } + else + { + _cpuStageFrame = default; + } + RequireRetainedGpuBudget(); + _lastShadowFrameSerial = frame.Serial; + return _lastShadowDiagnostics; + } + + public IGpuRenderTarget PrepareWorldTarget( + int width, + int height, + int sampleCount) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleCount); + if (_targets is { } current + && current.Width == width + && current.Height == height + && current.SampleCount == sampleCount) + return current.World; + + RenderPackHostCapabilities capabilities = + RenderPackCapabilityResolver.Resolve(_device.Capabilities); + RenderPackResourceBudgetPlanner.RequireWithinHost( + Descriptor, + Preset, + width, + height, + sampleCount, + capabilities); + TargetSet candidate = TargetSet.Create( + _device, + width, + height, + sampleCount, + PostScale(Descriptor, Preset), + RayScale(Descriptor, Preset), + allocateBloomIntermediates: !_fuseLowPostProcess, + _linearSampler, + _nearestSampler); + try + { + _volumetric?.PrepareTarget(width, height); + } + catch + { + candidate.Dispose(); + throw; + } + TargetSet? previous = _targets; + _targets = candidate; + _residentGpuBudgetBytes = Math.Min( + Preset.MaxResidentGpuBytes, + capabilities.MaxPackResidentBytes); + ResourceGeneration = checked(ResourceGeneration + 1); + _cpuStageProfiler?.Reset(); + previous?.Dispose(); + return candidate.World; + } + + public void RenderPostProcess( + IGpuFrame frame, + in AtmosphericFrameInputs inputs) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(frame); + TargetSet targets = _targets + ?? throw new InvalidOperationException( + "PrepareWorldTarget must succeed before post-processing begins."); + if (inputs.ViewportWidth != targets.Width + || inputs.ViewportHeight != targets.Height) + { + throw new InvalidOperationException( + "Atmospheric inputs and target extent belong to different frames."); + } + if (_lastShadowFrameSerial != frame.Serial) + { + _lastShadowDiagnostics = default; + _lastShadowCasterCount = 0; + _lastShadowClassificationCalls = 0; + } + + bool measureCpuStages = _cpuStageProfiler is not null + && _cpuStageFrame.FrameSerial == frame.Serial; + long postStarted = measureCpuStages ? Stopwatch.GetTimestamp() : 0L; + + float sunPolicy = EvaluateSunPolicy(inputs); + float elevationPolicy = EvaluateSunElevationPolicy(inputs.SunElevationDegrees); + float dayGroupPolicy = EvaluateDayGroupPolicy(inputs.ActiveDayGroup); + float shadowElevationPolicy = RenderPackAtmospherePolicyEvaluation + .DirectionalShadow( + Descriptor.AtmospherePolicy?.DirectionalShadowLightElevationResponse, + inputs.SunElevationDegrees, + elevationPolicy); + float volumetricElevationPolicy = RenderPackAtmospherePolicyEvaluation + .VolumetricShaft( + Descriptor.AtmospherePolicy?.VolumetricShaftSunElevationResponse, + inputs.SunElevationDegrees); + float rayStrength = inputs.SunIsOnScreen && inputs.IsOutdoor + ? Math.Clamp(_settings.SunRayStrength * sunPolicy, 0f, 4f) + : 0f; + var frameUniforms = new AtmosphericFrameUniforms( + new Vector4( + inputs.SunScreenUv, + rayStrength, + inputs.SunElevationDegrees), + new Vector4(inputs.SunColor, sunPolicy), + new Vector4( + targets.Width, + targets.Height, + 1f / targets.Width, + 1f / targets.Height), + new Vector4( + (float)inputs.Weather, + inputs.WeatherIntensity, + (float)Math.Clamp(inputs.DeltaSeconds, 0d, 1d), + inputs.IsOutdoor ? 1f : 0f), + new Vector4(inputs.SunDirection, inputs.SunDirectionalBrightness), + new Vector4( + inputs.ActiveDayGroup, + dayGroupPolicy, + shadowElevationPolicy, + volumetricElevationPolicy), + inputs.InverseViewProjection); + GpuRingAllocation frameBlock; + GpuRingAllocation settingsBlock; + GpuRingAllocation fusedSunPassBlock = default; + GpuRingAllocation fusedFilmicPassBlock = default; + if (_fuseLowPostProcess) + { + int alignment = checked((int)Math.Max( + 1u, + _device.Capabilities.MinUniformBufferOffsetAlignment)); + int settingsOffset = AlignUp( + AtmosphericFrameUniforms.SizeInBytes, + alignment); + int sunPassOffset = AlignUp( + checked(settingsOffset + PackSettingsUniforms.SizeInBytes), + alignment); + int filmicPassOffset = AlignUp( + checked(sunPassOffset + AtmosphericPackPassUniforms.SizeInBytes), + alignment); + GpuRingAllocation uniforms = frame.AllocateRing( + checked(filmicPassOffset + AtmosphericPackPassUniforms.SizeInBytes), + GpuRingUsage.Uniform); + frameBlock = Slice( + uniforms, + offsetBytes: 0, + AtmosphericFrameUniforms.SizeInBytes); + settingsBlock = Slice( + uniforms, + settingsOffset, + PackSettingsUniforms.SizeInBytes); + fusedSunPassBlock = Slice( + uniforms, + sunPassOffset, + AtmosphericPackPassUniforms.SizeInBytes); + fusedFilmicPassBlock = Slice( + uniforms, + filmicPassOffset, + AtmosphericPackPassUniforms.SizeInBytes); + } + else + { + frameBlock = frame.AllocateRing( + AtmosphericFrameUniforms.SizeInBytes, + GpuRingUsage.Uniform); + settingsBlock = frame.AllocateRing( + PackSettingsUniforms.SizeInBytes, + GpuRingUsage.Uniform); + } + MemoryMarshal.Write(frameBlock.Data, in frameUniforms); + PackSettingsUniforms packSettings = _packSettings; + MemoryMarshal.Write(settingsBlock.Data, in packSettings); + bool measureGpuTimers = AtmosphericGpuTimerSampling.ShouldMeasure( + Preset.Semantic, + frame.Serial); + + long sunRaysStarted = measureCpuStages ? Stopwatch.GetTimestamp() : 0L; + if (!_fuseLowPostProcess) + { + DrawFullscreen( + frame, + "atmospheric-sun-occlusion", + targets.SunMask, + _sunOcclusion, + targets.WorldDepthSlot, + GpuTextureSlot.Unassigned, + AtmosphericPackPassUniforms.From(Vector4.Zero), + frameBlock, + settingsBlock, + GpuTextureSlot.Unassigned, + GpuTextureSlot.Unassigned, + measureGpuTimers); + } + var sunPassUniforms = new AtmosphericPackPassUniforms( + new Vector4(0.965f, 0.24f, 0.82f, 48f), + _fuseLowPostProcess + ? new Vector4( + 1f, + targets.SunRays.Description.Width, + targets.SunRays.Description.Height, + 0f) + : Vector4.Zero, + Vector4.Zero, + Vector4.Zero); + if (_fuseLowPostProcess) + { + DrawFullscreenPrepared( + frame, + "atmospheric-sun-rays", + targets.SunRays, + _sunRays, + targets.WorldDepthSlot, + GpuTextureSlot.Unassigned, + in sunPassUniforms, + frameBlock, + settingsBlock, + fusedSunPassBlock, + GpuTextureSlot.Unassigned, + GpuTextureSlot.Unassigned, + measureGpuTimers); + } + else + { + DrawFullscreen( + frame, + "atmospheric-sun-rays", + targets.SunRays, + _sunRays, + targets.SunMaskSlot, + GpuTextureSlot.Unassigned, + in sunPassUniforms, + frameBlock, + settingsBlock, + GpuTextureSlot.Unassigned, + GpuTextureSlot.Unassigned, + measureGpuTimers); + } + long sunRaysFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L; + DirectionalShadowFrameBinding shadowBinding = + _directionalShadows.TryGetCurrentFrameBinding(frame, out var currentShadow) + ? currentShadow + : DirectionalShadowFrameBinding.Disabled; + VolumetricShaftOutput volumetric = _volumetric is null + ? default + : _volumetric.Render( + frame, + in inputs, + in shadowBinding, + targets.WorldDepthSlot); + if (!_fuseLowPostProcess) + { + DrawFullscreen( + frame, + "atmospheric-bloom-downsample", + targets.BloomA, + _bloomDownsample, + targets.WorldColorSlot, + targets.SunRaysSlot, + AtmosphericPackPassUniforms.From(new Vector4( + _settings.BloomStrength, + 1f, + 0.45f, + volumetric.HasTexture ? 1f : 0f)), + frameBlock, + settingsBlock, + volumetric.TextureSlot, + GpuTextureSlot.Unassigned, + measureGpuTimers); + DrawFullscreen( + frame, + "atmospheric-bloom-blur-horizontal", + targets.BloomB, + _bloomBlur, + targets.BloomASlot, + GpuTextureSlot.Unassigned, + AtmosphericPackPassUniforms.From(new Vector4( + 1f / targets.BloomA.Description.Width, + 0f, + 0f, + 0f)), + frameBlock, + settingsBlock, + GpuTextureSlot.Unassigned, + GpuTextureSlot.Unassigned, + measureGpuTimers); + DrawFullscreen( + frame, + "atmospheric-bloom-blur-vertical", + targets.BloomA, + _bloomBlur, + targets.BloomBSlot, + GpuTextureSlot.Unassigned, + AtmosphericPackPassUniforms.From(new Vector4( + 0f, + 1f / targets.BloomA.Description.Height, + 0f, + 0f)), + frameBlock, + settingsBlock, + GpuTextureSlot.Unassigned, + GpuTextureSlot.Unassigned, + measureGpuTimers); + } + var filmicPassUniforms = new AtmosphericPackPassUniforms( + new Vector4( + _settings.Exposure, + _settings.Saturation, + _settings.Contrast, + _settings.VignetteStrength), + new Vector4( + _settings.FilmicStrength, + volumetric.HasTexture ? 1f : 0f, + _fuseLowPostProcess ? 1f : 0f, + 0f), + _fuseLowPostProcess + ? new Vector4( + _settings.BloomStrength, + 1f, + 0.45f, + volumetric.HasTexture ? 1f : 0f) + : Vector4.Zero, + _fuseLowPostProcess + ? new Vector4( + 1f / targets.PostWidth, + 1f / targets.PostHeight, + 0f, + 0f) + : Vector4.Zero); + long filmicStarted = measureCpuStages ? Stopwatch.GetTimestamp() : 0L; + if (_fuseLowPostProcess) + { + DrawFullscreenPrepared( + frame, + "atmospheric-filmic", + target: null, + _filmic, + targets.WorldColorSlot, + targets.SunRaysSlot, + in filmicPassUniforms, + frameBlock, + settingsBlock, + fusedFilmicPassBlock, + volumetric.TextureSlot, + GpuTextureSlot.Unassigned, + measureGpuTimers); + } + else + { + DrawFullscreen( + frame, + "atmospheric-filmic", + target: null, + _filmic, + targets.WorldColorSlot, + targets.BloomASlot, + in filmicPassUniforms, + frameBlock, + settingsBlock, + targets.SunRaysSlot, + volumetric.TextureSlot, + measureGpuTimers); + } + long filmicFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L; + if (measureCpuStages) + { + _cpuStageFrame = _cpuStageFrame with + { + PostSetupAndOtherTicks = checked( + sunRaysStarted - postStarted + + filmicStarted - sunRaysFinished), + PostSunRaysTicks = sunRaysFinished - sunRaysStarted, + PostFilmicTicks = filmicFinished - filmicStarted, + }; + } + _lastInputs = inputs; + _renderedFrame = true; + } + + bool IAtmosphericCpuStageProfileRuntime.ShouldProfileCpuFrame(long frameSerial) => + _cpuStageProfiler is not null + && AtmosphericCpuStageProfiler.ShouldMeasure(frameSerial); + + void IAtmosphericCpuStageProfileRuntime.CompleteCpuProfile( + long frameSerial, + long targetPreparationTicks, + long measuredPackTotalTicks, + long observeBookkeepingTicks, + bool stableFrameBoundary) + { + if (!stableFrameBoundary + || _cpuStageProfiler is null + || _cpuStageFrame.FrameSerial != frameSerial) + { + return; + } + + _cpuStageProfiler.Observe( + in _cpuStageFrame, + targetPreparationTicks, + measuredPackTotalTicks, + observeBookkeepingTicks); + } + + public RenderPackRuntimeDiagnostics CaptureDiagnostics() + { + TargetSet? targets = _targets; + if (!_renderedFrame || targets is null) + return RenderPackRuntimeDiagnostics.Empty(Preset.Id); + VolumetricShaftDiagnostics volumetric = _volumetric?.LastDiagnostics ?? default; + (string Name, int DrawCalls)[] postPasses = + (_fuseLowPostProcess, _volumetric is null) switch + { + (true, true) => + [ + ("atmospheric-sun-rays", 1), + ("atmospheric-filmic", 1), + ], + (true, false) => + [ + ("atmospheric-sun-rays", 1), + (VolumetricShaftRenderer.TimerName, volumetric.DrawCalls), + ("atmospheric-filmic", 1), + ], + (false, true) => + [ + ("atmospheric-sun-occlusion", 1), + ("atmospheric-sun-rays", 1), + ("atmospheric-bloom-downsample", 1), + ("atmospheric-bloom-blur-horizontal", 1), + ("atmospheric-bloom-blur-vertical", 1), + ("atmospheric-filmic", 1), + ], + _ => + [ + ("atmospheric-sun-occlusion", 1), + ("atmospheric-sun-rays", 1), + (VolumetricShaftRenderer.TimerName, volumetric.DrawCalls), + ("atmospheric-bloom-downsample", 1), + ("atmospheric-bloom-blur-horizontal", 1), + ("atmospheric-bloom-blur-vertical", 1), + ("atmospheric-filmic", 1), + ], + }; + int shadowPassCount = _directionalShadows.MultiviewCascadesEnabled + && _lastShadowDiagnostics.CascadeCount > 0 + ? 1 + : _lastShadowDiagnostics.CascadeCount; + const int receiverPassCount = 1; + var passes = new RenderPackPassDiagnostics[ + receiverPassCount + postPasses.Length + shadowPassCount]; + _device.Timers.TryResolve( + RenderPackPerformanceScopeNames.EnhancedWorldReceiver, + out double receiverMilliseconds); + passes[0] = new RenderPackPassDiagnostics( + RenderPackPerformanceScopeNames.EnhancedWorldReceiver, + receiverMilliseconds, + DrawCalls: 0, + DispatchCalls: 0); + for (int i = 0; i < shadowPassCount; i++) + { + string name = _directionalShadows.MultiviewCascadesEnabled + ? DirectionalSunShadowRenderer.MultiviewTimerName + : DirectionalSunShadowRenderer.TimerName(i); + _device.Timers.TryResolve(name, out double milliseconds); + passes[receiverPassCount + i] = new RenderPackPassDiagnostics( + name, + milliseconds, + DrawCalls: shadowPassCount == 0 + ? 0 + : _lastShadowDiagnostics.DrawCalls / shadowPassCount, + DispatchCalls: 0); + } + for (int i = 0; i < postPasses.Length; i++) + { + (string name, int drawCalls) = postPasses[i]; + _device.Timers.TryResolve(name, out double milliseconds); + passes[receiverPassCount + shadowPassCount + i] = new RenderPackPassDiagnostics( + name, + milliseconds, + drawCalls, + DispatchCalls: 0); + } + return new RenderPackRuntimeDiagnostics( + Preset.Id, + checked( + targets.RetainedBytes + + _directionalShadows.Quality.ApproximateDepthMapBytes + + volumetric.RetainedGpuBytes + + _directionalShadows.RetainedGpuBufferBytes), + targets.TransientBytes, + targets.ImageCount + 1 + (volumetric.RetainedGpuBytes > 0 ? 1 : 0), + BufferCount: _directionalShadows.RetainedGpuBufferCount, + DrawCalls: postPasses.Sum(pass => pass.DrawCalls) + + _lastShadowDiagnostics.DrawCalls, + DispatchCalls: 0, + ShadowCasterCount: _lastShadowCasterCount, + CascadeDrawCount: _lastShadowDiagnostics.CascadeCount, + CpuClassificationCalls: _lastShadowClassificationCalls, + _lastInputs.SunElevationDegrees, + ActiveDayGroup: _lastInputs.ActiveDayGroup, + _lastInputs.Weather.ToString(), + _lastInputs.WeatherIntensity, + _lastInputs.IsOutdoor, + DirectionalShadowStrength: _lastShadowDiagnostics.Strength, + passes) + { + CpuStages = _cpuStageProfiler?.Snapshot() ?? [], + DirectionalShadowSourceKind = _lastShadowDiagnostics.SourceKind, + DirectionalShadowSourceObjectIndex = + _lastShadowDiagnostics.SourceObjectIndex, + DirectionalShadowSourceGfxObjId = + _lastShadowDiagnostics.SourceGfxObjId, + DirectionalShadowSurfaceToLightDirection = + _lastShadowDiagnostics.SurfaceToLightDirection, + DirectionalShadowLightElevationSin = + _lastShadowDiagnostics.LightElevationSin, + ShadowTransformChurn = _lastShadowDiagnostics.TransformChurn, + SharedWorldTransformUsedInstances = + _lastShadowWorldMeshes is not null + && _lastShadowWorldMeshes.HasDirectionalShadowTransformFrame( + _lastShadowFrameSerial) + ? _lastShadowWorldMeshes + .DirectionalShadowTransformFrameUsedInstances + : 0u, + }; + } + + public RenderPackRuntimePerformanceMetrics CapturePerformanceMetrics() + { + ObjectDisposedException.ThrowIf(_disposed, this); + TargetSet? targets = _targets; + if (targets is null) + { + return new RenderPackRuntimePerformanceMetrics( + ResourceGeneration, + HasResolvedGpuMeasurement: false, + InclusiveResolvedGpuMilliseconds: 0d, + RetainedGpuBytes: _directionalShadows.Quality.ApproximateDepthMapBytes, + TransientGpuBytes: 0L); + } + + double gpuMilliseconds = 0d; + bool resolved = true; + resolved &= TryAddResolvedTimer( + RenderPackPerformanceScopeNames.EnhancedWorldReceiver, + ref gpuMilliseconds); + if (!_fuseLowPostProcess) + { + resolved &= TryAddResolvedTimer( + "atmospheric-sun-occlusion", + ref gpuMilliseconds); + } + resolved &= TryAddResolvedTimer("atmospheric-sun-rays", ref gpuMilliseconds); + if (!_fuseLowPostProcess) + { + resolved &= TryAddResolvedTimer( + "atmospheric-bloom-downsample", + ref gpuMilliseconds); + resolved &= TryAddResolvedTimer( + "atmospheric-bloom-blur-horizontal", + ref gpuMilliseconds); + resolved &= TryAddResolvedTimer( + "atmospheric-bloom-blur-vertical", + ref gpuMilliseconds); + } + resolved &= TryAddResolvedTimer("atmospheric-filmic", ref gpuMilliseconds); + int shadowTimerCount = _directionalShadows.MultiviewCascadesEnabled + && _lastShadowDiagnostics.CascadeCount > 0 + ? 1 + : _lastShadowDiagnostics.CascadeCount; + for (int i = 0; i < shadowTimerCount; i++) + { + resolved &= TryAddResolvedTimer( + _directionalShadows.MultiviewCascadesEnabled + ? DirectionalSunShadowRenderer.MultiviewTimerName + : DirectionalSunShadowRenderer.TimerName(i), + ref gpuMilliseconds); + } + + VolumetricShaftDiagnostics volumetric = _volumetric?.LastDiagnostics ?? default; + if (volumetric.DrawCalls > 0) + { + resolved &= TryAddResolvedTimer( + VolumetricShaftRenderer.TimerName, + ref gpuMilliseconds); + } + + return new RenderPackRuntimePerformanceMetrics( + ResourceGeneration, + resolved, + resolved ? gpuMilliseconds : 0d, + checked( + targets.RetainedBytes + + _directionalShadows.Quality.ApproximateDepthMapBytes + + volumetric.RetainedGpuBytes + + _directionalShadows.RetainedGpuBufferBytes), + targets.TransientBytes); + } + + private void RequireRetainedGpuBudget() + { + TargetSet? targets = _targets; + if (targets is null) + return; + long total = checked( + targets.RetainedBytes + + _directionalShadows.Quality.ApproximateDepthMapBytes + + (_volumetric?.LastDiagnostics.RetainedGpuBytes ?? 0L) + + _directionalShadows.RetainedGpuBufferBytes); + if (total <= _residentGpuBudgetBytes) + return; + throw new NotSupportedException( + $"Render pack preset '{Preset.Id}' needs {total} resident GPU bytes " + + "after materializing its scene-dependent shadow command buffers; " + + $"the active pack budget is {_residentGpuBudgetBytes} bytes."); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _targets?.Dispose(); + _targets = null; + _volumetric?.Dispose(); + _directionalShadows.Dispose(); + _filmic.Dispose(); + _bloomBlur.Dispose(); + _bloomDownsample.Dispose(); + _sunRays.Dispose(); + _sunOcclusion.Dispose(); + _hdrPipelineLease.Dispose(); + } + + internal float EvaluateSunPolicy(in AtmosphericFrameInputs inputs) + { + if (!inputs.IsOutdoor || !inputs.SunIsOnScreen) + return 0f; + return Math.Clamp( + EvaluateSunElevationPolicy(inputs.SunElevationDegrees) + * EvaluateDayGroupPolicy(inputs.ActiveDayGroup) + * EvaluateWeatherPolicy(inputs.Weather, inputs.WeatherIntensity), + 0f, + 4f); + } + + internal float EvaluateDirectionalShadowStrength( + float sunElevationDegrees, + int activeDayGroup) => Math.Clamp( + RenderPackAtmospherePolicyEvaluation.DirectionalShadow( + Descriptor.AtmospherePolicy?.DirectionalShadowLightElevationResponse, + sunElevationDegrees) + * EvaluateDayGroupPolicy(activeDayGroup) + * _shadowStrength, + 0f, + 1f); + + private static float EvaluateWeatherPolicy( + AcDream.Core.World.WeatherKind weather, + float intensity) + { + float weatherTarget = weather switch + { + AcDream.Core.World.WeatherKind.Clear => 1f, + AcDream.Core.World.WeatherKind.Overcast => 0.18f, + AcDream.Core.World.WeatherKind.Rain => 0.10f, + AcDream.Core.World.WeatherKind.Snow => 0.16f, + AcDream.Core.World.WeatherKind.Storm => 0.06f, + _ => 0f, + }; + return 1f + ((weatherTarget - 1f) * Math.Clamp(intensity, 0f, 1f)); + } + + private bool TryAddResolvedTimer(string name, ref double total) + { + if (!_device.Timers.TryTakeResolved(name, out double milliseconds)) + return false; + total += milliseconds; + return true; + } + + private float EvaluateSunElevationPolicy(float elevation) + { + IReadOnlyList? points = + Descriptor.AtmospherePolicy?.SunElevationResponse; + return RenderPackAtmospherePolicyEvaluation.Ray(points, elevation); + } + + private float EvaluateDayGroupPolicy(int activeDayGroup) + { + ActiveDayGroupMultiplier? value = Descriptor.AtmospherePolicy? + .ActiveDayGroupMultipliers + .FirstOrDefault(entry => entry.ActiveDayGroup == activeDayGroup); + return value is null ? 1f : (float)value.Multiplier; + } + + private static IGpuPipeline CreatePipeline( + IGpuDevice device, + string name, + GpuShaderSet shaders, + GpuTextureFormat colorFormat) => + device.CreatePipeline(new GpuPipelineDescription + { + Name = name, + Shaders = shaders, + VertexLayout = GpuVertexLayout.None, + Blend = GpuBlendMode.None, + Depth = GpuDepthState.Disabled, + Cull = GpuCullMode.None, + ColorFormat = colorFormat, + AllowColorFormatVariants = false, + SampleCount = 1, + UsesRenderPackShaderAbi = true, + }); + + private static bool HasPass( + RenderPackDescriptor descriptor, + RenderPassSemantic semantic) => + descriptor.Passes.Any(pass => pass.Semantic == semantic); + + private static GpuShaderSet ShaderSet( + RenderPackDescriptor descriptor, + ValidatedRenderPackShaderAssets assets, + RenderPassSemantic semantic) + { + RenderPassDeclaration pass = descriptor.Passes.FirstOrDefault(value => + value.Semantic == semantic) + ?? throw new InvalidOperationException( + $"Atmospheric graph requires declared pass semantic '{semantic}'."); + return RenderPackShaderAssets.LoadPass(descriptor, assets, pass); + } + + private static DirectionalShadowPipelineShaders LoadDirectionalShadowShaders( + RenderPackDescriptor descriptor, + ValidatedRenderPackShaderAssets assets) + { + DirectionalShadowPipelineShaders shaders = new( + Variant(RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster), + Variant(RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster), + Variant(RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster), + Variant(RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver), + Variant(RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver)); + if (descriptor.PipelineVariants.Any(value => + value.Semantic == RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster)) + { + shaders = shaders with + { + MultiviewCasters = new DirectionalShadowMultiviewPipelineShaders( + Variant(RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster), + Variant(RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster), + Variant(RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster)), + }; + } + return shaders; + + GpuShaderSet Variant(RenderPipelineVariantSemantic semantic) + { + PipelineVariantDeclaration variant = descriptor.PipelineVariants + .FirstOrDefault(value => value.Semantic == semantic) + ?? throw new InvalidOperationException( + $"Atmospheric graph requires declared pipeline-variant semantic '{semantic}'."); + return RenderPackShaderAssets.LoadVariant(descriptor, assets, variant); + } + } + + private static DirectionalShadowPreset ShadowPreset( + RenderQualityPreset preset) => preset.Semantic switch + { + RenderQualitySemantic.Low => DirectionalShadowPreset.Low, + RenderQualitySemantic.High => DirectionalShadowPreset.High, + _ => DirectionalShadowPreset.Medium, + }; + + private static DirectionalShadowQuality ResolveShadowQuality( + RenderPackDescriptor descriptor, + RenderQualityPreset preset, + IReadOnlyDictionary? userSettingOverrides) + { + DirectionalShadowQuality quality = DirectionalShadowQuality.For( + ShadowPreset(preset)); + RenderResourceDeclaration resource = descriptor.Resources.Single(value => + value.Semantic == RenderResourceSemantic.DirectionalShadowDepth); + RenderQualityResourceOverride? resourceOverride = preset.ResourceOverrides + .FirstOrDefault(value => string.Equals( + value.ResourceId, + resource.Id, + StringComparison.OrdinalIgnoreCase)); + RenderExtentDeclaration extent = resourceOverride?.Extent + ?? resource.Extent + ?? throw new NotSupportedException( + "The DirectionalShadowDepth semantic resource has no image extent."); + if (extent.Mode != RenderExtentMode.AbsolutePixels + || extent.Width != extent.Height + || extent.Width != Math.Truncate(extent.Width) + || extent.Width is < 1 or > 16_384 + || extent.Layers is < 1 or > 4) + { + throw new NotSupportedException( + "The DirectionalShadowDepth semantic resource must be a square " + + "absolute 1..16384 image with 1..4 array layers."); + } + + float reach = ReadSemanticSetting( + descriptor, + preset, + userSettingOverrides, + RenderSettingSemantic.DirectionalShadowReachMetres, + quality.MaximumReachMeters); + int taps = ReadShadowPcfTaps( + descriptor, + preset, + userSettingOverrides, + quality.PcfRadiusTexels switch + { + 0 => 1, + 1 => 9, + _ => 25, + }); + int radius = taps switch + { + 1 => 0, + 9 => 1, + 25 => 2, + _ => throw new NotSupportedException( + "DirectionalShadowPcfTaps must resolve to exactly 1, 9, or 25 samples."), + }; + int resolution = checked((int)extent.Width); + int cascades = extent.Layers; + return quality with + { + CascadeCount = cascades, + MapResolution = resolution, + MaximumReachMeters = Math.Clamp(reach, 1f, 10_000f), + PcfRadiusTexels = radius, + ApproximateDepthMapBytes = checked( + (long)cascades * resolution * resolution * sizeof(float)), + IncrementalGpuP50BudgetMilliseconds = preset.MaxIncrementalGpuMillisecondsP50, + IncrementalGpuP99BudgetMilliseconds = preset.MaxIncrementalGpuMillisecondsP99, + IncrementalCpuP50BudgetMilliseconds = preset.MaxIncrementalCpuMillisecondsP50, + IncrementalCpuP99BudgetMilliseconds = preset.MaxIncrementalCpuMillisecondsP99, + PackResidentGpuByteBudget = preset.MaxResidentGpuBytes, + }; + } + + private static int ReadShadowPcfTaps( + RenderPackDescriptor descriptor, + RenderQualityPreset preset, + IReadOnlyDictionary? userSettingOverrides, + int fallback) + { + RenderSettingDeclaration? setting = descriptor.Settings.FirstOrDefault(value => + value.Semantic == RenderSettingSemantic.DirectionalShadowPcfTaps); + if (setting is null) + return fallback; + + string value = RenderPackSettingResolution.Resolve( + setting, + preset, + userSettingOverrides); + return int.TryParse( + value, + System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, + out int taps) + ? taps + : throw new NotSupportedException( + "DirectionalShadowPcfTaps must resolve to an integer sample count."); + } + + private static float ReadSemanticSetting( + RenderPackDescriptor descriptor, + RenderQualityPreset preset, + IReadOnlyDictionary? userSettingOverrides, + RenderSettingSemantic semantic, + float fallback) + { + RenderSettingDeclaration? setting = descriptor.Settings.FirstOrDefault(value => + value.Semantic == semantic); + if (setting is null) + return fallback; + string value = RenderPackSettingResolution.Resolve( + setting, + preset, + userSettingOverrides); + return RenderPackSettingValueCodec.TryEncode(setting, value, out float encoded) + && float.IsFinite(encoded) + ? encoded + : fallback; + } + + private static void DrawFullscreen( + IGpuFrame frame, + string name, + IGpuRenderTarget? target, + IGpuPipeline pipeline, + GpuTextureSlot textureA, + GpuTextureSlot textureB, + in AtmosphericPackPassUniforms passUniforms, + GpuRingAllocation frameBlock, + GpuRingAllocation settingsBlock, + GpuTextureSlot textureC, + GpuTextureSlot textureD, + bool measureGpuTimers) + { + GpuRingAllocation passBlock = frame.AllocateRing( + AtmosphericPackPassUniforms.SizeInBytes, + GpuRingUsage.Uniform); + DrawFullscreenPrepared( + frame, + name, + target, + pipeline, + textureA, + textureB, + in passUniforms, + frameBlock, + settingsBlock, + passBlock, + textureC, + textureD, + measureGpuTimers); + } + + private static void DrawFullscreenPrepared( + IGpuFrame frame, + string name, + IGpuRenderTarget? target, + IGpuPipeline pipeline, + GpuTextureSlot textureA, + GpuTextureSlot textureB, + in AtmosphericPackPassUniforms passUniforms, + GpuRingAllocation frameBlock, + GpuRingAllocation settingsBlock, + GpuRingAllocation passBlock, + GpuTextureSlot textureC, + GpuTextureSlot textureD, + bool measureGpuTimers) + { + using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription + { + Name = name, + Color = new GpuColorAttachment( + target, + GpuLoadOp.Clear, + GpuStoreOp.Store, + Vector4.Zero), + Depth = null, + SampleCount = 1, + }); + using IDisposable? timer = measureGpuTimers + ? encoder.BeginTimerScope(name) + : null; + encoder.BindPipeline(pipeline); + encoder.BindUniformBuffer( + GpuBindingModel.UniformAtmosphericFrame, + frameBlock.Buffer, + frameBlock.OffsetBytes, + AtmosphericFrameUniforms.SizeInBytes); + MemoryMarshal.Write(passBlock.Data, in passUniforms); + encoder.BindUniformBuffer( + GpuBindingModel.UniformPackPass, + passBlock.Buffer, + passBlock.OffsetBytes, + AtmosphericPackPassUniforms.SizeInBytes); + encoder.BindUniformBuffer( + GpuBindingModel.UniformPackSettings, + settingsBlock.Buffer, + settingsBlock.OffsetBytes, + PackSettingsUniforms.SizeInBytes); + GpuPushConstants constants = GpuPushConstants.Default; + constants.TextureIndexA = textureA.IsAssigned + ? textureA.Index + : GpuTextureSlot.Unassigned.Index; + constants.TextureIndexB = textureB.IsAssigned + ? textureB.Index + : GpuTextureSlot.Unassigned.Index; + constants.ParamA = BitConverter.UInt32BitsToSingle( + textureC.IsAssigned ? textureC.Index : GpuTextureSlot.Unassigned.Index); + constants.ParamB = BitConverter.UInt32BitsToSingle( + textureD.IsAssigned ? textureD.Index : GpuTextureSlot.Unassigned.Index); + encoder.SetPushConstants(in constants); + encoder.Draw(3, 1, 0, 0); + } + + private static GpuRingAllocation Slice( + GpuRingAllocation allocation, + int offsetBytes, + int sizeBytes) => new( + allocation.Buffer, + checked(allocation.OffsetBytes + (uint)offsetBytes), + allocation.Data.Slice(offsetBytes, sizeBytes)); + + private static int AlignUp(int value, int alignment) + { + int remainder = value % alignment; + return remainder == 0 + ? value + : checked(value + alignment - remainder); + } + + private static float PostScale( + RenderPackDescriptor descriptor, + RenderQualityPreset preset) + { + RenderQualityResourceOverride? value = preset.ResourceOverrides + .FirstOrDefault(overrideValue => + ResourceSemantic( + descriptor, + overrideValue, + RenderResourceSemantic.BloomPing)); + if (value?.Extent is { } extent + && extent.Mode == RenderExtentMode.RelativeToMainWorld) + return (float)Math.Clamp(extent.Width, 0.125, 1.0); + return preset.Semantic == RenderQualitySemantic.Low + ? 0.25f + : 0.5f; + } + + private static float RayScale( + RenderPackDescriptor descriptor, + RenderQualityPreset preset) + { + RenderQualityResourceOverride? value = preset.ResourceOverrides + .FirstOrDefault(overrideValue => + ResourceSemantic( + descriptor, + overrideValue, + RenderResourceSemantic.SunRays)); + if (value?.Extent is { } extent + && extent.Mode == RenderExtentMode.RelativeToMainWorld) + return (float)Math.Clamp(extent.Width, 0.125, 1.0); + return preset.Semantic == RenderQualitySemantic.Low + ? 0.25f + : 0.5f; + } + + private static bool ResourceSemantic( + RenderPackDescriptor descriptor, + RenderQualityResourceOverride value, + RenderResourceSemantic semantic) => + descriptor.Resources.FirstOrDefault(resource => string.Equals( + resource.Id, + value.ResourceId, + StringComparison.OrdinalIgnoreCase))?.Semantic == semantic; + + private sealed class TargetSet : IDisposable + { + private readonly IGpuDevice _device; + private readonly GpuTextureSlot[] _slots; + private bool _disposed; + + private TargetSet( + IGpuDevice device, + int width, + int height, + int sampleCount, + IGpuRenderTarget world, + IGpuRenderTarget? bloomA, + IGpuRenderTarget? bloomB, + IGpuRenderTarget sunMask, + IGpuRenderTarget sunRays, + int postWidth, + int postHeight, + GpuTextureSlot worldColorSlot, + GpuTextureSlot worldDepthSlot, + GpuTextureSlot bloomASlot, + GpuTextureSlot bloomBSlot, + GpuTextureSlot sunMaskSlot, + GpuTextureSlot sunRaysSlot) + { + _device = device; + Width = width; + Height = height; + SampleCount = sampleCount; + World = world; + BloomAOrNull = bloomA; + BloomBOrNull = bloomB; + SunMask = sunMask; + SunRays = sunRays; + PostWidth = postWidth; + PostHeight = postHeight; + WorldColorSlot = worldColorSlot; + WorldDepthSlot = worldDepthSlot; + BloomASlot = bloomASlot; + BloomBSlot = bloomBSlot; + SunMaskSlot = sunMaskSlot; + SunRaysSlot = sunRaysSlot; + _slots = bloomA is null + ? [worldColorSlot, worldDepthSlot, sunMaskSlot, sunRaysSlot] + : [worldColorSlot, worldDepthSlot, bloomASlot, bloomBSlot, + sunMaskSlot, sunRaysSlot]; + } + + internal int Width { get; } + internal int Height { get; } + internal int SampleCount { get; } + internal IGpuRenderTarget World { get; } + private IGpuRenderTarget? BloomAOrNull { get; } + private IGpuRenderTarget? BloomBOrNull { get; } + internal IGpuRenderTarget BloomA => BloomAOrNull + ?? throw new InvalidOperationException( + "The fused Low graph has no bloom ping intermediate."); + internal IGpuRenderTarget BloomB => BloomBOrNull + ?? throw new InvalidOperationException( + "The fused Low graph has no bloom pong intermediate."); + internal IGpuRenderTarget SunMask { get; } + internal IGpuRenderTarget SunRays { get; } + internal int PostWidth { get; } + internal int PostHeight { get; } + internal GpuTextureSlot WorldColorSlot { get; } + internal GpuTextureSlot WorldDepthSlot { get; } + internal GpuTextureSlot BloomASlot { get; } + internal GpuTextureSlot BloomBSlot { get; } + internal GpuTextureSlot SunMaskSlot { get; } + internal GpuTextureSlot SunRaysSlot { get; } + internal long RetainedBytes => + checked( + (long)Width * Height * 12L + + (BloomAOrNull is null + ? 0L + : (long)PostWidth * PostHeight * 16L) + + ((long)SunMask.Description.Width * SunMask.Description.Height * 4L) + + ((long)SunRays.Description.Width * SunRays.Description.Height * 8L)); + internal long TransientBytes => SampleCount > 1 + ? checked((long)Width * Height * 12L * SampleCount) + : 0L; + internal int ImageCount => (BloomAOrNull is null ? 4 : 6) + + (SampleCount > 1 ? 2 : 0); + + internal static TargetSet Create( + IGpuDevice device, + int width, + int height, + int sampleCount, + float postScale, + float rayScale, + bool allocateBloomIntermediates, + IGpuSampler linear, + IGpuSampler nearest) + { + var targets = new List(capacity: 5); + var slots = new List(capacity: 6); + try + { + IGpuRenderTarget world = CreateTarget( + device, + "atmospheric-world-hdr", + width, + height, + GpuTextureFormat.Rgba16FloatRenderTarget, + GpuTextureFormat.Depth24Stencil8, + sampleCount, + sampleableDepth: true); + targets.Add(world); + int postWidth = Math.Max(1, (int)MathF.Ceiling(width * postScale)); + int postHeight = Math.Max(1, (int)MathF.Ceiling(height * postScale)); + int rayWidth = Math.Max(1, (int)MathF.Ceiling(width * rayScale)); + int rayHeight = Math.Max(1, (int)MathF.Ceiling(height * rayScale)); + IGpuRenderTarget? bloomA = null; + IGpuRenderTarget? bloomB = null; + if (allocateBloomIntermediates) + { + bloomA = CreateTarget( + device, "atmospheric-bloom-a", postWidth, postHeight, + GpuTextureFormat.Rgba16FloatRenderTarget, null, 1, false); + targets.Add(bloomA); + bloomB = CreateTarget( + device, "atmospheric-bloom-b", postWidth, postHeight, + GpuTextureFormat.Rgba16FloatRenderTarget, null, 1, false); + targets.Add(bloomB); + } + IGpuRenderTarget sunMask = CreateTarget( + device, "atmospheric-sun-mask", rayWidth, rayHeight, + GpuTextureFormat.Rgba8UnormRenderTarget, null, 1, false); + targets.Add(sunMask); + IGpuRenderTarget sunRays = CreateTarget( + device, "atmospheric-sun-rays", rayWidth, rayHeight, + GpuTextureFormat.Rgba16FloatRenderTarget, null, 1, false); + targets.Add(sunRays); + + GpuTextureSlot worldColor = Register(device, world.ColorTexture, linear, slots); + GpuTextureSlot worldDepth = Register( + device, + world.DepthTexture + ?? throw new InvalidOperationException("The HDR world target exposed no sampled depth."), + nearest, + slots); + GpuTextureSlot bloomASlot = bloomA is null + ? GpuTextureSlot.Unassigned + : Register(device, bloomA.ColorTexture, linear, slots); + GpuTextureSlot bloomBSlot = bloomB is null + ? GpuTextureSlot.Unassigned + : Register(device, bloomB.ColorTexture, linear, slots); + GpuTextureSlot sunMaskSlot = Register(device, sunMask.ColorTexture, linear, slots); + GpuTextureSlot sunRaysSlot = Register(device, sunRays.ColorTexture, linear, slots); + return new TargetSet( + device, + width, + height, + sampleCount, + world, + bloomA, + bloomB, + sunMask, + sunRays, + postWidth, + postHeight, + worldColor, + worldDepth, + bloomASlot, + bloomBSlot, + sunMaskSlot, + sunRaysSlot); + } + catch + { + for (int i = slots.Count - 1; i >= 0; i--) + device.ReleaseTextureSlot(slots[i]); + for (int i = targets.Count - 1; i >= 0; i--) + targets[i].Dispose(); + throw; + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + for (int i = _slots.Length - 1; i >= 0; i--) + _device.ReleaseTextureSlot(_slots[i]); + SunRays.Dispose(); + SunMask.Dispose(); + BloomBOrNull?.Dispose(); + BloomAOrNull?.Dispose(); + World.Dispose(); + } + + private static IGpuRenderTarget CreateTarget( + IGpuDevice device, + string name, + int width, + int height, + GpuTextureFormat color, + GpuTextureFormat? depth, + int samples, + bool sampleableDepth) => + device.CreateRenderTarget(new GpuRenderTargetDescription( + name, + width, + height, + color, + depth, + samples, + sampleableDepth)); + + private static GpuTextureSlot Register( + IGpuDevice device, + IGpuTexture texture, + IGpuSampler sampler, + List slots) + { + GpuTextureSlot slot = device.RegisterTexture(texture, sampler); + slots.Add(slot); + return slot; + } + } +} + +internal sealed class AtmosphericRenderPackRuntimeFactory(IGpuDevice device) : + IRenderPackRuntimeFactory +{ + private readonly IGpuDevice _device = device + ?? throw new ArgumentNullException(nameof(device)); + + public IRenderPackRuntime Build( + RenderPackDescriptor descriptor, + IRenderPackAssets assets, + RenderQualityPreset preset, + IReadOnlyDictionary userSettingOverrides) => + Build( + descriptor, + RenderPackShaderAssets.Validate(descriptor, assets), + preset, + userSettingOverrides); + + public IRenderPackRuntime Build( + RenderPackDescriptor descriptor, + ValidatedRenderPackShaderAssets assets, + RenderQualityPreset preset, + IReadOnlyDictionary userSettingOverrides) + { + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(assets); + ArgumentNullException.ThrowIfNull(preset); + ArgumentNullException.ThrowIfNull(userSettingOverrides); + if (descriptor.Passes.Count == 0) + { + if (descriptor.SceneReplays.Count != 0 || descriptor.PipelineVariants.Count != 0) + { + throw new NotSupportedException( + $"Pack '{descriptor.Id}' declares scene replay or pipeline variants without an executable pass."); + } + return new NoOpRenderPackRuntime(descriptor, preset); + } + + RenderPassSemantic[] atmosphericSemanticPasses = + [ + RenderPassSemantic.DirectionalShadowDepth, + RenderPassSemantic.SunOcclusion, + RenderPassSemantic.SunRays, + RenderPassSemantic.VolumetricShafts, + RenderPassSemantic.BloomDownsample, + RenderPassSemantic.BloomBlurHorizontal, + RenderPassSemantic.BloomBlurVertical, + RenderPassSemantic.FilmicComposite, + ]; + bool standardAtmosphericGraph = atmosphericSemanticPasses.All(required => + descriptor.Passes.Count(pass => pass.Semantic == required) == 1); + if (standardAtmosphericGraph) + { + return new AtmosphericPostProcessGraph( + _device, + descriptor, + assets, + preset, + userSettingOverrides: userSettingOverrides); + } + + bool declaredDirectionalShadowGraph = descriptor.Passes.Count(pass => + pass.Semantic == RenderPassSemantic.DirectionalShadowDepth) == 1 + && descriptor.Passes.All(pass => + pass.Semantic is RenderPassSemantic.CustomFullscreen + or RenderPassSemantic.DirectionalShadowDepth); + if (declaredDirectionalShadowGraph) + { + return new DeclaredDirectionalShadowRenderPackGraph( + _device, + descriptor, + assets, + preset, + userSettingOverrides); + } + + if (descriptor.SceneReplays.Count != 0 || descriptor.PipelineVariants.Count != 0) + { + throw new NotSupportedException( + $"Pack '{descriptor.Id}' uses scene replay or renderer-pipeline variants " + + "without a host semantic executor."); + } + if (descriptor.Passes.Any(pass => pass.Hook is + RenderPassHook.ShadowDepthBeforeWorld or + RenderPassHook.AfterToneMapBeforePrivateViewports)) + { + throw new NotSupportedException( + $"Pack '{descriptor.Id}' uses a pass hook outside the API-v1 Tier-1 fullscreen executor."); + } + return new DeclaredFullscreenRenderPackGraph( + _device, + descriptor, + assets, + preset, + userSettingOverrides); + } +} + +internal sealed class NoOpRenderPackRuntime( + RenderPackDescriptor descriptor, + RenderQualityPreset preset) : IDefaultWorldPathRenderPackRuntime +{ + public RenderPackDescriptor Descriptor { get; } = descriptor + ?? throw new ArgumentNullException(nameof(descriptor)); + + public RenderQualityPreset Preset { get; } = preset + ?? throw new ArgumentNullException(nameof(preset)); + + public void Dispose() + { + } +} diff --git a/src/AcDream.App/Rendering/Packs/AuthoredCelestialShadowSource.cs b/src/AcDream.App/Rendering/Packs/AuthoredCelestialShadowSource.cs new file mode 100644 index 00000000..819e4dbd --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/AuthoredCelestialShadowSource.cs @@ -0,0 +1,183 @@ +using System.Numerics; +using AcDream.Core.World; + +namespace AcDream.App.Rendering.Packs; + +/// +/// Pack-only identity for the one celestial direction selected to cast the +/// current directional shadow map. Retail exposes one authored directional +/// colour/energy channel; moon meshes contribute direction only. +/// +internal enum AuthoredCelestialShadowSourceKind : uint +{ + None = 0, + Sun = 1, + DominantMoon = 2, + SecondaryMoon = 3, +} + +internal readonly record struct AuthoredCelestialShadowSource( + AuthoredCelestialShadowSourceKind Kind, + int ObjectIndex, + uint GfxObjId, + Vector3 SurfaceToLightDirection, + float ElevationSin, + float AuthoredEnergy) +{ + internal static AuthoredCelestialShadowSource None(float authoredEnergy = 0f) => + new( + AuthoredCelestialShadowSourceKind.None, + -1, + 0u, + Vector3.UnitZ, + 0f, + Math.Clamp(authoredEnergy, 0f, 1f)); + + internal bool IsAvailable => + Kind is not AuthoredCelestialShadowSourceKind.None; +} + +/// +/// Resolves the visible Dereth sun/moons from retail DAT sky objects and uses +/// the identical transform as SkyRenderer. This is an opt-in render-pack +/// enhancement; it never changes retail SceneLighting or world state. +/// +internal static class AuthoredCelestialShadowSourceResolver +{ + internal const uint SunGfxObjId = 0x01001348u; + internal const uint DominantMoonGfxObjId = 0x01001F6Au; + internal const uint SecondaryMoonGfxObjId = 0x01001F67u; + + internal static AuthoredCelestialShadowSource Resolve( + DayGroupData? dayGroup, + float dayFraction, + in SkyKeyframe sky) + { + float energy = Math.Clamp( + MathF.Max(sky.SunColor.X, MathF.Max(sky.SunColor.Y, sky.SunColor.Z)), + 0f, + 1f); + if (dayGroup is null || !float.IsFinite(dayFraction)) + return AuthoredCelestialShadowSource.None(energy); + + if (TryResolve( + dayGroup, + dayFraction, + SunGfxObjId, + AuthoredCelestialShadowSourceKind.Sun, + energy, + out var source) + || TryResolve( + dayGroup, + dayFraction, + DominantMoonGfxObjId, + AuthoredCelestialShadowSourceKind.DominantMoon, + energy, + out source) + || TryResolve( + dayGroup, + dayFraction, + SecondaryMoonGfxObjId, + AuthoredCelestialShadowSourceKind.SecondaryMoon, + energy, + out source)) + { + return source; + } + + return AuthoredCelestialShadowSource.None(energy); + } + + private static bool TryResolve( + DayGroupData dayGroup, + float dayFraction, + uint roleGfxObjId, + AuthoredCelestialShadowSourceKind kind, + float energy, + out AuthoredCelestialShadowSource source) + { + for (int index = 0; index < dayGroup.SkyObjects.Count; index++) + { + SkyObjectData skyObject = dayGroup.SkyObjects[index]; + if (skyObject.GfxObjId != roleGfxObjId + || !skyObject.IsVisible(dayFraction)) + { + continue; + } + + SkyObjectReplaceData? replace = ActiveReplace( + dayGroup, + dayFraction, + checked((uint)index)); + if (replace is not null && replace.Transparent >= 1f - 1e-5f) + continue; + + uint effectiveGfxObjId = replace is { GfxObjId: not 0u } + ? replace.GfxObjId + : skyObject.GfxObjId; + Vector3 anchor = replace is { GfxObjId: not 0u } + ? replace.AuthoredSortCenter + : skyObject.AuthoredSortCenter; + if (!IsFiniteDirection(anchor)) + continue; + + float headingRadians = (replace?.Rotate ?? 0f) * (MathF.PI / 180f); + float rotationRadians = skyObject.CurrentAngle(dayFraction) + * (MathF.PI / 180f); + Matrix4x4 model = Matrix4x4.CreateRotationZ(-headingRadians) + * Matrix4x4.CreateRotationY(-rotationRadians); + Vector3 transformed = Vector3.TransformNormal(anchor, model); + float length = transformed.Length(); + if (!float.IsFinite(length) || length <= 1e-5f) + continue; + + Vector3 direction = transformed / length; + if (!IsFiniteDirection(direction) || direction.Z <= 0f) + continue; + + source = new AuthoredCelestialShadowSource( + kind, + index, + effectiveGfxObjId, + direction, + direction.Z, + energy); + return true; + } + + source = default; + return false; + } + + private static SkyObjectReplaceData? ActiveReplace( + DayGroupData dayGroup, + float dayFraction, + uint objectIndex) + { + if (dayGroup.SkyTimes.Count == 0) + return null; + + DatSkyKeyframeData active = dayGroup.SkyTimes[^1]; + for (int i = 0; i < dayGroup.SkyTimes.Count; i++) + { + if (dayGroup.SkyTimes[i].Keyframe.Begin <= dayFraction) + active = dayGroup.SkyTimes[i]; + else + break; + } + + SkyObjectReplaceData? result = null; + foreach (SkyObjectReplaceData replace in active.Replaces) + { + if (replace.ObjectIndex == objectIndex) + result = replace; + } + return result; + } + + private static bool IsFiniteDirection(Vector3 value) => + float.IsFinite(value.X) + && float.IsFinite(value.Y) + && float.IsFinite(value.Z) + && value.LengthSquared() > 1e-10f; +} diff --git a/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs b/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs new file mode 100644 index 00000000..b52c3716 --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs @@ -0,0 +1,519 @@ +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.App.Rendering.Packs; + +/// +/// The built-in Atmospheric Rendering pack is expressed through the same +/// public declaration consumed by third-party packs. Renderer implementation +/// code resolves public enum semantics and never recognizes this pack's IDs, +/// so the built-in receives no private capability or lifecycle shortcut. +/// +internal static class BuiltInAtmosphericRenderPack +{ + internal const string Id = "acdream.atmospheric"; + + internal static RenderPackDescriptor Descriptor { get; } = new RenderPackDescriptor( + Id, + "Atmospheric Rendering", + new Version(1, 0, 0), + RenderPackApi.Current, + RenderPackTier.Tier2Plus, + [ + RenderCapability.MainWorldColorIntermediate, + RenderCapability.FullscreenPasses, + RenderCapability.SceneDepthSampling, + RenderCapability.AuthoredSunDirection, + RenderCapability.AuthoredSunScreenPosition, + RenderCapability.AuthoredWeather, + RenderCapability.DirectionalShadowMaps, + RenderCapability.OutdoorDirectionalShadowCasterReplay, + RenderCapability.AnimatedCasterTransforms, + RenderCapability.AlphaCutoutShadowCasters, + RenderCapability.AuthoredCelestialDirectionalLight, + ], + [RenderCapability.GpuTimestampQueries], + Resources(), + Passes(), + SceneReplays(), + PipelineVariants(), + QualityPresets(), + Settings(), + AtmospherePolicy()) + { + FeatureSummary = "Filmic HDR atmosphere, moving sun-and-moon shadows from terrain, " + + "trees, buildings, players, and monsters, plus optional volumetric shafts.", + }; + + internal static IRenderPackAssets CreateAssets(string shaderDirectory) => + new DirectoryRenderPackAssets(shaderDirectory); + + private static IReadOnlyList Resources() => + [ + Image("world-hdr", RenderResourceSemantic.MainWorldHdr, + RenderFormatClass.HdrColor, 1.0, 1.0, 32L * 1024 * 1024), + Image("bloom-a", RenderResourceSemantic.BloomPing, + RenderFormatClass.HdrColor, 0.5, 0.5, 8L * 1024 * 1024), + Image("bloom-b", RenderResourceSemantic.BloomPong, + RenderFormatClass.HdrColor, 0.5, 0.5, 8L * 1024 * 1024), + Image("sun-mask", RenderResourceSemantic.SunOcclusionMask, + RenderFormatClass.SingleChannel, 0.25, 0.25, 2L * 1024 * 1024), + Image("sun-rays", RenderResourceSemantic.SunRays, + RenderFormatClass.HdrColor, 0.25, 0.25, 2L * 1024 * 1024), + new RenderResourceDeclaration( + "directional-shadow-depth", + RenderResourceKind.Image2DArray, + RenderFormatClass.DirectionalDepth, + new RenderExtentDeclaration(RenderExtentMode.AbsolutePixels, 1024, 1024, Layers: 2), + SizeBytes: 0, + RenderResourceUsage.Sampled | RenderResourceUsage.DepthAttachment, + RenderResourceLifetime.ActivePack, + EstimatedResidentBytes: 8L * 1024 * 1024) + with { Semantic = RenderResourceSemantic.DirectionalShadowDepth }, + Image("volumetric", RenderResourceSemantic.VolumetricShafts, + RenderFormatClass.HdrColor, 0.25, 0.25, 2L * 1024 * 1024), + ]; + + private static IReadOnlyList Passes() => + [ + Pass( + "directional-shadow-depth", + RenderPassSemantic.DirectionalShadowDepth, + RenderPassHook.ShadowDepthBeforeWorld, + "directional_shadow_world_opaque.vert.spv", + "directional_shadow_world_opaque.frag.spv", + [RenderSemanticInput.CameraMatrices, + RenderSemanticInput.SelectedCelestialDirectionalLight, + RenderSemanticInput.ShadowCasterTransforms, RenderSemanticInput.ActiveDayGroup, + RenderSemanticInput.Weather], + [], + ["directional-shadow-depth"]), + Pass( + "sun-occlusion", + RenderPassSemantic.SunOcclusion, + RenderPassHook.AtmosphereBeforeToneMap, + "atmospheric_sun_occlusion.vert.spv", + "atmospheric_sun_occlusion.frag.spv", + [RenderSemanticInput.SceneDepth, RenderSemanticInput.SunScreenPosition, + RenderSemanticInput.ActiveDayGroup, RenderSemanticInput.Weather], + [], + ["sun-mask"]), + Pass( + "sun-rays", + RenderPassSemantic.SunRays, + RenderPassHook.AtmosphereBeforeToneMap, + "atmospheric_sun_rays.vert.spv", + "atmospheric_sun_rays.frag.spv", + [RenderSemanticInput.SunScreenPosition, RenderSemanticInput.FrameTime], + ["sun-mask"], + ["sun-rays"]), + Pass( + "volumetric-shafts", + RenderPassSemantic.VolumetricShafts, + RenderPassHook.AtmosphereBeforeToneMap, + "atmospheric_volumetric.vert.spv", + "atmospheric_volumetric.frag.spv", + [RenderSemanticInput.SceneDepth, RenderSemanticInput.CameraMatrices, + RenderSemanticInput.SunDirection, RenderSemanticInput.DirectionalShadowMaps, + RenderSemanticInput.ActiveDayGroup, RenderSemanticInput.Weather], + ["directional-shadow-depth"], + ["volumetric"]), + Pass( + "bloom-downsample", + RenderPassSemantic.BloomDownsample, + RenderPassHook.AtmosphereBeforeToneMap, + "atmospheric_bloom_downsample.vert.spv", + "atmospheric_bloom_downsample.frag.spv", + [RenderSemanticInput.WorldColor], + ["sun-rays", "volumetric"], + ["bloom-a"]), + Pass( + "bloom-blur-horizontal", + RenderPassSemantic.BloomBlurHorizontal, + RenderPassHook.AtmosphereBeforeToneMap, + "atmospheric_bloom_blur.vert.spv", + "atmospheric_bloom_blur.frag.spv", + [RenderSemanticInput.FrameTime], + ["bloom-a"], + ["bloom-b"]), + Pass( + "bloom-blur-vertical", + RenderPassSemantic.BloomBlurVertical, + RenderPassHook.AtmosphereBeforeToneMap, + "atmospheric_bloom_blur.vert.spv", + "atmospheric_bloom_blur.frag.spv", + [RenderSemanticInput.FrameTime], + ["bloom-b"], + ["bloom-a"]), + Pass( + "filmic-composite", + RenderPassSemantic.FilmicComposite, + RenderPassHook.ToneMap, + "atmospheric_filmic.vert.spv", + "atmospheric_filmic.frag.spv", + [RenderSemanticInput.WorldColor, RenderSemanticInput.FrameTime], + ["bloom-a", "sun-rays", "volumetric"], + []), + ]; + + private static IReadOnlyList SceneReplays() => + [ + new SceneReplayDeclaration( + "outdoor-directional-shadow-casters", + RenderSceneReplaySemantic.OutdoorDirectionalShadowCasters, + RenderCasterClass.Terrain + | RenderCasterClass.OpaqueWorld + | RenderCasterClass.AlphaCutoutWorld + | RenderCasterClass.AnimatedOpaque + | RenderCasterClass.AnimatedAlphaCutout, + ViewCount: 4), + ]; + + private static IReadOnlyList PipelineVariants() => + [ + Variant("terrain-shadow-caster", RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster, + RenderPipelineBaseSemantic.Terrain, + "directional_shadow_terrain.vert.spv", "directional_shadow_terrain.frag.spv", + RenderMaterialClass.Opaque, + [RenderSemanticInput.CameraMatrices]), + Variant("world-shadow-opaque", RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster, + RenderPipelineBaseSemantic.WorldMesh, + "directional_shadow_world_opaque.vert.spv", "directional_shadow_world_opaque.frag.spv", + RenderMaterialClass.Opaque | RenderMaterialClass.AnimatedOpaque, + [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]), + Variant("world-shadow-cutout", RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster, + RenderPipelineBaseSemantic.WorldMesh, + "directional_shadow_world_cutout.vert.spv", "directional_shadow_world_cutout.frag.spv", + RenderMaterialClass.AlphaCutout | RenderMaterialClass.AnimatedAlphaCutout, + [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]), + Variant("terrain-shadow-caster-multiview", RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster, + RenderPipelineBaseSemantic.Terrain, + "directional_shadow_terrain_multiview.vert.spv", "directional_shadow_terrain_multiview.frag.spv", + RenderMaterialClass.Opaque, + [RenderSemanticInput.CameraMatrices]), + Variant("world-shadow-opaque-multiview", RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster, + RenderPipelineBaseSemantic.WorldMesh, + "directional_shadow_world_opaque_multiview.vert.spv", "directional_shadow_world_opaque_multiview.frag.spv", + RenderMaterialClass.Opaque | RenderMaterialClass.AnimatedOpaque, + [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]), + Variant("world-shadow-cutout-multiview", RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster, + RenderPipelineBaseSemantic.WorldMesh, + "directional_shadow_world_cutout_multiview.vert.spv", "directional_shadow_world_cutout_multiview.frag.spv", + RenderMaterialClass.AlphaCutout | RenderMaterialClass.AnimatedAlphaCutout, + [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]), + Variant("terrain-shadow-receiver", RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver, + RenderPipelineBaseSemantic.Terrain, + "terrain_atmospheric.vert.spv", "terrain_atmospheric.frag.spv", + RenderMaterialClass.Opaque, + [RenderSemanticInput.DirectionalShadowMaps, + RenderSemanticInput.SelectedCelestialDirectionalLight]), + Variant("world-shadow-receiver", RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver, + RenderPipelineBaseSemantic.WorldMesh, + "mesh_atmospheric.vert.spv", "mesh_atmospheric.frag.spv", + RenderMaterialClass.Opaque | RenderMaterialClass.AlphaCutout + | RenderMaterialClass.AnimatedOpaque | RenderMaterialClass.AnimatedAlphaCutout, + [RenderSemanticInput.DirectionalShadowMaps, + RenderSemanticInput.SelectedCelestialDirectionalLight]), + ]; + + private static IReadOnlyList QualityPresets() => + [ + Preset("low", "Low", RenderQualitySemantic.Low, + 64, 2.0, 3.0, 0.15, 0.50, 768, 2, 72, 0.25) with + { + ExecutionHints = + RenderQualityExecutionHints.MultiviewDirectionalShadowCascades, + }, + Preset("medium", "Medium", RenderQualitySemantic.Medium, + 128, 3.25, 4.50, 0.25, 0.75, 1536, 3, 144, 0.5), + Preset("high", "High", RenderQualitySemantic.High, + 256, 4.50, 6.00, 0.35, 1.00, 2048, 4, 240, 0.5), + Preset("auto", "Auto", RenderQualitySemantic.Automatic, + 128, 3.25, 4.50, 0.25, 0.75, 1536, 3, 144, 0.5) + with + { + SettingOverrides = + [ + new RenderQualitySettingOverride("automatic-quality", "true"), + new RenderQualitySettingOverride("volumetric-strength", "0.35"), + new RenderQualitySettingOverride("volumetric-ray-steps", "40"), + new RenderQualitySettingOverride("sun-shadow-strength", "0.72"), + new RenderQualitySettingOverride("sun-shadow-reach-metres", "144"), + new RenderQualitySettingOverride("sun-shadow-pcf-taps", "9"), + new RenderQualitySettingOverride("sun-ray-strength", "0.55"), + ], + AutoEligible = false, + }, + ]; + + private static IReadOnlyList Settings() => + [ + Float("bloom-strength", "Bloom strength", RenderSettingSemantic.BloomStrength, + 0.65, 0, 2, 0.05), + Float("filmic-strength", "Filmic tonemap strength", RenderSettingSemantic.FilmicStrength, + 1.0, 0, 1, 0.05), + Float("exposure", "Exposure", RenderSettingSemantic.Exposure, + 0.80, 0.25, 4, 0.05), + Float("grade-saturation", "Colour saturation", RenderSettingSemantic.GradeSaturation, + 1.0, 0, 2, 0.05), + Float("grade-contrast", "Colour contrast", RenderSettingSemantic.GradeContrast, + 1.0, 0.5, 2, 0.05), + Float("vignette-strength", "Vignette strength", RenderSettingSemantic.VignetteStrength, + 0.12, 0, 1, 0.01), + Float("sun-ray-strength", "Sun-ray strength", RenderSettingSemantic.SunRayStrength, + 0.55, 0, 2, 0.05), + Float("sun-shadow-strength", "Directional-shadow strength", + RenderSettingSemantic.DirectionalShadowStrength, 0.72, 0, 1, 0.02), + Integer("sun-shadow-reach-metres", "Directional-shadow reach (metres)", + RenderSettingSemantic.DirectionalShadowReachMetres, 240, 16, 240, 1), + Choice("sun-shadow-pcf-taps", "Directional-shadow filter taps", + RenderSettingSemantic.DirectionalShadowPcfTaps, "9", ["1", "9", "25"]), + Float("volumetric-strength", "Volumetric-shaft strength", + RenderSettingSemantic.VolumetricStrength, 0.35, 0, 1, 0.01), + Integer("volumetric-ray-steps", "Volumetric ray-march steps", + RenderSettingSemantic.VolumetricRayMarchSteps, 40, 8, 64, 8), + new RenderSettingDeclaration( + "automatic-quality", + "Automatic quality", + RenderSettingKind.Boolean, + "false", + null, + null, + null, + []) + with { Semantic = RenderSettingSemantic.AutomaticQuality }, + ]; + + private static AtmospherePolicyDeclaration AtmospherePolicy() => new( + [ + new SunElevationResponsePoint(-90, 0), + new SunElevationResponsePoint(-3, 0), + new SunElevationResponsePoint(4, 1), + new SunElevationResponsePoint(22, 0.75), + new SunElevationResponsePoint(55, 0), + new SunElevationResponsePoint(90, 0), + ], + [ + new ActiveDayGroupMultiplier(0, 1.0), + new ActiveDayGroupMultiplier(1, 0.35), + new ActiveDayGroupMultiplier(2, 0.20), + ]) + { + DirectionalShadowLightElevationResponse = + [ + new SunElevationResponsePoint(-90, 0), + new SunElevationResponsePoint(1, 0), + new SunElevationResponsePoint(12, 1), + new SunElevationResponsePoint(90, 1), + ], + VolumetricShaftSunElevationResponse = + [ + new SunElevationResponsePoint(-90, 0), + new SunElevationResponsePoint(0, 0), + new SunElevationResponsePoint(6, 1), + new SunElevationResponsePoint(18, 1), + new SunElevationResponsePoint(70, 0), + new SunElevationResponsePoint(90, 0), + ], + }; + + private static RenderResourceDeclaration Image( + string id, + RenderResourceSemantic semantic, + RenderFormatClass format, + double widthScale, + double heightScale, + long estimatedBytes) => new RenderResourceDeclaration( + id, + RenderResourceKind.Image2D, + format, + new RenderExtentDeclaration( + RenderExtentMode.RelativeToMainWorld, + widthScale, + heightScale), + SizeBytes: 0, + RenderResourceUsage.Sampled | RenderResourceUsage.ColorAttachment, + RenderResourceLifetime.ActivePack, + estimatedBytes) + { Semantic = semantic }; + + private static RenderPassDeclaration Pass( + string id, + RenderPassSemantic semantic, + RenderPassHook hook, + string vertex, + string fragment, + IReadOnlyList semantics, + IReadOnlyList reads, + IReadOnlyList writes) => + new(id, hook, vertex, fragment, semantics, reads, writes) + { + Semantic = semantic, + }; + + private static PipelineVariantDeclaration Variant( + string id, + RenderPipelineVariantSemantic variantSemantic, + RenderPipelineBaseSemantic semantic, + string vertex, + string fragment, + RenderMaterialClass materials, + IReadOnlyList inputs) => + new(id, semantic, vertex, fragment, materials, inputs) + { + Semantic = variantSemantic, + }; + + private static RenderQualityPreset Preset( + string id, + string displayName, + RenderQualitySemantic semantic, + long maxMiB, + double gpuP50, + double gpuP99, + double cpuP50, + double cpuP99, + int shadowResolution, + int cascades, + int shadowReachMetres, + double postScale) => new RenderQualityPreset( + id, + displayName, + semantic == RenderQualitySemantic.Low + ? [RenderCapability.DirectionalShadowMaps, + RenderCapability.MultiviewDirectionalShadowCascades] + : [RenderCapability.DirectionalShadowMaps], + [ + Override("directional-shadow-depth", shadowResolution, shadowResolution, cascades, + 4L * shadowResolution * shadowResolution * cascades), + RelativeOverride("bloom-a", postScale), + RelativeOverride("bloom-b", postScale), + RelativeOverride("sun-mask", id == "low" ? 0.25 : 0.5), + RelativeOverride("sun-rays", id == "low" ? 0.25 : 0.5), + RelativeOverride("volumetric", id == "high" ? 0.5 : 0.25), + ], + [ + new RenderQualitySettingOverride("automatic-quality", "false"), + new RenderQualitySettingOverride("volumetric-strength", id == "low" ? "0" : "0.35"), + new RenderQualitySettingOverride( + "volumetric-ray-steps", + semantic switch + { + RenderQualitySemantic.Low => "24", + RenderQualitySemantic.High => "56", + _ => "40", + }), + new RenderQualitySettingOverride("sun-shadow-strength", "0.72"), + new RenderQualitySettingOverride("sun-shadow-reach-metres", shadowReachMetres.ToString()), + new RenderQualitySettingOverride( + "sun-shadow-pcf-taps", + semantic switch + { + RenderQualitySemantic.Low => "1", + RenderQualitySemantic.High => "25", + _ => "9", + }), + // The renderer recognizes this bounded preset fact; it remains + // visible here instead of becoming a hidden cascade constant. + new RenderQualitySettingOverride("sun-ray-strength", id == "low" ? "0.4" : "0.55"), + ], + maxMiB * 1024 * 1024, + gpuP50, + gpuP99, + cpuP50, + cpuP99) + { Semantic = semantic }; + + private static RenderQualityResourceOverride Override( + string id, + int width, + int height, + int layers, + long bytes) => new( + id, + new RenderExtentDeclaration(RenderExtentMode.AbsolutePixels, width, height, layers), + SizeBytes: 0, + EstimatedResidentBytes: bytes); + + private static RenderQualityResourceOverride RelativeOverride(string id, double scale) => + new( + id, + new RenderExtentDeclaration(RenderExtentMode.RelativeToMainWorld, scale, scale), + SizeBytes: 0, + EstimatedResidentBytes: 0); + + private static RenderSettingDeclaration Float( + string id, + string displayName, + RenderSettingSemantic semantic, + double defaultValue, + double min, + double max, + double step) => new RenderSettingDeclaration( + id, + displayName, + RenderSettingKind.Float, + defaultValue.ToString(System.Globalization.CultureInfo.InvariantCulture), + min, + max, + step, + []) + { Semantic = semantic }; + + private static RenderSettingDeclaration Integer( + string id, + string displayName, + RenderSettingSemantic semantic, + int defaultValue, + int min, + int max, + int step) => new RenderSettingDeclaration( + id, + displayName, + RenderSettingKind.Integer, + defaultValue.ToString(System.Globalization.CultureInfo.InvariantCulture), + min, + max, + step, + []) + { Semantic = semantic }; + + private static RenderSettingDeclaration Choice( + string id, + string displayName, + RenderSettingSemantic semantic, + string defaultValue, + IReadOnlyList choices) => new RenderSettingDeclaration( + id, + displayName, + RenderSettingKind.Choice, + defaultValue, + null, + null, + null, + choices) + { Semantic = semantic }; +} + +internal sealed class DirectoryRenderPackAssets : IRenderPackAssets +{ + private readonly string _root; + + internal DirectoryRenderPackAssets(string root) + { + ArgumentException.ThrowIfNullOrWhiteSpace(root); + _root = Path.GetFullPath(root); + } + + public Stream OpenRead(string assetKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(assetKey); + string normalized = assetKey.Replace('/', Path.DirectorySeparatorChar); + string path = Path.GetFullPath(Path.Combine(_root, normalized)); + string relative = Path.GetRelativePath(_root, path); + if (Path.IsPathRooted(relative) + || relative == ".." + || relative.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + throw new UnauthorizedAccessException("The asset key escapes the render-pack root."); + return File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read); + } +} diff --git a/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs b/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs new file mode 100644 index 00000000..0f005236 --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs @@ -0,0 +1,965 @@ +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Scene; +using AcDream.App.Rendering.Wb; +using AcDream.Core.World; +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.App.Rendering.Packs; + +/// +/// API-v1 executor for declaration-only fullscreen graphs. It supports the +/// portable Tier-1 hooks/resources without recognizing a pack id or shader +/// filename. Scene replay and renderer-pipeline variants remain separate host +/// facilities and are rejected by the factory before this runtime is built. +/// +internal class DeclaredFullscreenRenderPackGraph : + IAtmosphericWorldGraphRuntime, + IRenderPackRuntimePerformanceSource, + IRenderPackRuntimeDiagnosticsSource +{ + private readonly IGpuDevice _device; + private readonly IDisposable _hdrLease; + private readonly IGpuSampler _sampler; + private readonly Node[] _nodes; + private readonly IReadOnlyDictionary _resources; + private readonly PackSettingsUniforms _settings; + private readonly DirectionalSunShadowRenderer? _directionalShadows; + private readonly DirectionalShadowCasterFrame _shadowCasters = new(); + private readonly RenderPassDeclaration? _shadowPass; + private readonly float _shadowStrength; + private TargetSet? _targets; + private RenderPackResourceBudget _resourceBudget; + private long _resourceGeneration; + private long _residentGpuBudgetBytes; + private AtmosphericFrameInputs _lastInputs; + private DirectionalSunShadowDiagnostics _lastShadowDiagnostics; + private int _lastShadowCasterCount; + private int _lastShadowClassificationCalls; + private WbDrawDispatcher? _lastShadowWorldMeshes; + private long _lastShadowFrameSerial = -1; + private bool _renderedFrame; + private bool _disposed; + + internal DeclaredFullscreenRenderPackGraph( + IGpuDevice device, + RenderPackDescriptor descriptor, + IRenderPackAssets assets, + RenderQualityPreset preset, + IReadOnlyDictionary userSettingOverrides) + : this( + device, + descriptor, + RenderPackShaderAssets.Validate(descriptor, assets), + preset, + userSettingOverrides) + { + } + + internal DeclaredFullscreenRenderPackGraph( + IGpuDevice device, + RenderPackDescriptor descriptor, + ValidatedRenderPackShaderAssets assets, + RenderQualityPreset preset, + IReadOnlyDictionary userSettingOverrides) + { + _device = device ?? throw new ArgumentNullException(nameof(device)); + Descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor)); + ArgumentNullException.ThrowIfNull(assets); + Preset = preset ?? throw new ArgumentNullException(nameof(preset)); + ArgumentNullException.ThrowIfNull(userSettingOverrides); + if (device is not IGpuPipelineFormatVariantHost variants) + throw new NotSupportedException("The active RHI cannot build an HDR world intermediate."); + + _resources = descriptor.Resources.ToDictionary(value => value.Id, StringComparer.OrdinalIgnoreCase); + RenderPassDeclaration[] passes = descriptor.Passes + .OrderBy(value => value.Hook) + .ToArray(); + RenderPassDeclaration[] fullscreenPasses = passes + .Where(static value => + value.Semantic != RenderPassSemantic.DirectionalShadowDepth) + .ToArray(); + if (!fullscreenPasses.Any(value => value.Hook == RenderPassHook.ToneMap + && value.ResourceWrites.Count == 0)) + { + throw new NotSupportedException( + $"Fullscreen pack '{descriptor.Id}' must declare a ToneMap pass that writes the output surface."); + } + + IDisposable? lease = null; + DirectionalSunShadowRenderer? directionalShadows = null; + var nodes = new List(fullscreenPasses.Length); + try + { + lease = variants.AcquirePipelineColorFormat(GpuTextureFormat.Rgba16FloatRenderTarget); + _sampler = device.CreateSampler(GpuSamplerDescription.WorldClamp); + foreach (RenderPassDeclaration pass in fullscreenPasses) + { + if (pass.Hook is not RenderPassHook.AtmosphereBeforeToneMap + and not RenderPassHook.ToneMap) + throw new NotSupportedException($"Fullscreen executor does not support hook '{pass.Hook}'."); + RenderSemanticInput? unsupported = pass.SemanticInputs.FirstOrDefault(value => + value is RenderSemanticInput.SceneNormals + or RenderSemanticInput.ShadowCasterTransforms + or RenderSemanticInput.DirectionalShadowMaps); + if (unsupported is RenderSemanticInput.SceneNormals + or RenderSemanticInput.ShadowCasterTransforms + or RenderSemanticInput.DirectionalShadowMaps) + { + throw new NotSupportedException( + $"Tier-1 fullscreen pass '{pass.Id}' requires unsupported semantic '{unsupported}'."); + } + if (pass.ResourceWrites.Count > 1) + throw new NotSupportedException($"Pass '{pass.Id}' writes more than one colour target."); + GpuTextureFormat format = pass.ResourceWrites.Count == 0 + ? GpuTextureFormat.Rgba8UnormRenderTarget + : ValidateOutput(Resource(pass.ResourceWrites[0])); + var pipeline = device.CreatePipeline(new GpuPipelineDescription + { + Name = $"render-pack-{descriptor.Id}-{pass.Id}", + Shaders = RenderPackShaderAssets.LoadPass(descriptor, assets, pass), + VertexLayout = GpuVertexLayout.None, + Blend = GpuBlendMode.None, + Depth = GpuDepthState.Disabled, + Cull = GpuCullMode.None, + ColorFormat = format, + AllowColorFormatVariants = false, + SampleCount = 1, + UsesRenderPackShaderAbi = true, + }); + string timerName = $"render-pack-{descriptor.Id}-{pass.Id}"; + nodes.Add(new Node( + pass, + pipeline, + [.. RenderPackTextureBindingResolver.Resolve(pass, _resources)], + timerName)); + } + _nodes = [.. nodes]; + _settings = PackSettingsUniforms.Create( + descriptor, + preset, + userSettingOverrides); + _shadowPass = passes.SingleOrDefault(static value => + value.Semantic == RenderPassSemantic.DirectionalShadowDepth); + _shadowStrength = _shadowPass is null + ? 0f + : ReadSemanticSetting( + descriptor, + preset, + userSettingOverrides, + RenderSettingSemantic.DirectionalShadowStrength); + if (_shadowPass is not null) + { + directionalShadows = new DirectionalSunShadowRenderer( + device, + ResolveShadowQuality( + descriptor, + preset, + userSettingOverrides), + RenderPackAtmospherePolicyEvaluation.NeutralDirectionalShadowElevation, + LoadDirectionalShadowShaders(descriptor, assets), + multiviewCascades: (preset.ExecutionHints + & RenderQualityExecutionHints + .MultiviewDirectionalShadowCascades) != 0); + } + _directionalShadows = directionalShadows; + directionalShadows = null; + _hdrLease = lease; + lease = null; + } + catch + { + directionalShadows?.Dispose(); + for (int i = nodes.Count - 1; i >= 0; i--) + nodes[i].Pipeline.Dispose(); + lease?.Dispose(); + throw; + } + } + + public RenderPackDescriptor Descriptor { get; } + + public RenderQualityPreset Preset { get; } + + internal IDirectionalShadowReceiverSource DeclaredDirectionalShadowReceivers => + _directionalShadows + ?? throw new InvalidOperationException( + $"Pack '{Descriptor.Id}' has no declared directional-shadow executor."); + + internal DirectionalSunShadowDiagnostics RenderDeclaredDirectionalShadows( + IGpuFrame frame, + in RenderFrameFoundation foundation, + in WorldRenderFrame world, + int activeDayGroup, + in RenderSceneQuery scene, + WbDrawDispatcher worldMeshes, + TerrainModernRenderer terrain) + { + ObjectDisposedException.ThrowIf(_disposed, this); + DirectionalSunShadowRenderer renderer = _directionalShadows + ?? throw new InvalidOperationException( + $"Pack '{Descriptor.Id}' has no declared directional-shadow executor."); + _shadowCasters.Build(in scene); + AuthoredCelestialShadowSource source = world.CelestialShadowSource; + float elevationStrength = RenderPackAtmospherePolicyEvaluation + .DirectionalShadowFromSin( + Descriptor.AtmospherePolicy!.DirectionalShadowLightElevationResponse, + source.ElevationSin, + fallback: 0f); + var environment = new DirectionalShadowEnvironmentInput( + PackEnabled: true, + PortalOrLoginCoverVisible: foundation.PortalViewportVisible, + PlayerInsideCell: world.Roots.PlayerInsideCell + || world.Roots.CameraInsideCell, + source, + foundation.Atmosphere, + ActiveDayGroupMultiplier: Math.Clamp( + EvaluateDayGroupPolicy(activeDayGroup) + * elevationStrength + * _shadowStrength, + 0f, + 1f)); + var input = new DirectionalSunShadowRenderInput( + environment, + world.Camera.Camera.View, + world.Camera.Projection, + _shadowCasters, + ResidentMaximumReachMeters: + world.ResidentStreamingWindow.MaximumReachMeters); + _lastShadowCasterCount = _shadowCasters.Stats.Accepted; + _lastShadowClassificationCalls = _shadowCasters.Stats.TopologyRebuilt ? 1 : 0; + _lastShadowDiagnostics = renderer.Render( + frame, + in input, + worldMeshes, + terrain); + _lastShadowWorldMeshes = worldMeshes; + _lastShadowFrameSerial = frame.Serial; + RequireRetainedGpuBudget(renderer); + return _lastShadowDiagnostics; + } + + public IGpuRenderTarget PrepareWorldTarget(int width, int height, int sampleCount) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_targets is { } current + && current.Width == width + && current.Height == height + && current.SampleCount == sampleCount) + return current.World; + RenderPackHostCapabilities capabilities = + RenderPackCapabilityResolver.Resolve(_device.Capabilities); + RenderPackResourceBudget budget = RenderPackResourceBudgetPlanner.RequireWithinHost( + Descriptor, + Preset, + width, + height, + sampleCount, + capabilities); + TargetSet candidate = TargetSet.Create( + _device, + Descriptor, + Preset, + _sampler, + width, + height, + sampleCount); + TargetSet? prior = _targets; + _targets = candidate; + _resourceBudget = budget; + _residentGpuBudgetBytes = Math.Min( + Preset.MaxResidentGpuBytes, + capabilities.MaxPackResidentBytes); + _resourceGeneration = checked(_resourceGeneration + 1); + _renderedFrame = false; + prior?.Dispose(); + return candidate.World; + } + + public void RenderPostProcess(IGpuFrame frame, in AtmosphericFrameInputs inputs) + { + ObjectDisposedException.ThrowIf(_disposed, this); + TargetSet targets = _targets + ?? throw new InvalidOperationException("PrepareWorldTarget must run before the fullscreen graph."); + if (inputs.ViewportWidth != targets.Width || inputs.ViewportHeight != targets.Height) + throw new InvalidOperationException("Fullscreen graph inputs and targets belong to different frames."); + + float elevationPolicy = EvaluateSunElevationPolicy(inputs.SunElevationDegrees); + float dayGroupPolicy = EvaluateDayGroupPolicy(inputs.ActiveDayGroup); + IReadOnlyList shadowCurve = + Descriptor.AtmospherePolicy?.DirectionalShadowLightElevationResponse ?? []; + IReadOnlyList volumetricCurve = + Descriptor.AtmospherePolicy?.VolumetricShaftSunElevationResponse ?? []; + float shadowElevationPolicy = shadowCurve.Count == 0 + ? elevationPolicy + : RenderPackAtmospherePolicyEvaluation.DirectionalShadow( + shadowCurve, + inputs.SunElevationDegrees, + elevationPolicy); + float volumetricElevationPolicy = volumetricCurve.Count == 0 + ? 0f + : RenderPackAtmospherePolicyEvaluation.VolumetricShaft( + volumetricCurve, + inputs.SunElevationDegrees, + 0f); + float sunPolicy = EvaluateSunPolicy( + inputs, + elevationPolicy, + dayGroupPolicy); + var frameValues = new AtmosphericFrameUniforms( + new Vector4(inputs.SunScreenUv, sunPolicy, inputs.SunElevationDegrees), + new Vector4(inputs.SunColor, sunPolicy), + new Vector4(targets.Width, targets.Height, 1f / targets.Width, 1f / targets.Height), + new Vector4((float)inputs.Weather, inputs.WeatherIntensity, + (float)Math.Clamp(inputs.DeltaSeconds, 0d, 1d), inputs.IsOutdoor ? 1f : 0f), + new Vector4(inputs.SunDirection, inputs.SunDirectionalBrightness), + new Vector4( + inputs.ActiveDayGroup, + dayGroupPolicy, + shadowElevationPolicy, + volumetricElevationPolicy), + inputs.InverseViewProjection); + GpuRingAllocation frameBlock = frame.AllocateRing(AtmosphericFrameUniforms.SizeInBytes, GpuRingUsage.Uniform); + MemoryMarshal.Write(frameBlock.Data, in frameValues); + GpuRingAllocation settingsBlock = frame.AllocateRing(PackSettingsUniforms.SizeInBytes, GpuRingUsage.Uniform); + PackSettingsUniforms settings = _settings; + MemoryMarshal.Write(settingsBlock.Data, in settings); + + foreach (Node node in _nodes) + Draw(frame, node, targets, frameBlock, settingsBlock); + _lastInputs = inputs; + _renderedFrame = true; + } + + public RenderPackRuntimeDiagnostics CaptureDiagnostics() + { + ObjectDisposedException.ThrowIf(_disposed, this); + TargetSet? targets = _targets; + if (!_renderedFrame || targets is null) + return RenderPackRuntimeDiagnostics.Empty(Preset.Id); + + int shadowPassCount = _shadowPass is null ? 0 : 1; + var passes = new RenderPackPassDiagnostics[_nodes.Length + shadowPassCount]; + int passIndex = 0; + if (_shadowPass is not null) + { + passes[passIndex++] = new RenderPackPassDiagnostics( + _shadowPass.Id, + _lastShadowDiagnostics.LastResolvedGpuMilliseconds, + _lastShadowDiagnostics.DrawCalls, + DispatchCalls: 0); + } + for (int i = 0; i < _nodes.Length; i++) + { + Node node = _nodes[i]; + _device.Timers.TryResolve(node.TimerName, out double milliseconds); + passes[passIndex++] = new RenderPackPassDiagnostics( + node.Pass.Id, + milliseconds, + DrawCalls: 1, + DispatchCalls: 0); + } + + return new RenderPackRuntimeDiagnostics( + Preset.Id, + checked( + _resourceBudget.RetainedGpuBytes + + (_directionalShadows?.RetainedGpuBufferBytes ?? 0L)), + _resourceBudget.MultisampleGpuBytes, + targets.ImageCount + shadowPassCount, + BufferCount: _directionalShadows?.RetainedGpuBufferCount ?? 0, + DrawCalls: _nodes.Length + _lastShadowDiagnostics.DrawCalls, + DispatchCalls: 0, + ShadowCasterCount: _lastShadowCasterCount, + CascadeDrawCount: _lastShadowDiagnostics.CascadeCount, + CpuClassificationCalls: _lastShadowClassificationCalls, + _lastInputs.SunElevationDegrees, + _lastInputs.ActiveDayGroup, + _lastInputs.Weather.ToString(), + _lastInputs.WeatherIntensity, + _lastInputs.IsOutdoor, + DirectionalShadowStrength: _lastShadowDiagnostics.Strength, + passes) + { + DirectionalShadowSourceKind = _lastShadowDiagnostics.SourceKind, + DirectionalShadowSourceObjectIndex = + _lastShadowDiagnostics.SourceObjectIndex, + DirectionalShadowSourceGfxObjId = + _lastShadowDiagnostics.SourceGfxObjId, + DirectionalShadowSurfaceToLightDirection = + _lastShadowDiagnostics.SurfaceToLightDirection, + DirectionalShadowLightElevationSin = + _lastShadowDiagnostics.LightElevationSin, + ShadowTransformChurn = _lastShadowDiagnostics.TransformChurn, + SharedWorldTransformUsedInstances = + _lastShadowWorldMeshes is not null + && _lastShadowWorldMeshes.HasDirectionalShadowTransformFrame( + _lastShadowFrameSerial) + ? _lastShadowWorldMeshes + .DirectionalShadowTransformFrameUsedInstances + : 0u, + }; + } + + public RenderPackRuntimePerformanceMetrics CapturePerformanceMetrics() + { + ObjectDisposedException.ThrowIf(_disposed, this); + double gpuMilliseconds = 0d; + bool resolved = _targets is not null; + for (int i = 0; i < _nodes.Length; i++) + { + if (!_device.Timers.TryTakeResolved( + _nodes[i].TimerName, + out double milliseconds)) + { + resolved = false; + } + else + { + gpuMilliseconds += milliseconds; + } + } + if (_directionalShadows is not null) + { + if (!_device.Timers.TryTakeResolved( + RenderPackPerformanceScopeNames.EnhancedWorldReceiver, + out double receiverMilliseconds)) + { + resolved = false; + } + else + { + gpuMilliseconds += receiverMilliseconds; + } + int shadowTimerCount = _directionalShadows.MultiviewCascadesEnabled + && _lastShadowDiagnostics.CascadeCount > 0 + ? 1 + : _lastShadowDiagnostics.CascadeCount; + for (int i = 0; i < shadowTimerCount; i++) + { + if (!_device.Timers.TryTakeResolved( + _directionalShadows.MultiviewCascadesEnabled + ? DirectionalSunShadowRenderer.MultiviewTimerName + : DirectionalSunShadowRenderer.TimerName(i), + out double milliseconds)) + { + resolved = false; + } + else + { + gpuMilliseconds += milliseconds; + } + } + } + return new RenderPackRuntimePerformanceMetrics( + _resourceGeneration, + resolved, + resolved ? gpuMilliseconds : 0d, + checked( + _resourceBudget.RetainedGpuBytes + + (_directionalShadows?.RetainedGpuBufferBytes ?? 0L)), + _resourceBudget.MultisampleGpuBytes); + } + + private void RequireRetainedGpuBudget( + DirectionalSunShadowRenderer renderer) + { + long total = checked( + _resourceBudget.RetainedGpuBytes + + renderer.RetainedGpuBufferBytes); + if (total <= _residentGpuBudgetBytes) + return; + throw new NotSupportedException( + $"Render pack preset '{Preset.Id}' needs {total} resident GPU bytes " + + "after materializing its scene-dependent shadow command buffers; " + + $"the active pack budget is {_residentGpuBudgetBytes} bytes."); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _targets?.Dispose(); + _directionalShadows?.Dispose(); + for (int i = _nodes.Length - 1; i >= 0; i--) + _nodes[i].Pipeline.Dispose(); + _hdrLease.Dispose(); + } + + private void Draw( + IGpuFrame frame, + Node node, + TargetSet targets, + GpuRingAllocation frameBlock, + GpuRingAllocation settingsBlock) + { + IGpuRenderTarget? output = node.Pass.ResourceWrites.Count == 0 + ? null + : targets.Resource(node.Pass.ResourceWrites[0]).Target; + using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription + { + Name = node.TimerName, + Color = new GpuColorAttachment(output, GpuLoadOp.Clear, GpuStoreOp.Store, Vector4.Zero), + Depth = null, + SampleCount = 1, + }); + using IDisposable timer = encoder.BeginTimerScope(node.TimerName); + encoder.BindPipeline(node.Pipeline); + encoder.BindUniformBuffer(GpuBindingModel.UniformAtmosphericFrame, + frameBlock.Buffer, frameBlock.OffsetBytes, AtmosphericFrameUniforms.SizeInBytes); + GpuRingAllocation passBlock = frame.AllocateRing( + AtmosphericPackPassUniforms.SizeInBytes, + GpuRingUsage.Uniform); + var zero = AtmosphericPackPassUniforms.From(Vector4.Zero); + MemoryMarshal.Write(passBlock.Data, in zero); + encoder.BindUniformBuffer(GpuBindingModel.UniformPackPass, + passBlock.Buffer, passBlock.OffsetBytes, AtmosphericPackPassUniforms.SizeInBytes); + encoder.BindUniformBuffer(GpuBindingModel.UniformPackSettings, + settingsBlock.Buffer, settingsBlock.OffsetBytes, PackSettingsUniforms.SizeInBytes); + + Span slots = stackalloc GpuTextureSlot[4]; + slots.Fill(GpuTextureSlot.Unassigned); + for (int i = 0; i < node.Inputs.Length; i++) + slots[i] = Resolve(node.Inputs[i], targets); + GpuPushConstants push = GpuPushConstants.Default; + push.TextureIndexA = slots[0].Index; + push.TextureIndexB = slots[1].Index; + push.ParamA = BitConverter.UInt32BitsToSingle(slots[2].Index); + push.ParamB = BitConverter.UInt32BitsToSingle(slots[3].Index); + encoder.SetPushConstants(in push); + encoder.Draw(3, 1, 0, 0); + } + + private static GpuTextureSlot Resolve(RenderPackTextureInput input, TargetSet targets) + { + if (input.Semantic is { } semantic) + { + return semantic switch + { + RenderSemanticInput.WorldColor => targets.WorldColor, + RenderSemanticInput.SceneDepth => targets.WorldDepth, + _ => throw new NotSupportedException($"Texture semantic '{semantic}' is unsupported by Tier-1."), + }; + } + return targets.Resource(input.ResourceId!).Slot; + } + + private float EvaluateSunElevationPolicy(float elevation) + { + IReadOnlyList? points = + Descriptor.AtmospherePolicy?.SunElevationResponse; + return RenderPackAtmospherePolicyEvaluation.Ray(points, elevation); + } + + private float EvaluateDayGroupPolicy(int activeDayGroup) + { + ActiveDayGroupMultiplier? value = Descriptor.AtmospherePolicy? + .ActiveDayGroupMultipliers + .FirstOrDefault(entry => entry.ActiveDayGroup == activeDayGroup); + return value is null ? 1f : (float)value.Multiplier; + } + + private static float EvaluateSunPolicy( + in AtmosphericFrameInputs inputs, + float elevationPolicy, + float dayGroupPolicy) + { + if (!inputs.IsOutdoor || !inputs.SunIsOnScreen) + return 0f; + return Math.Clamp( + elevationPolicy + * dayGroupPolicy + * EvaluateWeatherPolicy(inputs.Weather, inputs.WeatherIntensity), + 0f, + 4f); + } + + private static float EvaluateWeatherPolicy( + AcDream.Core.World.WeatherKind weather, + float intensity) + { + float weatherTarget = weather switch + { + AcDream.Core.World.WeatherKind.Clear => 1f, + AcDream.Core.World.WeatherKind.Overcast => 0.18f, + AcDream.Core.World.WeatherKind.Rain => 0.10f, + AcDream.Core.World.WeatherKind.Snow => 0.16f, + AcDream.Core.World.WeatherKind.Storm => 0.06f, + _ => 0f, + }; + return 1f + ((weatherTarget - 1f) * Math.Clamp(intensity, 0f, 1f)); + } + + private static DirectionalShadowPipelineShaders LoadDirectionalShadowShaders( + RenderPackDescriptor descriptor, + ValidatedRenderPackShaderAssets assets) + { + DirectionalShadowPipelineShaders shaders = new( + Variant(RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster), + Variant(RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster), + Variant(RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster), + Variant(RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver), + Variant(RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver)); + if (descriptor.PipelineVariants.Any(value => + value.Semantic == RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster)) + { + shaders = shaders with + { + MultiviewCasters = new DirectionalShadowMultiviewPipelineShaders( + Variant(RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster), + Variant(RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster), + Variant(RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster)), + }; + } + return shaders; + + GpuShaderSet Variant(RenderPipelineVariantSemantic semantic) + { + PipelineVariantDeclaration variant = descriptor.PipelineVariants + .Single(value => value.Semantic == semantic); + return RenderPackShaderAssets.LoadVariant(descriptor, assets, variant); + } + } + + private static DirectionalShadowQuality ResolveShadowQuality( + RenderPackDescriptor descriptor, + RenderQualityPreset preset, + IReadOnlyDictionary userSettingOverrides) + { + DirectionalShadowPreset shadowPreset = preset.Semantic switch + { + RenderQualitySemantic.Low => DirectionalShadowPreset.Low, + RenderQualitySemantic.High => DirectionalShadowPreset.High, + _ => DirectionalShadowPreset.Medium, + }; + DirectionalShadowQuality quality = DirectionalShadowQuality.For(shadowPreset); + RenderResourceDeclaration resource = descriptor.Resources.Single(value => + value.Semantic == RenderResourceSemantic.DirectionalShadowDepth); + RenderExtentDeclaration extent = preset.ResourceOverrides.FirstOrDefault(value => + string.Equals( + value.ResourceId, + resource.Id, + StringComparison.OrdinalIgnoreCase))?.Extent + ?? resource.Extent + ?? throw new NotSupportedException( + "The DirectionalShadowDepth semantic resource has no image extent."); + if (extent.Mode != RenderExtentMode.AbsolutePixels + || extent.Width != extent.Height + || extent.Width != Math.Truncate(extent.Width) + || extent.Width is < 1 or > 16_384 + || extent.Layers is < 1 or > 4) + { + throw new NotSupportedException( + "The DirectionalShadowDepth semantic resource must be a square " + + "absolute 1..16384 image with 1..4 array layers."); + } + + float reach = ReadSemanticSetting( + descriptor, + preset, + userSettingOverrides, + RenderSettingSemantic.DirectionalShadowReachMetres); + int taps = ReadShadowPcfTaps(descriptor, preset, userSettingOverrides); + int radius = taps switch + { + 1 => 0, + 9 => 1, + 25 => 2, + _ => throw new NotSupportedException( + "DirectionalShadowPcfTaps must resolve to exactly 1, 9, or 25 samples."), + }; + int resolution = checked((int)extent.Width); + int cascades = extent.Layers; + return quality with + { + CascadeCount = cascades, + MapResolution = resolution, + MaximumReachMeters = Math.Clamp(reach, 1f, 10_000f), + PcfRadiusTexels = radius, + ApproximateDepthMapBytes = checked( + (long)cascades * resolution * resolution * sizeof(float)), + IncrementalGpuP50BudgetMilliseconds = preset.MaxIncrementalGpuMillisecondsP50, + IncrementalGpuP99BudgetMilliseconds = preset.MaxIncrementalGpuMillisecondsP99, + IncrementalCpuP50BudgetMilliseconds = preset.MaxIncrementalCpuMillisecondsP50, + IncrementalCpuP99BudgetMilliseconds = preset.MaxIncrementalCpuMillisecondsP99, + PackResidentGpuByteBudget = preset.MaxResidentGpuBytes, + }; + } + + private static int ReadShadowPcfTaps( + RenderPackDescriptor descriptor, + RenderQualityPreset preset, + IReadOnlyDictionary userSettingOverrides) + { + RenderSettingDeclaration setting = descriptor.Settings.Single(value => + value.Semantic == RenderSettingSemantic.DirectionalShadowPcfTaps); + string value = RenderPackSettingResolution.Resolve( + setting, + preset, + userSettingOverrides); + return int.TryParse( + value, + System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, + out int taps) + ? taps + : throw new NotSupportedException( + "DirectionalShadowPcfTaps must resolve to an integer sample count."); + } + + private static float ReadSemanticSetting( + RenderPackDescriptor descriptor, + RenderQualityPreset preset, + IReadOnlyDictionary userSettingOverrides, + RenderSettingSemantic semantic) + { + RenderSettingDeclaration setting = descriptor.Settings.Single(value => + value.Semantic == semantic); + string value = RenderPackSettingResolution.Resolve( + setting, + preset, + userSettingOverrides); + if (!RenderPackSettingValueCodec.TryEncode(setting, value, out float encoded) + || !float.IsFinite(encoded)) + { + throw new NotSupportedException( + $"Setting semantic '{semantic}' did not resolve to a finite value."); + } + return encoded; + } + + private RenderResourceDeclaration Resource(string id) => + _resources.TryGetValue(id, out RenderResourceDeclaration? value) + ? value + : throw new InvalidOperationException($"Unknown render-pack resource '{id}'."); + + private static GpuTextureFormat FormatOf(RenderResourceDeclaration resource) => resource.Format switch + { + RenderFormatClass.HdrColor => GpuTextureFormat.Rgba16FloatRenderTarget, + RenderFormatClass.LdrColor or RenderFormatClass.SingleChannel => + GpuTextureFormat.Rgba8UnormRenderTarget, + _ => throw new NotSupportedException( + $"Fullscreen resource '{resource.Id}' has unsupported format '{resource.Format}'."), + }; + + private static GpuTextureFormat ValidateOutput(RenderResourceDeclaration resource) + { + if (resource.Kind != RenderResourceKind.Image2D + || (resource.Usage & RenderResourceUsage.ColorAttachment) == 0 + || resource.Extent is null) + { + throw new NotSupportedException( + $"Fullscreen output '{resource.Id}' must be an extent-declared colour Image2D."); + } + return FormatOf(resource); + } + + private sealed record Node( + RenderPassDeclaration Pass, + IGpuPipeline Pipeline, + RenderPackTextureInput[] Inputs, + string TimerName); + + private sealed class TargetSet : IDisposable + { + private readonly IGpuDevice _device; + private readonly Dictionary _resources; + private readonly GpuTextureSlot[] _slots; + private readonly string? _mainWorldResourceId; + + private TargetSet( + IGpuDevice device, + int width, + int height, + int sampleCount, + IGpuRenderTarget world, + GpuTextureSlot worldColor, + GpuTextureSlot worldDepth, + Dictionary resources, + GpuTextureSlot[] slots, + string? mainWorldResourceId) + { + _device = device; + Width = width; + Height = height; + SampleCount = sampleCount; + World = world; + WorldColor = worldColor; + WorldDepth = worldDepth; + _resources = resources; + _slots = slots; + _mainWorldResourceId = mainWorldResourceId; + } + + internal int Width { get; } + internal int Height { get; } + internal int SampleCount { get; } + internal IGpuRenderTarget World { get; } + internal GpuTextureSlot WorldColor { get; } + internal GpuTextureSlot WorldDepth { get; } + internal int ImageCount => checked( + 2 + + _resources.Count + + (SampleCount > 1 ? (WorldDepth.IsAssigned ? 2 : 1) : 0)); + + internal ResourceTarget Resource(string id) => + string.Equals(id, _mainWorldResourceId, StringComparison.OrdinalIgnoreCase) + ? new ResourceTarget(World, WorldColor) + : _resources.TryGetValue(id, out ResourceTarget? value) + ? value + : throw new InvalidOperationException($"Resource '{id}' has no produced image."); + + internal static TargetSet Create( + IGpuDevice device, + RenderPackDescriptor descriptor, + RenderQualityPreset preset, + IGpuSampler sampler, + int width, + int height, + int samples) + { + var targets = new List(); + var slots = new List(); + try + { + bool needsDepth = descriptor.Passes.Any(pass => + pass.SemanticInputs.Contains(RenderSemanticInput.SceneDepth)); + IGpuRenderTarget world = device.CreateRenderTarget(new GpuRenderTargetDescription( + $"render-pack-{descriptor.Id}-world-hdr", width, height, + GpuTextureFormat.Rgba16FloatRenderTarget, + GpuTextureFormat.Depth24Stencil8, + samples, + needsDepth)); + targets.Add(world); + GpuTextureSlot worldColor = Register(device, world.ColorTexture, sampler, slots); + GpuTextureSlot worldDepth = needsDepth + ? Register(device, world.DepthTexture!, sampler, slots) + : GpuTextureSlot.Unassigned; + var resources = new Dictionary(StringComparer.OrdinalIgnoreCase); + string? mainWorldResourceId = descriptor.Resources.SingleOrDefault(resource => + resource.Semantic == RenderResourceSemantic.MainWorldHdr)?.Id; + HashSet written = descriptor.Passes + .SelectMany(pass => pass.ResourceWrites) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (RenderResourceDeclaration resource in descriptor.Resources) + { + if (!written.Contains(resource.Id) + || resource.Semantic is RenderResourceSemantic.MainWorldHdr + or RenderResourceSemantic.DirectionalShadowDepth) + continue; + if (resource.Kind != RenderResourceKind.Image2D + || (resource.Usage & RenderResourceUsage.ColorAttachment) == 0) + throw new NotSupportedException($"Fullscreen resource '{resource.Id}' is not a colour image."); + (int resourceWidth, int resourceHeight) = Extent(resource, preset, width, height); + IGpuRenderTarget target = device.CreateRenderTarget(new GpuRenderTargetDescription( + $"render-pack-{descriptor.Id}-{resource.Id}", resourceWidth, resourceHeight, + FormatOf(resource), null, 1)); + targets.Add(target); + resources.Add(resource.Id, new ResourceTarget( + target, + Register(device, target.ColorTexture, sampler, slots))); + } + return new TargetSet( + device, width, height, samples, world, worldColor, worldDepth, + resources, [.. slots], mainWorldResourceId); + } + catch + { + for (int i = slots.Count - 1; i >= 0; i--) + device.ReleaseTextureSlot(slots[i]); + for (int i = targets.Count - 1; i >= 0; i--) + targets[i].Dispose(); + throw; + } + } + + public void Dispose() + { + for (int i = _slots.Length - 1; i >= 0; i--) + _device.ReleaseTextureSlot(_slots[i]); + foreach (ResourceTarget resource in _resources.Values.Reverse()) + resource.Target.Dispose(); + World.Dispose(); + } + + private static (int Width, int Height) Extent( + RenderResourceDeclaration resource, + RenderQualityPreset preset, + int width, + int height) + { + RenderExtentDeclaration extent = preset.ResourceOverrides.FirstOrDefault(value => + string.Equals(value.ResourceId, resource.Id, StringComparison.OrdinalIgnoreCase))?.Extent + ?? resource.Extent + ?? throw new NotSupportedException($"Image resource '{resource.Id}' has no extent."); + return extent.Mode switch + { + RenderExtentMode.AbsolutePixels => + (checked((int)extent.Width), checked((int)extent.Height)), + RenderExtentMode.RelativeToMainWorld or RenderExtentMode.RelativeToOutput => + (Math.Max(1, (int)Math.Ceiling(width * extent.Width)), + Math.Max(1, (int)Math.Ceiling(height * extent.Height))), + _ => throw new NotSupportedException($"Resource '{resource.Id}' has unsupported extent mode."), + }; + } + + private static GpuTextureSlot Register( + IGpuDevice device, + IGpuTexture texture, + IGpuSampler sampler, + List slots) + { + GpuTextureSlot slot = device.RegisterTexture(texture, sampler); + slots.Add(slot); + return slot; + } + } + + internal sealed record ResourceTarget(IGpuRenderTarget Target, GpuTextureSlot Slot); +} + +internal sealed class DeclaredDirectionalShadowRenderPackGraph : + DeclaredFullscreenRenderPackGraph, + IDirectionalShadowWorldGraphRuntime +{ + internal DeclaredDirectionalShadowRenderPackGraph( + IGpuDevice device, + RenderPackDescriptor descriptor, + IRenderPackAssets assets, + RenderQualityPreset preset, + IReadOnlyDictionary userSettingOverrides) + : base(device, descriptor, assets, preset, userSettingOverrides) + { + } + + internal DeclaredDirectionalShadowRenderPackGraph( + IGpuDevice device, + RenderPackDescriptor descriptor, + ValidatedRenderPackShaderAssets assets, + RenderQualityPreset preset, + IReadOnlyDictionary userSettingOverrides) + : base(device, descriptor, assets, preset, userSettingOverrides) + { + } + + public IDirectionalShadowReceiverSource DirectionalShadowReceivers => + DeclaredDirectionalShadowReceivers; + + public DirectionalSunShadowDiagnostics RenderDirectionalShadows( + IGpuFrame frame, + in RenderFrameFoundation foundation, + in WorldRenderFrame world, + int activeDayGroup, + in RenderSceneQuery scene, + WbDrawDispatcher worldMeshes, + TerrainModernRenderer terrain) => RenderDeclaredDirectionalShadows( + frame, + in foundation, + in world, + activeDayGroup, + in scene, + worldMeshes, + terrain); +} diff --git a/src/AcDream.App/Rendering/Packs/RenderPackAtmospherePolicyEvaluation.cs b/src/AcDream.App/Rendering/Packs/RenderPackAtmospherePolicyEvaluation.cs new file mode 100644 index 00000000..410a7914 --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/RenderPackAtmospherePolicyEvaluation.cs @@ -0,0 +1,87 @@ +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.App.Rendering.Packs; + +/// +/// Host evaluation for the public data-only atmosphere curves. Keeping the +/// three interpolation contracts here prevents a pack declaration from being +/// reinterpreted differently by the declared, shadow, and volumetric graphs. +/// +internal static class RenderPackAtmospherePolicyEvaluation +{ + internal static DirectionalShadowAtmospherePolicy NeutralDirectionalShadowElevation { get; } = + DirectionalShadowAtmospherePolicy.BuiltIn with + { + MinimumLightElevationSin = -1.001f, + FullStrengthLightElevationSin = -1f, + }; + + internal static float Ray( + IReadOnlyList? points, + float elevationDegrees, + float fallback = 1f) => Evaluate( + points, + elevationDegrees, + static value => (float)value, + static value => (float)value, + fallback); + + internal static float DirectionalShadow( + IReadOnlyList? points, + float elevationDegrees, + float fallback = 0f) => DirectionalShadowFromSin( + points, + MathF.Sin(elevationDegrees * (MathF.PI / 180f)), + fallback); + + internal static float DirectionalShadowFromSin( + IReadOnlyList? points, + float lightElevationSin, + float fallback = 0f) => Evaluate( + points, + Math.Clamp(lightElevationSin, -1f, 1f), + static degrees => MathF.Sin((float)degrees * (MathF.PI / 180f)), + static value => (float)value, + fallback); + + internal static float VolumetricShaft( + IReadOnlyList? points, + float elevationDegrees, + float fallback = 0f) => Evaluate( + points, + elevationDegrees, + static value => (float)value, + static value => value * value * (3f - (2f * value)), + fallback); + + private static float Evaluate( + IReadOnlyList? points, + float input, + Func transformPoint, + Func transformInterpolation, + float fallback) + { + if (points is null || points.Count == 0) + return fallback; + float first = transformPoint(points[0].ElevationDegrees); + if (input <= first) + return (float)points[0].Multiplier; + for (int i = 1; i < points.Count; i++) + { + SunElevationResponsePoint upper = points[i]; + float upperInput = transformPoint(upper.ElevationDegrees); + if (input > upperInput) + continue; + SunElevationResponsePoint lower = points[i - 1]; + float lowerInput = transformPoint(lower.ElevationDegrees); + float span = upperInput - lowerInput; + float t = span <= 0f + ? 0f + : Math.Clamp((input - lowerInput) / span, 0f, 1f); + t = transformInterpolation(t); + return (float)(lower.Multiplier + + ((upper.Multiplier - lower.Multiplier) * t)); + } + return (float)points[^1].Multiplier; + } +} diff --git a/src/AcDream.App/Rendering/Packs/RenderPackCapabilityResolver.cs b/src/AcDream.App/Rendering/Packs/RenderPackCapabilityResolver.cs new file mode 100644 index 00000000..bfd46732 --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/RenderPackCapabilityResolver.cs @@ -0,0 +1,66 @@ +using AcDream.App.Rendering.Gpu; +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.App.Rendering.Packs; + +internal static class RenderPackCapabilityResolver +{ + internal const long AbsoluteResidentByteCeiling = 256L * 1024 * 1024; + internal const long AbsoluteTransientByteCeiling = 512L * 1024 * 1024; + internal const int DeviceLocalShareDenominator = 8; + + internal static RenderPackHostCapabilities Resolve(GpuCapabilityRecord gpu) + { + ArgumentNullException.ThrowIfNull(gpu); + var available = new HashSet + { + RenderCapability.FullscreenPasses, + RenderCapability.AuthoredSunDirection, + RenderCapability.AuthoredCelestialDirectionalLight, + RenderCapability.AuthoredSunScreenPosition, + RenderCapability.AuthoredWeather, + RenderCapability.OutdoorDirectionalShadowCasterReplay, + RenderCapability.AnimatedCasterTransforms, + RenderCapability.AlphaCutoutShadowCasters, + }; + + if (gpu.SupportsRgba16FloatRenderTargets) + available.Add(RenderCapability.MainWorldColorIntermediate); + if (gpu.SupportsSampledDepth) + { + available.Add(RenderCapability.SceneDepthSampling); + available.Add(RenderCapability.DirectionalShadowMaps); + } + if (gpu.SupportsTimestampQueries) + available.Add(RenderCapability.GpuTimestampQueries); + if (gpu.SupportsMultiview) + available.Add(RenderCapability.MultiviewDirectionalShadowCascades); + + long residentBytes = DeviceLocalShare( + gpu.DeviceLocalMemoryBytes, + AbsoluteResidentByteCeiling); + long transientBytes = DeviceLocalShare( + gpu.DeviceLocalMemoryBytes, + AbsoluteTransientByteCeiling); + return new RenderPackHostCapabilities( + available, + MaxImageDimension2D: checked((int)Math.Min( + gpu.MaxImageDimension2D, + (uint)int.MaxValue)), + MaxImageArrayLayers: checked((int)Math.Min( + gpu.MaxImageArrayLayers, + (uint)int.MaxValue)), + MaxPackResidentBytes: residentBytes, + MaxPackTransientBytes: transientBytes, + MemoryPolicyDescription: + $"one eighth of {gpu.DeviceLocalMemoryBytes} device-local bytes, " + + $"capped at {AbsoluteResidentByteCeiling} resident and " + + $"{AbsoluteTransientByteCeiling} transient bytes"); + } + + private static long DeviceLocalShare(ulong deviceLocalBytes, long ceiling) + { + ulong share = deviceLocalBytes / DeviceLocalShareDenominator; + return (long)Math.Min(share, checked((ulong)ceiling)); + } +} diff --git a/src/AcDream.App/Rendering/Packs/RenderPackCatalogSource.cs b/src/AcDream.App/Rendering/Packs/RenderPackCatalogSource.cs new file mode 100644 index 00000000..60e031ba --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/RenderPackCatalogSource.cs @@ -0,0 +1,37 @@ +using AcDream.App.Plugins; + +namespace AcDream.App.Rendering.Packs; + +/// +/// Production catalog authority shared by retained UI and the activation +/// controller. It deliberately retains no catalog snapshot (and therefore no +/// plugin asset source): withdrawal immediately releases the registry's last +/// catalog reference, while consumers rebuild only after the revision event or +/// an explicit UI interaction. +/// +internal sealed class RenderPackCatalogSource +{ + private readonly BufferedRenderPackRegistry _registry; + private readonly RenderPackHostCapabilities _capabilities; + + internal RenderPackCatalogSource( + BufferedRenderPackRegistry registry, + RenderPackHostCapabilities capabilities) + { + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _capabilities = capabilities + ?? throw new ArgumentNullException(nameof(capabilities)); + } + + internal long Revision => _registry.Revision; + + internal event Action Changed + { + add => _registry.Changed += value; + remove => _registry.Changed -= value; + } + + internal RenderPackCatalog Snapshot() => RenderPackCatalog.Build( + _registry.Snapshot(), + _capabilities); +} diff --git a/src/AcDream.App/Rendering/Packs/RenderPackController.cs b/src/AcDream.App/Rendering/Packs/RenderPackController.cs new file mode 100644 index 00000000..55e7fc2e --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/RenderPackController.cs @@ -0,0 +1,1404 @@ +using AcDream.App.Plugins; +using AcDream.App.Rendering.Gpu.Vk; +using AcDream.Plugin.Abstractions.Rendering; +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.App.Rendering.Packs; + +internal sealed record RenderPackCatalogEntry( + RenderPackDescriptor Descriptor, + IRenderPackAssets Assets, + long RegistrationId, + bool IsCompatible, + string? IncompatibilityReason, + IReadOnlyDictionary PresetIncompatibilityReasons); + +internal sealed class RenderPackCatalog +{ + private readonly Dictionary _entries; + + private RenderPackCatalog(Dictionary entries) + { + _entries = entries; + } + + internal IReadOnlyList Entries => + _entries.Values + .OrderBy(static value => value.Descriptor.DisplayName, StringComparer.OrdinalIgnoreCase) + .ThenBy(static value => value.Descriptor.Id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + internal bool TryGet(string id, out RenderPackCatalogEntry entry) => + _entries.TryGetValue(id, out entry!); + + internal static RenderPackCatalog Build( + IEnumerable registrations, + RenderPackHostCapabilities capabilities) + { + ArgumentNullException.ThrowIfNull(registrations); + ArgumentNullException.ThrowIfNull(capabilities); + var entries = new Dictionary( + StringComparer.OrdinalIgnoreCase); + + foreach (BufferedRenderPackRegistration registration in registrations) + { + RenderPackDescriptor descriptor = registration.Descriptor; + RenderPackValidationResult validation = + RenderPackValidator.ValidateDescriptor(descriptor, capabilities); + if (entries.ContainsKey(descriptor.Id)) + continue; + + IReadOnlyDictionary presetReasons = + descriptor.QualityPresets is null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : descriptor.QualityPresets + .Where(static preset => preset is not null + && !string.IsNullOrWhiteSpace(preset.Id)) + .GroupBy(static preset => preset.Id, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + static group => group.Key, + group => + { + RenderPackValidationResult result = + RenderPackValidator.ValidatePresetCompatibility( + descriptor, + group.First(), + capabilities); + return result.Success ? null : result.Reason; + }, + StringComparer.OrdinalIgnoreCase); + entries.Add( + descriptor.Id, + new RenderPackCatalogEntry( + descriptor, + registration.Assets, + registration.RegistrationId, + validation.Success, + validation.Reason, + presetReasons)); + } + + return new RenderPackCatalog(entries); + } +} + +internal interface IRenderPackRuntime : IDisposable +{ + RenderPackDescriptor Descriptor { get; } + + RenderQualityPreset Preset { get; } +} + +/// +/// An active conformance pack which deliberately contributes no render graph. +/// Production keeps its selected identity/diagnostics active while delegating +/// the world frame to acdream's unchanged default rendering path. +/// +internal interface IDefaultWorldPathRenderPackRuntime : IRenderPackRuntime +{ +} + +internal interface IRenderPackRuntimeFactory +{ + IRenderPackRuntime Build( + RenderPackDescriptor descriptor, + ValidatedRenderPackShaderAssets assets, + RenderQualityPreset preset, + IReadOnlyDictionary userSettingOverrides); +} + +internal enum RenderPackActivationState +{ + Retail, + CandidatePending, + Active, + FailedToRetail, +} + +internal readonly record struct RenderPackActivationSnapshot( + RenderPackActivationState State, + RenderPackSelectionSettings Selection, + string? ActivePackDisplayName, + string? Reason, + long ActivationGeneration); + +internal readonly record struct RenderPackActivationExtent( + int Width, + int Height, + int SampleCount) +{ + internal void Validate() + { + if (Width <= 0 || Height <= 0 || SampleCount <= 0) + throw new ArgumentOutOfRangeException(nameof(Width), "Activation extent must be positive."); + } +} + +/// +/// Owns asynchronous candidate preparation and atomic render-frame-boundary +/// activation. The active runtime remains published while shader validation, +/// GPU resource creation, target sizing, and receiver-pipeline preparation run +/// off-side. A failed selection is remembered for its exact live registration +/// and never retried in a loop; a corrected re-registration can be selected +/// without recreating this controller. +/// +internal sealed class RenderPackController : + IDisposable, + IRenderPackDiagnosticsSnapshotSource +{ + private readonly Func _catalog; + private readonly IRenderPackRuntimeFactory _factory; + private readonly IRenderPackReceiverPipelineCoordinator? _receiverPipelines; + private readonly IRenderPackPreparationScheduler _preparationScheduler; + private readonly RenderPackCatalogSource? _catalogSource; + private readonly Dictionary _failedSelections = []; + private readonly RenderPackPerformanceWindow _performance = new(); + private RenderPackSelectionSettings? _pending; + private AtmosphericAutoQualityController? _autoQuality; + private AtmosphericQualityLevel? _pendingAutoQuality; + private string? _pendingAutoFallbackReason; + private PendingPreparation? _preparation; + private IRenderPackRuntime? _active; + private long _activeRegistrationId; + private IRenderPackRuntime? _observedPerformanceRuntime; + private long _observedResourceGeneration = -1; + private RenderPackActivationSnapshot _snapshot = new( + RenderPackActivationState.Retail, + RenderPackSelectionSettings.Retail, + ActivePackDisplayName: null, + Reason: null, + ActivationGeneration: 0); + private bool _disposed; + private int _catalogChanged; + + internal RenderPackController( + Func catalog, + IRenderPackRuntimeFactory factory, + IRenderPackReceiverPipelineCoordinator? receiverPipelines = null, + IRenderPackPreparationScheduler? preparationScheduler = null, + RenderPackCatalogSource? catalogSource = null) + { + _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + _factory = factory ?? throw new ArgumentNullException(nameof(factory)); + _receiverPipelines = receiverPipelines; + _preparationScheduler = preparationScheduler + ?? ThreadPoolRenderPackPreparationScheduler.Instance; + _catalogSource = catalogSource; + if (catalogSource is not null) + catalogSource.Changed += OnCatalogChanged; + } + + internal RenderPackActivationSnapshot Snapshot => _snapshot; + + internal IRenderPackRuntime? ActiveRuntime => _active; + + internal RenderPackPerformanceSnapshot Performance => _performance.Snapshot(); + + internal int MinimumPerformanceSampleCount => + _performance.MinimumSampleCount; + + internal bool TryResetPerformanceEvidence(out string error) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_snapshot.State != RenderPackActivationState.Active + || _active is not IRenderPackRuntimePerformanceSource source) + { + error = "an active render pack with performance diagnostics is required"; + return false; + } + if (_autoQuality is not null) + { + error = "performance evidence reset requires an explicit quality preset"; + return false; + } + + RenderPackRuntimePerformanceMetrics metrics = + source.CapturePerformanceMetrics(); + Validate(in metrics); + _performance.Reset(); + _observedPerformanceRuntime = _active; + _observedResourceGeneration = metrics.ResourceGeneration; + error = string.Empty; + return true; + } + + internal AtmosphericAutoQualitySnapshot? AutoQuality => _autoQuality?.Snapshot; + + public RenderPackDiagnosticsSnapshot CaptureDiagnostics() + { + RenderPackRuntimeDiagnostics runtime = + (_active as IRenderPackRuntimeDiagnosticsSource)?.CaptureDiagnostics() + ?? RenderPackRuntimeDiagnostics.Empty(_snapshot.Selection.PresetId); + RenderPackPerformanceSnapshot performance = _performance.Snapshot(); + return new RenderPackDiagnosticsSnapshot( + _snapshot.State, + _snapshot.Selection.PackId, + _snapshot.Selection.PackVersion, + _snapshot.Selection.PresetId, + _active?.Preset.Id ?? runtime.EffectiveQuality, + _snapshot.Reason, + _snapshot.ActivationGeneration, + runtime.RetainedGpuBytes, + runtime.TransientGpuBytes, + runtime.ImageCount, + runtime.BufferCount, + runtime.DrawCalls, + runtime.DispatchCalls, + runtime.ShadowCasterCount, + runtime.CascadeDrawCount, + runtime.CpuClassificationCalls, + runtime.SunElevationDegrees, + runtime.ActiveDayGroup, + runtime.Weather, + runtime.WeatherIntensity, + runtime.Outdoor, + runtime.DirectionalShadowStrength, + runtime.Passes, + performance) + { + CpuStages = runtime.CpuStages, + DirectionalShadowSourceKind = runtime.DirectionalShadowSourceKind, + DirectionalShadowSourceObjectIndex = + runtime.DirectionalShadowSourceObjectIndex, + DirectionalShadowSourceGfxObjId = + runtime.DirectionalShadowSourceGfxObjId, + DirectionalShadowSurfaceToLightDirection = + runtime.DirectionalShadowSurfaceToLightDirection, + DirectionalShadowLightElevationSin = + runtime.DirectionalShadowLightElevationSin, + ShadowTransformChurn = runtime.ShadowTransformChurn, + SharedWorldTransformUsedInstances = + runtime.SharedWorldTransformUsedInstances, + }; + } + + internal void Request(RenderPackSelectionSettings? selection) + { + ObjectDisposedException.ThrowIf(_disposed, this); + RenderPackSelectionSettings normalized = Normalize(selection); + if (normalized == _snapshot.Selection + && _pending is null) + return; + _pendingAutoQuality = null; + _pendingAutoFallbackReason = null; + _pending = normalized; + _snapshot = _snapshot with + { + State = RenderPackActivationState.CandidatePending, + Selection = normalized, + ActivePackDisplayName = _active?.Descriptor.DisplayName, + Reason = $"Preparing render pack '{normalized.PackId}'.", + }; + } + + /// + /// Poll once at a stable render-frame boundary, before any pass opens. + /// This method never waits for candidate work. It only starts preparation, + /// observes completion, and publishes an already-complete candidate. The + /// existing runtime therefore remains renderable throughout preparation. + /// + internal RenderPackActivationSnapshot ApplyAtFrameBoundary( + RenderPackActivationExtent extent) + { + ObjectDisposedException.ThrowIf(_disposed, this); + extent.Validate(); + + if (ApplyCatalogChangeAtFrameBoundary() is { } catalogFailure) + return catalogFailure; + + if (_preparation is { } preparation) + { + if (!preparation.Work.IsCompleted) + return _snapshot; + + _preparation = null; + if (preparation.Work.IsCanceled) + { + preparation.Dispose(); + return Fail( + preparation.Plan.Selection, + PreparationFailure( + preparation.Plan.Selection, + "candidate preparation was cancelled"), + preparation.Plan.Entry.RegistrationId); + } + if (preparation.Work.IsFaulted) + { + Exception failure = preparation.Work.Exception!.GetBaseException(); + preparation.Dispose(); + if (VulkanRenderFailurePolicy.IsFatal(failure)) + throw failure; + return Fail( + preparation.Plan.Selection, + PreparationFailure(preparation.Plan.Selection, failure.Message), + preparation.Plan.Entry.RegistrationId); + } + + if (_pending is not null + || preparation.Plan.Selection != _snapshot.Selection) + { + preparation.Dispose(); + if (_pending is null + && _snapshot.State == RenderPackActivationState.FailedToRetail + && _snapshot.Selection.IsRetail) + { + return _snapshot; + } + if (_pending is null) + _pending = _snapshot.Selection; + } + else if (preparation.Plan.Extent != extent) + { + preparation.Dispose(); + StartPreparation(preparation.Plan with { Extent = extent }); + return _snapshot; + } + else + { + PreparationOutcome outcome = preparation.TakeOutcome(); + if (outcome.FailureReason is not null) + { + string? retirementFailure = outcome.DisposeResources(); + return Fail( + preparation.Plan.Selection, + outcome.FailureReason + + FormatRetirementFailure(retirementFailure), + preparation.Plan.Entry.RegistrationId); + } + return PublishPreparedCandidate(preparation.Plan, outcome); + } + } + + if (_pending is { } selection) + { + _pending = null; + if (selection.IsRetail) + { + string? retirementFailure = RetireActive(); + return PublishRetail(selection, retirementFailure); + } + + ActivationPlan? plan = PlanSelection(selection, in extent); + if (plan is null) + return _snapshot; + StartPreparation(plan); + if (_preparation!.Work.IsCompleted) + return ApplyAtFrameBoundary(extent); + return _snapshot; + } + + if (_pendingAutoFallbackReason is { } fallbackReason) + { + _pendingAutoFallbackReason = null; + return Fail(_snapshot.Selection, fallbackReason); + } + + if (_pendingAutoQuality is { } quality) + { + _pendingAutoQuality = null; + ActivationPlan? plan = PlanAutomaticQuality(quality, in extent); + if (plan is null) + return _snapshot; + StartPreparation(plan); + if (_preparation!.Work.IsCompleted) + return ApplyAtFrameBoundary(extent); + } + return _snapshot; + } + + private ActivationPlan? PlanSelection( + RenderPackSelectionSettings selection, + in RenderPackActivationExtent extent) + { + RenderPackCatalog catalog = _catalog(); + if (!catalog.TryGet(selection.PackId, out RenderPackCatalogEntry entry)) + { + if (AlreadyFailed(selection, registrationId: 0)) + return null; + Fail(selection, $"Render pack '{selection.PackId}' is not installed.", 0); + return null; + } + if (AlreadyFailed(selection, entry.RegistrationId)) + return null; + if (!entry.IsCompatible) + { + Fail(selection, entry.IncompatibilityReason ?? "The render pack is incompatible."); + return null; + } + if (!string.Equals( + entry.Descriptor.PackVersion.ToString(), + selection.PackVersion, + StringComparison.Ordinal)) + { + Fail( + selection, + $"Render pack '{selection.PackId}' version {selection.PackVersion ?? "(missing)"} " + + $"was selected, but version {entry.Descriptor.PackVersion} is installed."); + return null; + } + + RenderQualityPreset? selectedPreset = entry.Descriptor.QualityPresets.FirstOrDefault( + value => string.Equals(value.Id, selection.PresetId, StringComparison.OrdinalIgnoreCase)); + if (selectedPreset is null) + { + Fail(selection, $"Render pack preset '{selection.PresetId}' is not available."); + return null; + } + if (entry.PresetIncompatibilityReasons.TryGetValue( + selectedPreset.Id, + out string? presetFailure) + && presetFailure is not null) + { + Fail(selection, presetFailure); + return null; + } + + RenderPackValidationResult userSettings = + RenderPackSettingResolution.ValidateUserOverrides( + entry.Descriptor, + selection.SettingOverrides); + if (!userSettings.Success) + { + Fail(selection, userSettings.Reason!); + return null; + } + + bool automaticSelector = + selectedPreset.Semantic == RenderQualitySemantic.Automatic; + bool automatic = TryResolveAutomaticSetting( + entry.Descriptor, + selectedPreset, + selection.SettingOverrides, + out bool configuredAutomatic) + ? configuredAutomatic + : automaticSelector; + RenderQualityPreset preset = selectedPreset; + AtmosphericQualityLevel autoInitial = AtmosphericQualityLevel.Medium; + AtmosphericQualityLevel autoMaximum = AtmosphericQualityLevel.High; + if (automaticSelector || automatic) + { + if (!TryResolveAutomaticRange( + entry, + out preset, + out autoInitial, + out autoMaximum, + out string? automaticFailure)) + { + Fail(selection, automaticFailure!); + return null; + } + if (automatic + && !automaticSelector + && TryQualityLevel(selectedPreset.Semantic, out AtmosphericQualityLevel preferred)) + { + preset = selectedPreset; + autoInitial = preferred; + } + } + + AutomaticPublication automaticPublication = automatic + ? new AutomaticPublication( + AutomaticPublicationMode.Initialise, + AutomaticBudgets(entry), + autoInitial, + autoMaximum) + : AutomaticPublication.Disabled; + return new ActivationPlan( + entry, + selection, + preset, + extent, + RequirePerformanceSource: automatic, + automaticPublication); + } + + /// + /// Consumes one completed pack frame without allocating or waiting for the + /// GPU. A quality decision only queues an off-side candidate; publication + /// remains owned by the next stable frame boundary. + /// + internal void ObserveActiveFrame( + in RenderPackFramePerformanceObservation observation) + { + ObjectDisposedException.ThrowIf(_disposed, this); + Validate(in observation); + IRenderPackRuntime? active = _active; + if (active is not IRenderPackRuntimePerformanceSource source) + return; + + RenderPackRuntimePerformanceMetrics metrics = + source.CapturePerformanceMetrics(); + Validate(in metrics); + if (!ReferenceEquals(active, _observedPerformanceRuntime) + || metrics.ResourceGeneration != _observedResourceGeneration) + { + _performance.Reset(); + _observedPerformanceRuntime = active; + _observedResourceGeneration = metrics.ResourceGeneration; + return; + } + if (!observation.StableFrameBoundary + || !metrics.HasResolvedGpuMeasurement) + { + return; + } + + _performance.Observe( + observation.PackAddedCpuMilliseconds, + observation.AbsoluteEnhancedWorldReceiverCpuMilliseconds, + hasResolvedGpuMeasurement: true, + metrics.InclusiveResolvedGpuMilliseconds, + metrics.RetainedGpuBytes, + metrics.TransientGpuBytes); + if (_autoQuality is null + || _pendingAutoQuality is not null + || _pendingAutoFallbackReason is not null + || _snapshot.State != RenderPackActivationState.Active) + { + return; + } + + RenderPackPerformanceSnapshot performance = _performance.Snapshot(); + var measurement = new AtmosphericQualityMeasurement( + performance.InclusiveGpuMillisecondsP99, + performance.IncrementalCpuMillisecondsP99, + performance.ResidentGpuBytes, + StableFrameBoundary: true); + AtmosphericQualityBudget budget = _autoQuality.CurrentBudget; + long priorGeneration = _autoQuality.Snapshot.ChangeGeneration; + AtmosphericAutoQualitySnapshot quality = _autoQuality.Observe(in measurement); + if (quality.ChangeGeneration == priorGeneration) + return; + + if (quality.SafeFallbackToRetailRequested) + { + _pendingAutoFallbackReason = string.Format( + System.Globalization.CultureInfo.InvariantCulture, + "Automatic quality disabled render pack '{0}' because {1} remained " + + "over its declared performance budget for {2} stable samples: " + + "GPU p99 {3:F3} ms (budget {4:F3} ms), CPU p99 {5:F3} ms " + + "(budget {6:F3} ms), resident GPU bytes {7} (budget {8}).", + _snapshot.Selection.PackId, + quality.Current, + AtmosphericAutoQualityController.DowngradeHysteresisFrames, + measurement.InclusivePackGpuMillisecondsP99, + budget.GpuMillisecondsP99, + measurement.IncrementalCpuMillisecondsP99, + budget.CpuMillisecondsP99, + measurement.ResidentGpuBytes, + budget.ResidentGpuBytes); + return; + } + + _pendingAutoQuality = quality.Current; + } + + private ActivationPlan? PlanAutomaticQuality( + AtmosphericQualityLevel quality, + in RenderPackActivationExtent extent) + { + RenderPackSelectionSettings selection = _snapshot.Selection; + if (_active is null + || _autoQuality is null + || _snapshot.State != RenderPackActivationState.Active) + { + return null; + } + + RenderPackCatalog catalog = _catalog(); + if (!catalog.TryGet(selection.PackId, out RenderPackCatalogEntry entry)) + { + Fail(selection, $"Render pack '{selection.PackId}' is no longer installed."); + return null; + } + if (!entry.IsCompatible) + { + Fail(selection, entry.IncompatibilityReason ?? "The render pack is incompatible."); + return null; + } + if (!string.Equals( + entry.Descriptor.PackVersion.ToString(), + selection.PackVersion, + StringComparison.Ordinal)) + { + Fail( + selection, + $"Render pack '{selection.PackId}' changed version during automatic quality selection."); + return null; + } + + RenderQualityPreset? preset = FindEffectivePreset(entry, quality); + if (preset is null) + { + Fail( + selection, + $"Automatic quality cannot select a missing or ineligible " + + $"'{QualitySemantic(quality)}' semantic preset."); + return null; + } + if (entry.PresetIncompatibilityReasons.TryGetValue( + preset.Id, + out string? presetFailure) + && presetFailure is not null) + { + Fail(selection, presetFailure); + return null; + } + + RenderPackValidationResult userSettings = + RenderPackSettingResolution.ValidateUserOverrides( + entry.Descriptor, + selection.SettingOverrides); + if (!userSettings.Success) + { + Fail(selection, userSettings.Reason!); + return null; + } + + _snapshot = _snapshot with + { + State = RenderPackActivationState.CandidatePending, + ActivePackDisplayName = _active.Descriptor.DisplayName, + Reason = $"Preparing automatic quality preset '{preset.DisplayName}'.", + }; + return new ActivationPlan( + entry, + selection, + preset, + extent, + RequirePerformanceSource: true, + AutomaticPublication.Preserve); + } + + private void StartPreparation(ActivationPlan plan) + { + var preparation = new PendingPreparation(plan); + _preparation = preparation; + _snapshot = _snapshot with + { + State = RenderPackActivationState.CandidatePending, + Selection = plan.Selection, + ActivePackDisplayName = _active?.Descriptor.DisplayName, + Reason = $"Preparing render pack '{plan.Selection.PackId}' preset " + + $"'{plan.Preset.DisplayName}'.", + }; + try + { + preparation.Work = _preparationScheduler.Schedule( + () => preparation.Outcome = PrepareCandidate(plan)); + } + catch (Exception error) when (VulkanRenderFailurePolicy.IsFatal(error)) + { + _preparation = null; + preparation.Dispose(); + throw; + } + catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error)) + { + preparation.Outcome = PreparationOutcome.Failed( + PreparationFailure(plan.Selection, error.GetBaseException().Message)); + preparation.Work = Task.CompletedTask; + } + } + + private PreparationOutcome PrepareCandidate(ActivationPlan plan) + { + IRenderPackRuntime? candidate = null; + IRenderPackReceiverPipelineCandidate? receiverCandidate = null; + try + { + RenderPackValidationResult assets = RenderPackValidator.ValidateSelectedAssets( + plan.Entry.Descriptor, + plan.Entry.Assets, + out ValidatedRenderPackShaderAssets? validatedAssets); + if (!assets.Success) + return PreparationOutcome.Failed(assets.Reason!); + + candidate = _factory.Build( + plan.Entry.Descriptor, + validatedAssets!, + plan.Preset, + plan.Selection.SettingOverrides); + if (candidate is null) + throw new InvalidOperationException("The render-pack factory returned no candidate."); + if (!ReferenceEquals(candidate.Descriptor, plan.Entry.Descriptor) + && candidate.Descriptor != plan.Entry.Descriptor) + throw new InvalidOperationException("The candidate does not represent the selected descriptor."); + if (!ReferenceEquals(candidate.Preset, plan.Preset) + && candidate.Preset != plan.Preset) + throw new InvalidOperationException("The candidate does not represent the selected preset."); + if (plan.RequirePerformanceSource + && candidate is not IRenderPackRuntimePerformanceSource) + { + throw new NotSupportedException( + "Automatic quality requires allocation-free runtime performance metrics."); + } + if (candidate is IAtmosphericWorldGraphRuntime graph) + { + _ = graph.PrepareWorldTarget( + plan.Extent.Width, + plan.Extent.Height, + plan.Extent.SampleCount); + } + else if (plan.RequirePerformanceSource) + { + throw new NotSupportedException( + "Automatic quality requires a complete off-side world graph candidate."); + } + + IDirectionalShadowReceiverSource? receiverSource = + candidate is IDirectionalShadowWorldGraphRuntime directional + ? directional.DirectionalShadowReceivers + : null; + if (receiverSource is not null && _receiverPipelines is null) + { + throw new NotSupportedException( + "Directional-shadow activation requires an atomic receiver-pipeline coordinator."); + } + if (_receiverPipelines is not null) + { + receiverCandidate = _receiverPipelines.Prepare( + receiverSource, + plan.Extent.SampleCount); + } + PreparationOutcome outcome = PreparationOutcome.Ready( + candidate, + receiverCandidate); + candidate = null; + receiverCandidate = null; + return outcome; + } + catch (Exception error) when (VulkanRenderFailurePolicy.IsFatal(error)) + { + BestEffortDisposeForFatal(receiverCandidate); + BestEffortDisposeForFatal(candidate); + throw; + } + catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error)) + { + return new PreparationOutcome( + candidate, + receiverCandidate, + PreparationFailure( + plan.Selection, + error.GetBaseException().Message)); + } + } + + private RenderPackActivationSnapshot PublishPreparedCandidate( + ActivationPlan plan, + PreparationOutcome outcome) + { + IRenderPackRuntime? candidate = outcome.TakeRuntime(); + IRenderPackReceiverPipelineCandidate? receiverCandidate = + outcome.TakeReceiverCandidate(); + try + { + IRenderPackRuntime? previous = _active; + if (receiverCandidate is not null) + { + _receiverPipelines!.Publish(receiverCandidate); + receiverCandidate = null; + } + _active = candidate; + _activeRegistrationId = plan.Entry.RegistrationId; + candidate = null; + ResetPerformanceTracking(_active); + previous?.Dispose(); + ApplyAutomaticPublication(plan.AutomaticPublication); + _snapshot = new RenderPackActivationSnapshot( + RenderPackActivationState.Active, + plan.Selection, + plan.Entry.Descriptor.DisplayName, + Reason: null, + ActivationGeneration: checked(_snapshot.ActivationGeneration + 1)); + return _snapshot; + } + catch (Exception error) when (VulkanRenderFailurePolicy.IsFatal(error)) + { + BestEffortDisposeForFatal(receiverCandidate); + BestEffortDisposeForFatal(candidate); + throw; + } + catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error)) + { + string? receiverRetirement = TryDisposeReceiverCandidate(receiverCandidate); + string? candidateRetirement = TryDispose(candidate); + return Fail( + plan.Selection, + $"Render pack '{plan.Selection.PackId}' could not be published: " + + error.GetBaseException().Message + + FormatRetirementFailure(receiverRetirement) + + FormatRetirementFailure(candidateRetirement), + plan.Entry.RegistrationId); + } + finally + { + outcome.Dispose(); + } + } + + private void ApplyAutomaticPublication(AutomaticPublication publication) + { + if (publication.Mode == AutomaticPublicationMode.Preserve) + return; + _pendingAutoQuality = null; + _pendingAutoFallbackReason = null; + _autoQuality = publication.Mode == AutomaticPublicationMode.Initialise + ? new AtmosphericAutoQualityController( + publication.Budgets!, + publication.Initial, + AtmosphericQualityLevel.Low, + publication.Maximum) + : null; + } + + private static string PreparationFailure( + RenderPackSelectionSettings selection, + string reason) => + $"Render pack '{selection.PackId}' could not be prepared: {reason}"; + + internal void OnRuntimeFailure(string reason) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentException.ThrowIfNullOrWhiteSpace(reason); + RenderPackSelectionSettings failed = _snapshot.Selection; + if (!failed.IsRetail) + _failedSelections[failed] = _activeRegistrationId; + string? retirementFailure = RetireActive(); + ClearAutomaticQuality(); + _snapshot = new RenderPackActivationSnapshot( + RenderPackActivationState.FailedToRetail, + RenderPackSelectionSettings.Retail, + ActivePackDisplayName: null, + reason + FormatRetirementFailure(retirementFailure), + ActivationGeneration: checked(_snapshot.ActivationGeneration + 1)); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + if (_catalogSource is not null) + _catalogSource.Changed -= OnCatalogChanged; + _pending = null; + if (_preparation is { } preparation) + { + _preparation = null; + try + { + preparation.Work.GetAwaiter().GetResult(); + } + catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error)) + { + // The controller is terminal. Waiting prevents a candidate + // worker from racing device teardown; the active/fallback + // diagnostic was already published before shutdown began. + } + finally + { + preparation.Dispose(); + } + } + ClearAutomaticQuality(); + RetireActive(); + } + + private RenderPackActivationSnapshot Fail( + RenderPackSelectionSettings selection, + string reason, + long? registrationId = null) + { + _failedSelections[selection] = registrationId + ?? CurrentRegistrationId(selection); + string? retirementFailure = RetireActive(); + ClearAutomaticQuality(); + _snapshot = new RenderPackActivationSnapshot( + RenderPackActivationState.FailedToRetail, + RenderPackSelectionSettings.Retail, + ActivePackDisplayName: null, + reason + FormatRetirementFailure(retirementFailure), + ActivationGeneration: checked(_snapshot.ActivationGeneration + 1)); + return _snapshot; + } + + private RenderPackActivationSnapshot PublishRetail( + RenderPackSelectionSettings requested, + string? reason) + { + ClearAutomaticQuality(); + _snapshot = new RenderPackActivationSnapshot( + reason is null + ? RenderPackActivationState.Retail + : RenderPackActivationState.FailedToRetail, + RenderPackSelectionSettings.Retail, + ActivePackDisplayName: null, + reason, + ActivationGeneration: checked(_snapshot.ActivationGeneration + 1)); + return _snapshot; + } + + private string? RetireActive() + { + IRenderPackRuntime? active = _active; + _active = null; + _activeRegistrationId = 0; + ResetPerformanceTracking(null); + string? receiverFailure = TryClearReceiverPipelines(); + string? runtimeFailure = TryDispose(active); + return CombineRetirementFailures(receiverFailure, runtimeFailure); + } + + private bool AlreadyFailed( + RenderPackSelectionSettings selection, + long registrationId) + { + if (!_failedSelections.TryGetValue(selection, out long failedRegistrationId)) + return false; + if (failedRegistrationId != registrationId) + { + // Stable pack/version/preset IDs are persistence keys, not a + // process-lifetime quarantine key. A corrected registration gets + // one fresh explicit activation attempt. + _failedSelections.Remove(selection); + return false; + } + + RetireActive(); + PublishRetail( + selection, + "This pack selection already failed for the current registration " + + "and will not be retried."); + return true; + } + + private long CurrentRegistrationId(RenderPackSelectionSettings selection) + { + if (selection.IsRetail) + return 0; + return _catalog().TryGet(selection.PackId, out RenderPackCatalogEntry entry) + ? entry.RegistrationId + : 0; + } + + private void OnCatalogChanged(long revision) + { + _ = revision; + Interlocked.Exchange(ref _catalogChanged, 1); + } + + /// + /// Registry notifications may originate on plugin load/unload threads. + /// The notification only raises a flag; candidate invalidation, receiver + /// retirement, and runtime disposal remain atomic at this frame boundary. + /// + private RenderPackActivationSnapshot? ApplyCatalogChangeAtFrameBoundary() + { + RenderPackCatalogSource? source = _catalogSource; + if (source is null || Interlocked.Exchange(ref _catalogChanged, 0) == 0) + return null; + + RenderPackSelectionSettings selection = _snapshot.Selection; + if (selection.IsRetail) + return null; + + long selectedRegistrationId = _preparation?.Plan.Entry.RegistrationId + ?? _activeRegistrationId; + if (selectedRegistrationId == 0) + { + // A catalog event can precede an ordinary user/settings request. + // PlanSelection owns first-time admission and its precise reason; + // there is no published/candidate registration to retire here. + return null; + } + RenderPackCatalog catalog = source.Snapshot(); + if (!catalog.TryGet(selection.PackId, out RenderPackCatalogEntry current)) + { + _pending = null; + return Fail( + selection, + $"Render pack '{selection.PackId}' was withdrawn; acdream's default renderer is active.", + selectedRegistrationId); + } + if (!string.Equals( + current.Descriptor.PackVersion.ToString(), + selection.PackVersion, + StringComparison.Ordinal) + || (selectedRegistrationId != 0 + && current.RegistrationId != selectedRegistrationId)) + { + _pending = null; + return Fail( + selection, + $"Render pack '{selection.PackId}' was replaced by registration " + + $"version {current.Descriptor.PackVersion}; reselect it to activate the update.", + selectedRegistrationId); + } + return null; + } + + private void ResetPerformanceTracking(IRenderPackRuntime? runtime) + { + _performance.Reset(); + _observedPerformanceRuntime = runtime; + _observedResourceGeneration = + runtime is IRenderPackRuntimePerformanceSource source + ? source.CapturePerformanceMetrics().ResourceGeneration + : -1; + } + + private void ClearAutomaticQuality() + { + _autoQuality = null; + _pendingAutoQuality = null; + _pendingAutoFallbackReason = null; + } + + private static RenderQualityPreset? FindEffectivePreset( + RenderPackCatalogEntry entry, + AtmosphericQualityLevel quality) => + entry.Descriptor.QualityPresets.FirstOrDefault(preset => + preset.AutoEligible + && preset.Semantic == QualitySemantic(quality)); + + private static bool TryResolveAutomaticSetting( + RenderPackDescriptor descriptor, + RenderQualityPreset preset, + IReadOnlyDictionary userOverrides, + out bool automatic) + { + RenderSettingDeclaration? setting = descriptor.Settings.FirstOrDefault(value => + value.Semantic == RenderSettingSemantic.AutomaticQuality); + if (setting is null) + { + automatic = false; + return false; + } + + automatic = bool.Parse(RenderPackSettingResolution.Resolve( + setting, + preset, + userOverrides)); + return true; + } + + private static bool TryResolveAutomaticRange( + RenderPackCatalogEntry entry, + out RenderQualityPreset preset, + out AtmosphericQualityLevel initial, + out AtmosphericQualityLevel maximum, + out string? failure) + { + preset = null!; + initial = AtmosphericQualityLevel.Low; + maximum = AtmosphericQualityLevel.Low; + failure = null; + + RenderQualityPreset? low = FindEffectivePreset( + entry, + AtmosphericQualityLevel.Low); + if (low is null) + { + failure = "Automatic quality requires an AutoEligible Low semantic preset as its safe fallback."; + return false; + } + if (entry.PresetIncompatibilityReasons.TryGetValue( + low.Id, + out string? lowFailure) + && lowFailure is not null) + { + failure = "Automatic quality cannot support Low on this host: " + lowFailure; + return false; + } + + preset = low; + RenderQualityPreset? medium = FindEffectivePreset( + entry, + AtmosphericQualityLevel.Medium); + if (medium is null + || (entry.PresetIncompatibilityReasons.TryGetValue( + medium.Id, + out string? mediumFailure) + && mediumFailure is not null)) + { + return true; + } + + preset = medium; + initial = AtmosphericQualityLevel.Medium; + maximum = AtmosphericQualityLevel.Medium; + RenderQualityPreset? high = FindEffectivePreset( + entry, + AtmosphericQualityLevel.High); + if (high is not null + && (!entry.PresetIncompatibilityReasons.TryGetValue( + high.Id, + out string? highFailure) + || highFailure is null)) + { + maximum = AtmosphericQualityLevel.High; + } + return true; + } + + private static AtmosphericQualityBudget[] AutomaticBudgets( + RenderPackCatalogEntry entry) => + [ + Budget(entry, AtmosphericQualityLevel.Low), + Budget(entry, AtmosphericQualityLevel.Medium), + Budget(entry, AtmosphericQualityLevel.High), + ]; + + private static AtmosphericQualityBudget Budget( + RenderPackCatalogEntry entry, + AtmosphericQualityLevel level) => + AtmosphericQualityBudget.FromPreset( + entry.Descriptor.QualityPresets.Single(preset => + preset.Semantic == QualitySemantic(level))); + + private static RenderQualitySemantic QualitySemantic( + AtmosphericQualityLevel quality) => quality switch + { + AtmosphericQualityLevel.Low => RenderQualitySemantic.Low, + AtmosphericQualityLevel.Medium => RenderQualitySemantic.Medium, + AtmosphericQualityLevel.High => RenderQualitySemantic.High, + _ => throw new ArgumentOutOfRangeException(nameof(quality)), + }; + + private static bool TryQualityLevel( + RenderQualitySemantic semantic, + out AtmosphericQualityLevel quality) + { + quality = semantic switch + { + RenderQualitySemantic.Low => AtmosphericQualityLevel.Low, + RenderQualitySemantic.Medium => AtmosphericQualityLevel.Medium, + RenderQualitySemantic.High => AtmosphericQualityLevel.High, + _ => default, + }; + return semantic is RenderQualitySemantic.Low + or RenderQualitySemantic.Medium + or RenderQualitySemantic.High; + } + + private enum AutomaticPublicationMode : byte + { + Disable, + Initialise, + Preserve, + } + + private readonly record struct AutomaticPublication( + AutomaticPublicationMode Mode, + AtmosphericQualityBudget[]? Budgets, + AtmosphericQualityLevel Initial, + AtmosphericQualityLevel Maximum) + { + internal static AutomaticPublication Disabled { get; } = new( + AutomaticPublicationMode.Disable, + Budgets: null, + AtmosphericQualityLevel.Low, + AtmosphericQualityLevel.Low); + + internal static AutomaticPublication Preserve { get; } = new( + AutomaticPublicationMode.Preserve, + Budgets: null, + AtmosphericQualityLevel.Low, + AtmosphericQualityLevel.Low); + } + + private sealed record ActivationPlan( + RenderPackCatalogEntry Entry, + RenderPackSelectionSettings Selection, + RenderQualityPreset Preset, + RenderPackActivationExtent Extent, + bool RequirePerformanceSource, + AutomaticPublication AutomaticPublication); + + private sealed class PendingPreparation(ActivationPlan plan) : IDisposable + { + private PreparationOutcome? _outcome; + + internal ActivationPlan Plan { get; } = plan; + + internal Task Work { get; set; } = Task.CompletedTask; + + internal PreparationOutcome? Outcome + { + set => _outcome = value; + } + + internal PreparationOutcome TakeOutcome() + { + if (!Work.IsCompleted) + throw new InvalidOperationException("Render-pack preparation is not complete."); + PreparationOutcome outcome = _outcome + ?? throw new InvalidOperationException( + "Render-pack preparation completed without an outcome."); + _outcome = null; + return outcome; + } + + public void Dispose() + { + _outcome?.Dispose(); + _outcome = null; + } + } + + private sealed class PreparationOutcome( + IRenderPackRuntime? runtime, + IRenderPackReceiverPipelineCandidate? receiverCandidate, + string? failureReason) : IDisposable + { + private IRenderPackRuntime? _runtime = runtime; + private IRenderPackReceiverPipelineCandidate? _receiverCandidate = receiverCandidate; + + internal string? FailureReason { get; } = failureReason; + + internal static PreparationOutcome Ready( + IRenderPackRuntime runtime, + IRenderPackReceiverPipelineCandidate? receiverCandidate) => + new(runtime, receiverCandidate, null); + + internal static PreparationOutcome Failed(string reason) => + new(null, null, reason); + + internal IRenderPackRuntime TakeRuntime() + { + IRenderPackRuntime runtime = _runtime + ?? throw new InvalidOperationException( + "The prepared render-pack outcome has no runtime."); + _runtime = null; + return runtime; + } + + internal IRenderPackReceiverPipelineCandidate? TakeReceiverCandidate() + { + IRenderPackReceiverPipelineCandidate? candidate = _receiverCandidate; + _receiverCandidate = null; + return candidate; + } + + internal string? DisposeResources() + { + string? receiverFailure = TryDisposeReceiverCandidate(_receiverCandidate); + _receiverCandidate = null; + string? runtimeFailure = TryDispose(_runtime); + _runtime = null; + return CombineRetirementFailures(receiverFailure, runtimeFailure); + } + + public void Dispose() => _ = DisposeResources(); + } + + private static void Validate(in RenderPackFramePerformanceObservation value) + { + if (!double.IsFinite(value.PackAddedCpuMilliseconds) + || value.PackAddedCpuMilliseconds < 0d + || !double.IsFinite(value.AbsoluteEnhancedWorldReceiverCpuMilliseconds) + || value.AbsoluteEnhancedWorldReceiverCpuMilliseconds < 0d + || value.ViewportWidth <= 0 + || value.ViewportHeight <= 0 + || value.SampleCount <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(value), + "Render-pack frame measurements and target dimensions must be valid."); + } + } + + private static void Validate(in RenderPackRuntimePerformanceMetrics value) + { + if (value.ResourceGeneration < 0 + || (value.HasResolvedGpuMeasurement + && (!double.IsFinite(value.InclusiveResolvedGpuMilliseconds) + || value.InclusiveResolvedGpuMilliseconds < 0d)) + || value.RetainedGpuBytes < 0 + || value.TransientGpuBytes < 0) + { + throw new InvalidOperationException( + "The render-pack runtime published invalid performance metrics."); + } + } + + private static string? TryDispose(IRenderPackRuntime? runtime) + { + if (runtime is null) + return null; + try + { + runtime.Dispose(); + return null; + } + catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error)) + { + return error.GetBaseException().Message; + } + } + + private static string? TryDisposeReceiverCandidate( + IRenderPackReceiverPipelineCandidate? candidate) + { + if (candidate is null) + return null; + try + { + candidate.Dispose(); + return null; + } + catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error)) + { + return error.GetBaseException().Message; + } + } + + private string? TryClearReceiverPipelines() + { + if (_receiverPipelines is null) + return null; + try + { + _receiverPipelines.Clear(); + return null; + } + catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error)) + { + return error.GetBaseException().Message; + } + } + + private static string? CombineRetirementFailures(string? first, string? second) => + first is null ? second : second is null ? first : first + "; " + second; + + private static void BestEffortDisposeForFatal(IDisposable? value) + { + if (value is null) + return; + try + { + value.Dispose(); + } + catch + { + // Preserve the original terminal device/memory exception. Host + // teardown owns the remaining best-effort backend cleanup. + } + } + + private static string FormatRetirementFailure(string? reason) => + reason is null ? string.Empty : $" Pack resource retirement also failed: {reason}"; + + private static RenderPackSelectionSettings Normalize( + RenderPackSelectionSettings? selection) + { + if (selection is null + || string.IsNullOrWhiteSpace(selection.PackId) + || string.IsNullOrWhiteSpace(selection.PresetId)) + return RenderPackSelectionSettings.Retail; + if (selection.IsRetail) + return RenderPackSelectionSettings.Retail; + return selection; + } +} diff --git a/src/AcDream.App/Rendering/Packs/RenderPackDiagnostics.cs b/src/AcDream.App/Rendering/Packs/RenderPackDiagnostics.cs new file mode 100644 index 00000000..4275ad35 --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/RenderPackDiagnostics.cs @@ -0,0 +1,284 @@ +using System.Numerics; + +namespace AcDream.App.Rendering.Packs; + +internal readonly record struct RenderPackPassDiagnostics( + string PassId, + double GpuMilliseconds, + int DrawCalls, + int DispatchCalls); + +/// +/// Pack-owned facts sampled after a successful frame. Implementations expose +/// already-resolved asynchronous timestamp results; capturing this value must +/// never wait for the GPU. +/// +internal sealed record RenderPackRuntimeDiagnostics( + string EffectiveQuality, + long RetainedGpuBytes, + long TransientGpuBytes, + int ImageCount, + int BufferCount, + int DrawCalls, + int DispatchCalls, + int ShadowCasterCount, + int CascadeDrawCount, + int CpuClassificationCalls, + double SunElevationDegrees, + int ActiveDayGroup, + string Weather, + double WeatherIntensity, + bool Outdoor, + double DirectionalShadowStrength, + IReadOnlyList Passes) +{ + /// + /// Number of matrices addressed through the one shared world-transform + /// binding after the enhanced world receiver has appended its ordinary + /// draws to the directional-shadow prefix. Zero means that no shared + /// directional-shadow frame was active for the sampled frame. + /// + public uint SharedWorldTransformUsedInstances { get; init; } + + public IReadOnlyList CpuStages { get; init; } = []; + public AuthoredCelestialShadowSourceKind DirectionalShadowSourceKind + { + get; + init; + } + public int DirectionalShadowSourceObjectIndex { get; init; } = -1; + public uint DirectionalShadowSourceGfxObjId { get; init; } + public Vector3 DirectionalShadowSurfaceToLightDirection { get; init; } + public float DirectionalShadowLightElevationSin { get; init; } + public DirectionalShadowTransformChurnDiagnostics ShadowTransformChurn + { + get; + init; + } + + internal static RenderPackRuntimeDiagnostics Empty(string quality) => new( + quality, + RetainedGpuBytes: 0, + TransientGpuBytes: 0, + ImageCount: 0, + BufferCount: 0, + DrawCalls: 0, + DispatchCalls: 0, + ShadowCasterCount: 0, + CascadeDrawCount: 0, + CpuClassificationCalls: 0, + SunElevationDegrees: 0, + ActiveDayGroup: -1, + Weather: "unknown", + WeatherIntensity: 0, + Outdoor: false, + DirectionalShadowStrength: 0, + Passes: []); +} + +internal interface IRenderPackRuntimeDiagnosticsSource +{ + RenderPackRuntimeDiagnostics CaptureDiagnostics(); +} + +internal sealed record RenderPackDiagnosticsSnapshot( + RenderPackActivationState State, + string PackId, + string? PackVersion, + string PresetId, + string EffectiveQuality, + string? FailureReason, + long ActivationGeneration, + long RetainedGpuBytes, + long TransientGpuBytes, + int ImageCount, + int BufferCount, + int DrawCalls, + int DispatchCalls, + int ShadowCasterCount, + int CascadeDrawCount, + int CpuClassificationCalls, + double SunElevationDegrees, + int ActiveDayGroup, + string Weather, + double WeatherIntensity, + bool Outdoor, + double DirectionalShadowStrength, + IReadOnlyList Passes, + RenderPackPerformanceSnapshot Performance = default) +{ + public uint SharedWorldTransformUsedInstances { get; init; } + + public IReadOnlyList CpuStages { get; init; } = []; + public AuthoredCelestialShadowSourceKind DirectionalShadowSourceKind + { + get; + init; + } + public int DirectionalShadowSourceObjectIndex { get; init; } = -1; + public uint DirectionalShadowSourceGfxObjId { get; init; } + public Vector3 DirectionalShadowSurfaceToLightDirection { get; init; } + public float DirectionalShadowLightElevationSin { get; init; } + public DirectionalShadowTransformChurnDiagnostics ShadowTransformChurn + { + get; + init; + } + + internal static RenderPackDiagnosticsSnapshot Retail { get; } = new( + RenderPackActivationState.Retail, + PackId: "retail", + PackVersion: null, + PresetId: "off", + EffectiveQuality: "off", + FailureReason: null, + ActivationGeneration: 0, + RetainedGpuBytes: 0, + TransientGpuBytes: 0, + ImageCount: 0, + BufferCount: 0, + DrawCalls: 0, + DispatchCalls: 0, + ShadowCasterCount: 0, + CascadeDrawCount: 0, + CpuClassificationCalls: 0, + SunElevationDegrees: 0, + ActiveDayGroup: -1, + Weather: "unknown", + WeatherIntensity: 0, + Outdoor: false, + DirectionalShadowStrength: 0, + Passes: []); + + internal bool IsRetail => + string.Equals(PackId, "retail", StringComparison.Ordinal); +} + +internal interface IRenderPackDiagnosticsSnapshotSource +{ + RenderPackDiagnosticsSnapshot CaptureDiagnostics(); +} + +/// +/// Construction-order bridge used by screenshot and retained-UI diagnostics. +/// Until the render-thread controller is composed, it reports the exact +/// resource-free retail selection. +/// +internal sealed class DeferredRenderPackDiagnosticsSource + : IRenderPackDiagnosticsSnapshotSource +{ + private IRenderPackDiagnosticsSnapshotSource? _target; + + public RenderPackDiagnosticsSnapshot CaptureDiagnostics() => + _target?.CaptureDiagnostics() ?? RenderPackDiagnosticsSnapshot.Retail; + + internal IDisposable BindOwned(IRenderPackDiagnosticsSnapshotSource target) + { + ArgumentNullException.ThrowIfNull(target); + if (_target is not null && !ReferenceEquals(_target, target)) + throw new InvalidOperationException("Render-pack diagnostics are already bound."); + _target = target; + return new Binding(this, target); + } + + private void Unbind(IRenderPackDiagnosticsSnapshotSource target) + { + if (ReferenceEquals(_target, target)) + _target = null; + } + + private sealed class Binding( + DeferredRenderPackDiagnosticsSource owner, + IRenderPackDiagnosticsSnapshotSource target) : IDisposable + { + private DeferredRenderPackDiagnosticsSource? _owner = owner; + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)?.Unbind(target); + } +} + +internal static class RenderPackDiagnosticsFormatter +{ + internal static string Format(RenderPackDiagnosticsSnapshot value) => + $"[render-pack] state={value.State} " + + $"pack={value.PackId}@{value.PackVersion ?? "(missing)"} " + + $"preset={value.PresetId} effective={value.EffectiveQuality} " + + $"generation={value.ActivationGeneration} " + + $"gpuBytes={value.RetainedGpuBytes}/{value.TransientGpuBytes} " + + $"resources={value.ImageCount}i/{value.BufferCount}b " + + $"submit={value.DrawCalls}d/{value.DispatchCalls}c " + + $"worldTransforms={value.SharedWorldTransformUsedInstances}used " + + $"shadow={value.ShadowCasterCount}casters/{value.CascadeDrawCount}cascadeDraws/" + + $"{value.CpuClassificationCalls}classify " + + $"shadowSource={value.DirectionalShadowSourceKind}/" + + $"obj{value.DirectionalShadowSourceObjectIndex}/" + + $"0x{value.DirectionalShadowSourceGfxObjId:X8}/" + + $"dir({Invariant(value.DirectionalShadowSurfaceToLightDirection.X, "F4")}," + + $"{Invariant(value.DirectionalShadowSurfaceToLightDirection.Y, "F4")}," + + $"{Invariant(value.DirectionalShadowSurfaceToLightDirection.Z, "F4")})/" + + $"elevSin={Invariant(value.DirectionalShadowLightElevationSin, "F4")} " + + $"atmosphere={Invariant(value.SunElevationDegrees, "F2")}deg/day{value.ActiveDayGroup}/" + + $"{value.Weather}:{Invariant(value.WeatherIntensity, "F3")}/outdoor={value.Outdoor}/" + + $"shadowStrength={Invariant(value.DirectionalShadowStrength, "F3")} " + + $"perf=cpu-added:{Invariant(value.Performance.IncrementalCpuMillisecondsP50, "F3")}/" + + $"{Invariant(value.Performance.IncrementalCpuMillisecondsP95, "F3")}/" + + $"{Invariant(value.Performance.IncrementalCpuMillisecondsP99, "F3")}ms," + + $"receiver-cpu-absolute:{Invariant(value.Performance.AbsoluteReceiverCpuMillisecondsP50, "F3")}/" + + $"{Invariant(value.Performance.AbsoluteReceiverCpuMillisecondsP95, "F3")}/" + + $"{Invariant(value.Performance.AbsoluteReceiverCpuMillisecondsP99, "F3")}ms," + + $"gpu-inclusive:{Invariant(value.Performance.InclusiveGpuMillisecondsP50, "F3")}/" + + $"{Invariant(value.Performance.InclusiveGpuMillisecondsP95, "F3")}/" + + $"{Invariant(value.Performance.InclusiveGpuMillisecondsP99, "F3")}ms " + + $"passes={FormatPasses(value.Passes)} " + + $"cpuStages={FormatCpuStages(value.CpuStages)} " + + $"shadowTransformChurn={FormatShadowTransformChurn(value.ShadowTransformChurn)} " + + $"reason={value.FailureReason ?? "none"}"; + + private static string FormatShadowTransformChurn( + DirectionalShadowTransformChurnDiagnostics value) => + $"scene={value.CopiedSceneChanges}[transform={value.UpdateTransformChanges}," + + $"appearance={value.UpdateAppearanceChanges},sync={value.DynamicSynchronizationChanges};" + + $"animated={value.ActiveAnimatedStaticChanges},live={value.LiveDynamicRootChanges}," + + $"equipped={value.EquippedChildChanges}]/" + + $"casters={value.DedupedCasterSlots}/sceneFallback={value.SceneJournalFullRefresh}/" + + $"densityBulk={value.DensityBulkRefresh}/batchCopies={value.BatchedProjectionCopyCalls}/" + + $"matrices={value.ChangedMatrixSlots}/flightCurrent={value.FlightCurrentChangedMatrices}/" + + $"flightReplay={value.FlightPendingReplayMatrices}/uploaded={value.FlightUploadedMatrices}/" + + $"ranges={value.FlightUploadRanges}/bytes={value.FlightBytesWritten}/" + + $"flightFallback={value.FlightFullDynamicFallback}/denseDirect={value.DenseDirectUpload}/" + + $"denseReplay={value.DenseFlightReplay}/" + + $"classes=[terrain={value.CasterClasses.TerrainCommands}," + + $"outdoorStatic={value.CasterClasses.OutdoorStatics}," + + $"building={value.CasterClasses.Buildings}," + + $"animated={value.CasterClasses.AnimatedStatics}," + + $"localPlayer={value.CasterClasses.LocalPlayers}," + + $"remotePlayer={value.CasterClasses.RemotePlayers}," + + $"nonPlayerCreature={value.CasterClasses.NonPlayerCreatures}," + + $"otherLive={value.CasterClasses.OtherLiveDynamics}," + + $"equipped={value.CasterClasses.EquippedChildren}]"; + + private static string FormatPasses(IReadOnlyList passes) => + passes.Count == 0 + ? "none" + : string.Join( + ',', + passes.Select(static pass => + $"{pass.PassId}:{Invariant(pass.GpuMilliseconds, "F3")}ms/" + + $"{pass.DrawCalls}d/{pass.DispatchCalls}c")); + + private static string FormatCpuStages( + IReadOnlyList stages) => + stages.Count == 0 + ? "none" + : string.Join( + ',', + stages.Select(static stage => + $"{stage.Stage}:{stage.SampleCount}n/" + + $"{Invariant(stage.CpuMillisecondsP50, "F3")}/" + + $"{Invariant(stage.CpuMillisecondsP95, "F3")}/" + + $"{Invariant(stage.CpuMillisecondsP99, "F3")}ms")); + + private static string Invariant(double value, string format) => + value.ToString(format, System.Globalization.CultureInfo.InvariantCulture); +} diff --git a/src/AcDream.App/Rendering/Packs/RenderPackPerformanceWindow.cs b/src/AcDream.App/Rendering/Packs/RenderPackPerformanceWindow.cs new file mode 100644 index 00000000..974bc49a --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/RenderPackPerformanceWindow.cs @@ -0,0 +1,166 @@ +using AcDream.App.Diagnostics; + +namespace AcDream.App.Rendering.Packs; + +internal readonly record struct RenderPackPerformanceSnapshot( + int CpuSampleCount, + int AbsoluteReceiverCpuSampleCount, + int GpuSampleCount, + double IncrementalCpuMillisecondsP50, + double IncrementalCpuMillisecondsP95, + double IncrementalCpuMillisecondsP99, + double AbsoluteReceiverCpuMillisecondsP50, + double AbsoluteReceiverCpuMillisecondsP95, + double AbsoluteReceiverCpuMillisecondsP99, + double InclusiveGpuMillisecondsP50, + double InclusiveGpuMillisecondsP95, + double InclusiveGpuMillisecondsP99, + long ResidentGpuBytes, + long TransientGpuBytes) +{ + internal bool HasStableAutoWindow(int minimumSamples) => + minimumSamples > 0 + && CpuSampleCount >= minimumSamples + && GpuSampleCount >= minimumSamples; +} + +/// +/// Allocation-free facts captured from the active runtime after it has +/// submitted one complete frame. GPU time is the inclusive sum of already- +/// resolved asynchronous pack timers, including the enhanced-world receiver +/// pass; this contract never waits for the device. +/// +internal readonly record struct RenderPackRuntimePerformanceMetrics( + long ResourceGeneration, + bool HasResolvedGpuMeasurement, + double InclusiveResolvedGpuMilliseconds, + long RetainedGpuBytes, + long TransientGpuBytes); + +internal interface IRenderPackRuntimePerformanceSource +{ + RenderPackRuntimePerformanceMetrics CapturePerformanceMetrics(); +} + +internal readonly record struct RenderPackFramePerformanceObservation( + double PackAddedCpuMilliseconds, + bool StableFrameBoundary, + int ViewportWidth, + int ViewportHeight, + int SampleCount, + double AbsoluteEnhancedWorldReceiverCpuMilliseconds = 0d); + +internal static class RenderPackPerformanceScopeNames +{ + /// + /// The enhanced main-world pass uses the pack's receiver pipelines. Its + /// timestamp is intentionally part of the same total consumed by + /// diagnostics and Auto; measuring only the extra shadow/post passes would + /// hide the receiver shader's GPU cost. + /// + internal const string EnhancedWorldReceiver = "atmospheric-world-receiver"; +} + +/// +/// Allocation-free rolling evidence for one active pack runtime. Incremental +/// CPU samples bracket only work added by the pack. The complete enhanced-world +/// receiver recording is retained as a separate absolute diagnostic because it +/// is not an incremental delta and must never be compared with the pack's +/// incremental CPU budget. GPU samples are the already-resolved asynchronous +/// total including the receiver pass for the frame that issued them. The owner +/// resets this window on activation or quality generation changes so Auto can +/// never compare measurements from mixed resource layouts. +/// +internal sealed class RenderPackPerformanceWindow +{ + internal const int DefaultCapacity = 2048; + + private readonly FrameStatsBuffer _cpuMicroseconds; + private readonly FrameStatsBuffer _absoluteReceiverCpuMicroseconds; + private readonly FrameStatsBuffer _gpuMicroseconds; + private long _residentGpuBytes; + private long _transientGpuBytes; + + internal RenderPackPerformanceWindow(int capacity = DefaultCapacity) + { + if (capacity <= 0) + throw new ArgumentOutOfRangeException(nameof(capacity)); + _cpuMicroseconds = new FrameStatsBuffer(capacity); + _absoluteReceiverCpuMicroseconds = new FrameStatsBuffer(capacity); + _gpuMicroseconds = new FrameStatsBuffer(capacity); + } + + internal void Observe( + double incrementalCpuMilliseconds, + double absoluteReceiverCpuMilliseconds, + bool hasResolvedGpuMeasurement, + double inclusiveResolvedGpuMilliseconds, + long residentGpuBytes, + long transientGpuBytes) + { + if (!double.IsFinite(incrementalCpuMilliseconds) || incrementalCpuMilliseconds < 0d) + throw new ArgumentOutOfRangeException(nameof(incrementalCpuMilliseconds)); + if (!double.IsFinite(absoluteReceiverCpuMilliseconds) + || absoluteReceiverCpuMilliseconds < 0d) + { + throw new ArgumentOutOfRangeException(nameof(absoluteReceiverCpuMilliseconds)); + } + if (hasResolvedGpuMeasurement + && (!double.IsFinite(inclusiveResolvedGpuMilliseconds) + || inclusiveResolvedGpuMilliseconds < 0d)) + { + throw new ArgumentOutOfRangeException(nameof(inclusiveResolvedGpuMilliseconds)); + } + if (residentGpuBytes < 0) + throw new ArgumentOutOfRangeException(nameof(residentGpuBytes)); + if (transientGpuBytes < 0) + throw new ArgumentOutOfRangeException(nameof(transientGpuBytes)); + + _cpuMicroseconds.Push(ToMicroseconds(incrementalCpuMilliseconds)); + _absoluteReceiverCpuMicroseconds.Push( + ToMicroseconds(absoluteReceiverCpuMilliseconds)); + if (hasResolvedGpuMeasurement) + _gpuMicroseconds.Push(ToMicroseconds(inclusiveResolvedGpuMilliseconds)); + _residentGpuBytes = residentGpuBytes; + _transientGpuBytes = transientGpuBytes; + } + + internal RenderPackPerformanceSnapshot Snapshot() => new( + _cpuMicroseconds.Count, + _absoluteReceiverCpuMicroseconds.Count, + _gpuMicroseconds.Count, + ToMilliseconds(_cpuMicroseconds.Percentile(0.50)), + ToMilliseconds(_cpuMicroseconds.Percentile(0.95)), + ToMilliseconds(_cpuMicroseconds.Percentile(0.99)), + ToMilliseconds(_absoluteReceiverCpuMicroseconds.Percentile(0.50)), + ToMilliseconds(_absoluteReceiverCpuMicroseconds.Percentile(0.95)), + ToMilliseconds(_absoluteReceiverCpuMicroseconds.Percentile(0.99)), + ToMilliseconds(_gpuMicroseconds.Percentile(0.50)), + ToMilliseconds(_gpuMicroseconds.Percentile(0.95)), + ToMilliseconds(_gpuMicroseconds.Percentile(0.99)), + _residentGpuBytes, + _transientGpuBytes); + + internal int MinimumSampleCount => Math.Min( + _cpuMicroseconds.Count, + Math.Min( + _absoluteReceiverCpuMicroseconds.Count, + _gpuMicroseconds.Count)); + + internal void Reset() + { + _cpuMicroseconds.Reset(); + _absoluteReceiverCpuMicroseconds.Reset(); + _gpuMicroseconds.Reset(); + _residentGpuBytes = 0; + _transientGpuBytes = 0; + } + + private static long ToMicroseconds(double milliseconds) => + checked((long)Math.Round( + milliseconds * 1000d, + MidpointRounding.AwayFromZero)); + + private static double ToMilliseconds(long microseconds) => + microseconds / 1000d; +} diff --git a/src/AcDream.App/Rendering/Packs/RenderPackPreparationScheduler.cs b/src/AcDream.App/Rendering/Packs/RenderPackPreparationScheduler.cs new file mode 100644 index 00000000..7e08b872 --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/RenderPackPreparationScheduler.cs @@ -0,0 +1,49 @@ +namespace AcDream.App.Rendering.Packs; + +/// +/// Schedules one complete, unpublished render-pack candidate preparation. +/// Production uses the worker scheduler so shader I/O/validation and Vulkan +/// resource creation cannot block the render-frame boundary. Tests can inject +/// a deterministic scheduler without adding sleeps or timing races. +/// +internal interface IRenderPackPreparationScheduler +{ + Task Schedule(Action preparation); +} + +internal sealed class ThreadPoolRenderPackPreparationScheduler : + IRenderPackPreparationScheduler +{ + internal static ThreadPoolRenderPackPreparationScheduler Instance { get; } = new(); + + private ThreadPoolRenderPackPreparationScheduler() + { + } + + public Task Schedule(Action preparation) + { + ArgumentNullException.ThrowIfNull(preparation); + return Task.Run(preparation); + } +} + +/// +/// Synchronous fixture scheduler. Production composition must use +/// . +/// +internal sealed class InlineRenderPackPreparationScheduler : + IRenderPackPreparationScheduler +{ + internal static InlineRenderPackPreparationScheduler Instance { get; } = new(); + + private InlineRenderPackPreparationScheduler() + { + } + + public Task Schedule(Action preparation) + { + ArgumentNullException.ThrowIfNull(preparation); + preparation(); + return Task.CompletedTask; + } +} diff --git a/src/AcDream.App/Rendering/Packs/RenderPackReceiverPipelineCoordinator.cs b/src/AcDream.App/Rendering/Packs/RenderPackReceiverPipelineCoordinator.cs new file mode 100644 index 00000000..d583312e --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/RenderPackReceiverPipelineCoordinator.cs @@ -0,0 +1,124 @@ +using AcDream.App.Rendering.Wb; + +namespace AcDream.App.Rendering.Packs; + +/// +/// One complete, unpublished receiver-pipeline product. Candidate resources +/// stay owned here until the render-pack controller commits them at a stable +/// frame boundary. +/// +internal interface IRenderPackReceiverPipelineCandidate : IDisposable +{ +} + +internal interface IRenderPackReceiverPipelineCoordinator +{ + IRenderPackReceiverPipelineCandidate Prepare( + IDirectionalShadowReceiverSource? source, + int sampleCount); + + void Publish(IRenderPackReceiverPipelineCandidate candidate); + + void Clear(); +} + +/// +/// Couples terrain and world-mesh receiver pipelines into the same activation +/// transaction as their producing render-pack runtime. Preparation may compile +/// pipelines, publication only swaps already-complete state objects, and old +/// pipelines retire after both renderer owners point at the new generation. +/// +internal sealed class RenderPackReceiverPipelineCoordinator( + TerrainModernRenderer terrain, + WbDrawDispatcher worldMeshes) : IRenderPackReceiverPipelineCoordinator +{ + private readonly TerrainModernRenderer _terrain = terrain + ?? throw new ArgumentNullException(nameof(terrain)); + private readonly WbDrawDispatcher _worldMeshes = worldMeshes + ?? throw new ArgumentNullException(nameof(worldMeshes)); + + public IRenderPackReceiverPipelineCandidate Prepare( + IDirectionalShadowReceiverSource? source, + int sampleCount) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleCount); + TerrainModernRenderer.DirectionalShadowReceiverPipelineState? terrainState = + _terrain.PrepareDirectionalShadowReceiver(source, sampleCount); + try + { + WbDrawDispatcher.DirectionalShadowReceiverPipelineState? worldState = + _worldMeshes.PrepareDirectionalShadowReceiver(source, sampleCount); + return new Candidate(this, terrainState, worldState); + } + catch + { + terrainState?.Dispose(); + throw; + } + } + + public void Publish(IRenderPackReceiverPipelineCandidate candidate) + { + ArgumentNullException.ThrowIfNull(candidate); + if (candidate is not Candidate prepared || !ReferenceEquals(prepared.Owner, this)) + throw new ArgumentException("Receiver candidate belongs to another coordinator.", nameof(candidate)); + + (TerrainModernRenderer.DirectionalShadowReceiverPipelineState? terrainState, + WbDrawDispatcher.DirectionalShadowReceiverPipelineState? worldState) = prepared.Take(); + TerrainModernRenderer.DirectionalShadowReceiverPipelineState? oldTerrain = + _terrain.SwapDirectionalShadowReceiver(terrainState); + WbDrawDispatcher.DirectionalShadowReceiverPipelineState? oldWorld = + _worldMeshes.SwapDirectionalShadowReceiver(worldState); + + // Vulkan pipeline disposal is flight-fence retirement. Do this only + // after both owners publish the complete new generation. + oldTerrain?.Dispose(); + oldWorld?.Dispose(); + } + + public void Clear() + { + TerrainModernRenderer.DirectionalShadowReceiverPipelineState? oldTerrain = + _terrain.SwapDirectionalShadowReceiver(null); + WbDrawDispatcher.DirectionalShadowReceiverPipelineState? oldWorld = + _worldMeshes.SwapDirectionalShadowReceiver(null); + oldTerrain?.Dispose(); + oldWorld?.Dispose(); + } + + private sealed class Candidate( + RenderPackReceiverPipelineCoordinator owner, + TerrainModernRenderer.DirectionalShadowReceiverPipelineState? terrain, + WbDrawDispatcher.DirectionalShadowReceiverPipelineState? world) : + IRenderPackReceiverPipelineCandidate + { + private TerrainModernRenderer.DirectionalShadowReceiverPipelineState? _terrain = terrain; + private WbDrawDispatcher.DirectionalShadowReceiverPipelineState? _world = world; + private bool _taken; + + internal RenderPackReceiverPipelineCoordinator Owner { get; } = owner; + + internal (TerrainModernRenderer.DirectionalShadowReceiverPipelineState?, + WbDrawDispatcher.DirectionalShadowReceiverPipelineState?) Take() + { + ObjectDisposedException.ThrowIf(_taken, this); + _taken = true; + TerrainModernRenderer.DirectionalShadowReceiverPipelineState? terrainState = _terrain; + WbDrawDispatcher.DirectionalShadowReceiverPipelineState? worldState = _world; + _terrain = null; + _world = null; + return (terrainState, worldState); + } + + public void Dispose() + { + if (_taken) + return; + _taken = true; + _terrain?.Dispose(); + _world?.Dispose(); + _terrain = null; + _world = null; + } + } +} diff --git a/src/AcDream.App/Rendering/Packs/RenderPackResourceBudgetPlanner.cs b/src/AcDream.App/Rendering/Packs/RenderPackResourceBudgetPlanner.cs new file mode 100644 index 00000000..8ddff76e --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/RenderPackResourceBudgetPlanner.cs @@ -0,0 +1,270 @@ +using AcDream.Plugin.Abstractions.Rendering; +using AcDream.App.Rendering.Wb; + +namespace AcDream.App.Rendering.Packs; + +internal readonly record struct RenderPackResourceBudget( + long RetainedGpuBytes, + long MultisampleGpuBytes, + int LargestImageWidth, + int LargestImageHeight, + int LargestImageLayerCount) +{ + internal long TotalGpuBytes => checked(RetainedGpuBytes + MultisampleGpuBytes); +} + +/// +/// Resolves declaration extents against the real main-world size before an +/// executor allocates any size-dependent image. Declared byte estimates are +/// useful during discovery, but cannot prove a 1080p/1440p/4K preset ceiling. +/// This is the allocation-time authority for the images API-v1 executors +/// actually keep alive. +/// +internal static class RenderPackResourceBudgetPlanner +{ + private const int HdrColorBytesPerPixel = 8; + private const int LdrColorBytesPerPixel = 4; + private const int DirectionalDepthBytesPerPixel = 4; + private const int MainWorldDepthBytesPerPixel = 4; + // Production Vulkan owns two frame-flight slots. Directional shadows + // materialize one shared demand-growth N.5 transform arena in each slot before an + // ordinary world frame can consume the pack, so admission must include + // those mandatory buffers rather than discovering them after the first + // shadow pass has already published a borrow. + private const int DirectionalShadowTransformFlightSlots = 2; + + internal static RenderPackResourceBudget Resolve( + RenderPackDescriptor descriptor, + RenderQualityPreset preset, + int mainWorldWidth, + int mainWorldHeight, + int sampleCount) + { + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(preset); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(mainWorldWidth); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(mainWorldHeight); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleCount); + + // Every executable graph replaces the main world attachment with one + // RGBA16F colour image and one D24S8 depth image. The resolve images + // remain alive for the complete active target set. + long mainPixels = checked((long)mainWorldWidth * mainWorldHeight); + long retained = checked(mainPixels + * (HdrColorBytesPerPixel + MainWorldDepthBytesPerPixel)); + long multisample = sampleCount > 1 + ? checked(mainPixels + * (HdrColorBytesPerPixel + MainWorldDepthBytesPerPixel) + * sampleCount) + : 0L; + int largestWidth = mainWorldWidth; + int largestHeight = mainWorldHeight; + int largestLayers = 1; + + HashSet writtenResources = descriptor.Passes + .SelectMany(static pass => pass.ResourceWrites) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (RenderResourceDeclaration resource in descriptor.Resources) + { + if (resource.Semantic == RenderResourceSemantic.MainWorldHdr + || !writtenResources.Contains(resource.Id)) + { + continue; + } + if (UsesFusedAtmosphericPostProcess(preset) + && resource.Semantic is RenderResourceSemantic.BloomPing + or RenderResourceSemantic.BloomPong) + { + // The fused Low filmic shader evaluates the declared bloom + // extraction/filter directly from world colour + sun rays. + // These ping/pong images have no executing writer or reader. + continue; + } + + if (resource.Kind is not RenderResourceKind.Image2D + and not RenderResourceKind.Image2DArray) + { + throw new NotSupportedException( + $"Resource '{resource.Id}' is not an API-v1 image resource."); + } + + RenderQualityResourceOverride? resourceOverride = preset.ResourceOverrides + .FirstOrDefault(value => string.Equals( + value.ResourceId, + resource.Id, + StringComparison.OrdinalIgnoreCase)); + RenderExtentDeclaration extent = resourceOverride?.Extent + ?? resource.Extent + ?? throw new NotSupportedException( + $"Image resource '{resource.Id}' has no extent."); + (int width, int height) = ResolveExtent( + resource.Id, + extent, + mainWorldWidth, + mainWorldHeight); + int layers = extent.Layers; + if (layers <= 0) + { + throw new NotSupportedException( + $"Image resource '{resource.Id}' has no image layers."); + } + + int bytesPerPixel = resource.Format switch + { + RenderFormatClass.HdrColor => HdrColorBytesPerPixel, + RenderFormatClass.LdrColor or RenderFormatClass.SingleChannel => + LdrColorBytesPerPixel, + RenderFormatClass.DirectionalDepth => DirectionalDepthBytesPerPixel, + _ => throw new NotSupportedException( + $"Image resource '{resource.Id}' has unsupported format " + + $"'{resource.Format}'."), + }; + retained = checked(retained + + ((long)width * height * layers * bytesPerPixel)); + largestWidth = Math.Max(largestWidth, width); + largestHeight = Math.Max(largestHeight, height); + largestLayers = Math.Max(largestLayers, layers); + } + + if (descriptor.Passes.Any(pass => + pass.Semantic == RenderPassSemantic.DirectionalShadowDepth)) + { + retained = checked( + retained + + DirectionalShadowTransformFlightSlots + * WorldTransformCapacityPolicy.InitialBindingSizeBytes); + } + + return new RenderPackResourceBudget( + retained, + multisample, + largestWidth, + largestHeight, + largestLayers); + } + + private static bool UsesFusedAtmosphericPostProcess( + RenderQualityPreset preset) => + (preset.ExecutionHints + & RenderQualityExecutionHints.FusedAtmosphericPostProcess) != 0; + + internal static RenderPackResourceBudget RequireWithinPreset( + RenderPackDescriptor descriptor, + RenderQualityPreset preset, + int mainWorldWidth, + int mainWorldHeight, + int sampleCount) + { + RenderPackResourceBudget budget = Resolve( + descriptor, + preset, + mainWorldWidth, + mainWorldHeight, + sampleCount); + if (budget.RetainedGpuBytes > preset.MaxResidentGpuBytes) + { + throw new NotSupportedException( + $"Render pack preset '{preset.Id}' needs " + + $"{budget.RetainedGpuBytes} resident GPU bytes at " + + $"{mainWorldWidth}x{mainWorldHeight}; its declared ceiling is " + + $"{preset.MaxResidentGpuBytes}. Select a compatible preset or " + + "reduce the main-world resolution."); + } + return budget; + } + + /// + /// Allocation-time gate against the selected adapter and the host's + /// explicit optional-memory share. Catalog checks can reject absolute + /// preset extents, but only this point knows the resolved viewport-relative + /// sizes and multisample attachment bytes. + /// + internal static RenderPackResourceBudget RequireWithinHost( + RenderPackDescriptor descriptor, + RenderQualityPreset preset, + int mainWorldWidth, + int mainWorldHeight, + int sampleCount, + RenderPackHostCapabilities capabilities) + { + ArgumentNullException.ThrowIfNull(capabilities); + RenderPackResourceBudget budget = RequireWithinPreset( + descriptor, + preset, + mainWorldWidth, + mainWorldHeight, + sampleCount); + if (budget.LargestImageWidth > capabilities.MaxImageDimension2D + || budget.LargestImageHeight > capabilities.MaxImageDimension2D) + { + throw new NotSupportedException( + $"Render pack preset '{preset.Id}' resolves an image to " + + $"{budget.LargestImageWidth}x{budget.LargestImageHeight} at " + + $"{mainWorldWidth}x{mainWorldHeight}; this device's maximum " + + $"2-D image edge is {capabilities.MaxImageDimension2D}."); + } + if (budget.LargestImageLayerCount > capabilities.MaxImageArrayLayers) + { + throw new NotSupportedException( + $"Render pack preset '{preset.Id}' needs " + + $"{budget.LargestImageLayerCount} image-array layers; this " + + $"device provides {capabilities.MaxImageArrayLayers}."); + } + if (budget.RetainedGpuBytes > capabilities.MaxPackResidentBytes) + { + throw new NotSupportedException( + $"Render pack preset '{preset.Id}' needs " + + $"{budget.RetainedGpuBytes} resident GPU bytes at " + + $"{mainWorldWidth}x{mainWorldHeight}; this host permits " + + $"{capabilities.MaxPackResidentBytes} under its " + + $"{capabilities.MemoryPolicyDescription} policy."); + } + if (budget.MultisampleGpuBytes > capabilities.MaxPackTransientBytes) + { + throw new NotSupportedException( + $"Render pack preset '{preset.Id}' needs " + + $"{budget.MultisampleGpuBytes} transient multisample GPU bytes " + + $"at {mainWorldWidth}x{mainWorldHeight} x{sampleCount}; this " + + $"host permits {capabilities.MaxPackTransientBytes} under its " + + $"{capabilities.MemoryPolicyDescription} policy."); + } + return budget; + } + + private static (int Width, int Height) ResolveExtent( + string resourceId, + RenderExtentDeclaration extent, + int mainWorldWidth, + int mainWorldHeight) + { + if (!double.IsFinite(extent.Width) + || !double.IsFinite(extent.Height) + || extent.Width <= 0d + || extent.Height <= 0d) + { + throw new NotSupportedException( + $"Image resource '{resourceId}' has an invalid extent."); + } + + try + { + return extent.Mode switch + { + RenderExtentMode.AbsolutePixels => + (checked((int)extent.Width), checked((int)extent.Height)), + RenderExtentMode.RelativeToMainWorld or RenderExtentMode.RelativeToOutput => + (Math.Max(1, checked((int)Math.Ceiling(mainWorldWidth * extent.Width))), + Math.Max(1, checked((int)Math.Ceiling(mainWorldHeight * extent.Height)))), + _ => throw new NotSupportedException( + $"Image resource '{resourceId}' has unsupported extent mode " + + $"'{extent.Mode}'."), + }; + } + catch (OverflowException error) + { + throw new NotSupportedException( + $"Image resource '{resourceId}' extent overflows the host image range.", + error); + } + } +} diff --git a/src/AcDream.App/Rendering/Packs/RenderPackSelectionBinding.cs b/src/AcDream.App/Rendering/Packs/RenderPackSelectionBinding.cs new file mode 100644 index 00000000..23bc4537 --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/RenderPackSelectionBinding.cs @@ -0,0 +1,85 @@ +using AcDream.App.Settings; +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.App.Rendering.Packs; + +/// +/// Bridges committed Display settings to the render-thread controller. The +/// controller performs all GPU work at the explicit frame boundary; this +/// binding only queues stable logical selections and persists a safe retail +/// fallback once per failed activation generation. +/// +internal sealed class RenderPackSelectionBinding : IDisposable +{ + private readonly RuntimeSettingsController _settings; + private readonly RenderPackController _controller; + private readonly Action _log; + private long _fallbackPersistedGeneration = -1; + private bool _suppressDisplayEdge; + private bool _disposed; + + internal RenderPackSelectionBinding( + RuntimeSettingsController settings, + RenderPackController controller, + Action? log = null) + { + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _controller = controller ?? throw new ArgumentNullException(nameof(controller)); + _log = log ?? (_ => { }); + _settings.DisplayChanged += OnDisplayChanged; + _controller.Request(_settings.Display.RenderPack); + } + + internal RenderPackActivationSnapshot ApplyAtFrameBoundary( + RenderPackActivationExtent extent) + { + ObjectDisposedException.ThrowIf(_disposed, this); + RenderPackActivationSnapshot snapshot = _controller.ApplyAtFrameBoundary(extent); + if (snapshot.State != RenderPackActivationState.FailedToRetail + || snapshot.ActivationGeneration == _fallbackPersistedGeneration + || _settings.Display.RenderPack.IsRetail) + return snapshot; + + _fallbackPersistedGeneration = snapshot.ActivationGeneration; + _suppressDisplayEdge = true; + try + { + _settings.SaveDisplay(_settings.Display with + { + RenderPack = RenderPackSelectionSettings.Retail, + }); + } + finally + { + _suppressDisplayEdge = false; + } + + if (_settings.Display.RenderPack.IsRetail) + { + _log( + $"[render-pack] selection failed; persisted acdream default (retail-faithful): " + + snapshot.Reason); + } + else + { + _log( + $"[render-pack] selection failed and retail fallback could not be persisted: " + + snapshot.Reason); + } + return snapshot; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _settings.DisplayChanged -= OnDisplayChanged; + } + + private void OnDisplayChanged(DisplaySettings display) + { + if (!_disposed && !_suppressDisplayEdge) + _controller.Request(display.RenderPack); + } +} diff --git a/src/AcDream.App/Rendering/Packs/RenderPackSettingResolution.cs b/src/AcDream.App/Rendering/Packs/RenderPackSettingResolution.cs new file mode 100644 index 00000000..9c563ac6 --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/RenderPackSettingResolution.cs @@ -0,0 +1,77 @@ +using AcDream.Plugin.Abstractions.Rendering; +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.App.Rendering.Packs; + +internal static class RenderPackSettingResolution +{ + internal static RenderPackValidationResult ValidateUserOverrides( + RenderPackDescriptor descriptor, + RenderPackSettingOverrides overrides) + { + ArgumentNullException.ThrowIfNull(descriptor); + if (overrides is null) + return Invalid($"Render pack '{descriptor.Id}' has a null user-setting override map."); + + Dictionary settings = descriptor.Settings + .ToDictionary(setting => setting.Id, StringComparer.OrdinalIgnoreCase); + foreach ((string id, string value) in overrides) + { + if (!settings.TryGetValue(id, out RenderSettingDeclaration? setting)) + { + return Invalid( + $"Render pack '{descriptor.Id}' has a user override for unknown " + + $"setting '{id}'."); + } + if (!RenderPackSettingValueCodec.TryEncode(setting, value, out _)) + { + return Invalid( + $"Render pack '{descriptor.Id}' user override '{id}' has invalid " + + $"{setting.Kind} value '{value}'."); + } + } + return RenderPackValidationResult.Valid(); + } + + internal static string Resolve( + RenderSettingDeclaration setting, + RenderQualityPreset preset, + IReadOnlyDictionary? userOverrides) + { + ArgumentNullException.ThrowIfNull(setting); + ArgumentNullException.ThrowIfNull(preset); + if (TryGet(userOverrides, setting.Id, out string? user)) + return user; + RenderQualitySettingOverride? presetValue = preset.SettingOverrides + .FirstOrDefault(value => string.Equals( + value.SettingId, + setting.Id, + StringComparison.OrdinalIgnoreCase)); + return presetValue?.Value ?? setting.DefaultValue; + } + + private static bool TryGet( + IReadOnlyDictionary? values, + string id, + out string value) + { + if (values is not null && values.TryGetValue(id, out value!)) + return true; + if (values is not null) + { + foreach ((string key, string candidate) in values) + { + if (string.Equals(key, id, StringComparison.OrdinalIgnoreCase)) + { + value = candidate; + return true; + } + } + } + value = string.Empty; + return false; + } + + private static RenderPackValidationResult Invalid(string reason) => + RenderPackValidationResult.Invalid(reason); +} diff --git a/src/AcDream.App/Rendering/Packs/RenderPackShaderAssets.cs b/src/AcDream.App/Rendering/Packs/RenderPackShaderAssets.cs new file mode 100644 index 00000000..cb33d382 --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/RenderPackShaderAssets.cs @@ -0,0 +1,71 @@ +using System.Collections.Immutable; +using AcDream.App.Rendering.Gpu; +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.App.Rendering.Packs; + +internal static class RenderPackShaderAssets +{ + internal static ValidatedRenderPackShaderAssets Validate( + RenderPackDescriptor descriptor, + IRenderPackAssets assets) + { + RenderPackValidationResult result = RenderPackValidator.ValidateSelectedAssets( + descriptor, + assets, + out ValidatedRenderPackShaderAssets? validated); + if (!result.Success) + throw new InvalidDataException(result.Reason); + return validated!; + } + + internal static GpuShaderSet LoadPass( + RenderPackDescriptor descriptor, + ValidatedRenderPackShaderAssets assets, + RenderPassDeclaration pass) => new( + $"{descriptor.Id}:{pass.Id}", + assets.Copy(pass.VertexShaderAsset), + assets.Copy(pass.FragmentShaderAsset)); + + internal static GpuShaderSet LoadVariant( + RenderPackDescriptor descriptor, + ValidatedRenderPackShaderAssets assets, + PipelineVariantDeclaration variant) => new( + $"{descriptor.Id}:{variant.Id}", + assets.Copy(variant.VertexShaderAsset), + assets.Copy(variant.FragmentShaderAsset)); +} + +/// +/// Candidate-owned immutable shader snapshot. The plugin asset provider is +/// read exactly once during selected-candidate validation; pipeline creation +/// only copies bytes from this snapshot and cannot reopen a mutable plugin +/// stream or resolve a second path. +/// +internal sealed class ValidatedRenderPackShaderAssets +{ + private readonly IReadOnlyDictionary> _assets; + + internal ValidatedRenderPackShaderAssets( + IReadOnlyDictionary assets) + { + ArgumentNullException.ThrowIfNull(assets); + var owned = new Dictionary>( + assets.Count, + StringComparer.Ordinal); + foreach ((string key, byte[] bytes) in assets) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentNullException.ThrowIfNull(bytes); + owned.Add(key, [.. bytes]); + } + _assets = owned; + } + + internal byte[] Copy(string key) + { + if (!_assets.TryGetValue(key, out ImmutableArray bytes)) + throw new InvalidDataException($"Validated render-pack shader '{key}' is missing."); + return [.. bytes]; + } +} diff --git a/src/AcDream.App/Rendering/Packs/RenderPackTextureBindingResolver.cs b/src/AcDream.App/Rendering/Packs/RenderPackTextureBindingResolver.cs new file mode 100644 index 00000000..55e5df0c --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/RenderPackTextureBindingResolver.cs @@ -0,0 +1,54 @@ +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.App.Rendering.Packs; + +internal readonly record struct RenderPackTextureInput( + RenderSemanticInput? Semantic, + string? ResourceId) +{ + internal static RenderPackTextureInput FromSemantic(RenderSemanticInput value) => + new(value, null); + + internal static RenderPackTextureInput FromResource(string value) => + new(null, value); +} + +/// +/// Binary API-v1 texture-slot rule. Ordinary sampled inputs occupy push +/// TextureIndexA..D in declaration order: sampled semantic inputs first, then +/// declared resource reads. Directional depth uses its dedicated binding-6 +/// texture slot and therefore does not consume A..D. +/// +internal static class RenderPackTextureBindingResolver +{ + internal static IReadOnlyList Resolve( + RenderPassDeclaration pass, + IReadOnlyDictionary resources) + { + ArgumentNullException.ThrowIfNull(pass); + ArgumentNullException.ThrowIfNull(resources); + var result = new List(4); + foreach (RenderSemanticInput semantic in pass.SemanticInputs) + { + if (semantic is RenderSemanticInput.WorldColor + or RenderSemanticInput.SceneDepth + or RenderSemanticInput.SceneNormals) + result.Add(RenderPackTextureInput.FromSemantic(semantic)); + } + foreach (string resourceId in pass.ResourceReads) + { + if (!resources.TryGetValue(resourceId, out RenderResourceDeclaration? resource)) + throw new InvalidOperationException($"Unknown render-pack resource '{resourceId}'."); + if (resource.Format == RenderFormatClass.DirectionalDepth + && pass.SemanticInputs.Contains(RenderSemanticInput.DirectionalShadowMaps)) + continue; + result.Add(RenderPackTextureInput.FromResource(resourceId)); + } + if (result.Count > 4) + { + throw new InvalidOperationException( + $"Render-pack pass '{pass.Id}' exceeds the four API-v1 texture slots."); + } + return result; + } +} diff --git a/src/AcDream.App/Rendering/Packs/RenderPackValidation.cs b/src/AcDream.App/Rendering/Packs/RenderPackValidation.cs new file mode 100644 index 00000000..c16e67e1 --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/RenderPackValidation.cs @@ -0,0 +1,1604 @@ +using System.Buffers.Binary; +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.App.Rendering.Packs; + +internal sealed record RenderPackHostCapabilities( + IReadOnlySet Available, + int MaxImageDimension2D, + int MaxImageArrayLayers, + long MaxPackResidentBytes, + long MaxPackTransientBytes = 512L * 1024 * 1024, + string MemoryPolicyDescription = "API-v1 conformance ceiling") +{ + internal static RenderPackHostCapabilities Conformance { get; } = new( + Enum.GetValues().ToHashSet(), + MaxImageDimension2D: 16_384, + MaxImageArrayLayers: 256, + MaxPackResidentBytes: 256L * 1024 * 1024, + MaxPackTransientBytes: 512L * 1024 * 1024); +} + +internal readonly record struct RenderPackValidationResult( + bool Success, + string? Reason) +{ + internal static RenderPackValidationResult Valid() => new(true, null); + + internal static RenderPackValidationResult Invalid(string reason) => + new(false, reason); +} + +/// +/// Complete side-effect-free declaration validation plus explicitly-selected +/// asset validation. Discovery calls only ; +/// is intentionally separate so merely +/// installing a pack never opens files or creates GPU work. +/// +internal static class RenderPackValidator +{ + private const long AbsolutePackByteCeiling = 256L * 1024 * 1024; + private const int AbsoluteImageDimension2DCeiling = 16_384; + private const int AbsoluteImageArrayLayerCeiling = 256; + private const int MaximumShaderBytes = RenderPackShaderAbi.MaximumShaderAssetBytes; + private const uint SpirvMagic = 0x0723_0203u; + + internal static RenderPackValidationResult ValidateDescriptor( + RenderPackDescriptor? descriptor, + RenderPackHostCapabilities capabilities) + { + ArgumentNullException.ThrowIfNull(capabilities); + if (descriptor is null) + return Invalid("The pack descriptor is missing."); + if (!IsStableId(descriptor.Id)) + return Invalid("The pack id must be a stable lowercase logical id."); + if (string.IsNullOrWhiteSpace(descriptor.DisplayName)) + return Invalid($"Pack '{descriptor.Id}' has no display name."); + if (string.IsNullOrWhiteSpace(descriptor.FeatureSummary)) + return Invalid($"Pack '{descriptor.Id}' has no feature summary."); + if (descriptor.PackVersion is null) + return Invalid($"Pack '{descriptor.Id}' has no version."); + if (!RenderPackApi.IsSupported(descriptor.PackApiVersion)) + { + return Invalid( + $"Pack '{descriptor.Id}' requires render-pack API " + + $"{descriptor.PackApiVersion}; this client supports " + + $"{RenderPackApi.MinimumSupported}..{RenderPackApi.Current}."); + } + + string? nullList = FirstNullList(descriptor); + if (nullList is not null) + return Invalid($"Pack '{descriptor.Id}' has a null {nullList} declaration list."); + + foreach (RenderCapability required in descriptor.RequiredCapabilities) + { + if (!capabilities.Available.Contains(required)) + { + return Invalid( + $"Pack '{descriptor.Id}' requires unsupported capability " + + $"'{required}'."); + } + } + + RenderPackValidationResult semanticCapabilities = + ValidateSemanticCapabilities(descriptor, capabilities); + if (!semanticCapabilities.Success) + return semanticCapabilities; + + RenderPackValidationResult ids = ValidateUniqueIds(descriptor); + if (!ids.Success) + return ids; + RenderPackValidationResult resources = ValidateResources(descriptor, capabilities); + if (!resources.Success) + return resources; + RenderPackValidationResult passes = ValidatePasses(descriptor); + if (!passes.Success) + return passes; + RenderPackValidationResult replays = ValidateReplays(descriptor); + if (!replays.Success) + return replays; + RenderPackValidationResult variants = ValidateVariants(descriptor); + if (!variants.Success) + return variants; + RenderPackValidationResult settings = ValidateSettings(descriptor); + if (!settings.Success) + return settings; + RenderPackValidationResult presets = ValidatePresets(descriptor, capabilities); + if (!presets.Success) + return presets; + RenderPackValidationResult semantics = ValidateSemanticRoles(descriptor); + if (!semantics.Success) + return semantics; + return ValidateAtmosphere(descriptor); + } + + internal static RenderPackValidationResult ValidateSelectedAssets( + RenderPackDescriptor descriptor, + IRenderPackAssets assets) => + ValidateSelectedAssets(descriptor, assets, out _); + + internal static RenderPackValidationResult ValidateSelectedAssets( + RenderPackDescriptor descriptor, + IRenderPackAssets assets, + out ValidatedRenderPackShaderAssets? validatedAssets) + { + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(assets); + validatedAssets = null; + + ShaderValidationRequest[] requests = descriptor.Passes + .SelectMany(static pass => new[] + { + new ShaderValidationRequest( + pass.VertexShaderAsset, RenderPackShaderStage.Vertex, pass, null), + new ShaderValidationRequest( + pass.FragmentShaderAsset, RenderPackShaderStage.Fragment, pass, null), + }) + .Concat(descriptor.PipelineVariants.SelectMany(static variant => new[] + { + new ShaderValidationRequest( + variant.VertexShaderAsset, RenderPackShaderStage.Vertex, null, variant), + new ShaderValidationRequest( + variant.FragmentShaderAsset, RenderPackShaderStage.Fragment, null, variant), + })) + .ToArray(); + + byte[] readBuffer = new byte[4096]; + var validated = new Dictionary(StringComparer.Ordinal); + foreach (IGrouping group in + requests.GroupBy(static request => request.Key, StringComparer.Ordinal)) + { + string key = group.Key; + if (!IsSafeAssetKey(key)) + return Invalid($"Pack '{descriptor.Id}' declares unsafe asset key '{key}'."); + + try + { + using Stream stream = assets.OpenRead(key); + if (stream is null || !stream.CanRead) + return Invalid($"Pack '{descriptor.Id}' asset '{key}' is not readable."); + using var destination = new MemoryStream(); + while (true) + { + int count = stream.Read(readBuffer); + if (count == 0) + break; + if (destination.Length + count > MaximumShaderBytes) + { + return Invalid( + $"Pack '{descriptor.Id}' asset '{key}' exceeds " + + $"the {MaximumShaderBytes}-byte shader ceiling."); + } + destination.Write(readBuffer, 0, count); + } + + byte[] spirv = destination.ToArray(); + if (spirv.Length < 4 + || (spirv.Length & 3) != 0 + || BinaryPrimitives.ReadUInt32LittleEndian(spirv) != SpirvMagic) + { + return Invalid( + $"Pack '{descriptor.Id}' asset '{key}' is not valid SPIR-V."); + } + + foreach (ShaderValidationRequest request in group) + { + RenderPackSpirvValidationResult validation = request.Pass is not null + ? RenderPackSpirvValidator.ValidatePassShader( + spirv, request.Stage, request.Pass) + : RenderPackSpirvValidator.ValidatePipelineVariantShader( + spirv, request.Stage, request.Variant!); + if (!validation.Success) + { + return Invalid( + $"Pack '{descriptor.Id}' asset '{key}' fails render-pack shader ABI v1: " + + validation.Reason + "."); + } + } + validated.Add(key, spirv); + } + catch (Exception error) when (error is not OutOfMemoryException) + { + return Invalid( + $"Pack '{descriptor.Id}' asset '{key}' could not be opened: " + + error.GetBaseException().Message); + } + } + + validatedAssets = new ValidatedRenderPackShaderAssets(validated); + return RenderPackValidationResult.Valid(); + } + + private sealed record ShaderValidationRequest( + string Key, + RenderPackShaderStage Stage, + RenderPassDeclaration? Pass, + PipelineVariantDeclaration? Variant); + + private static RenderPackValidationResult ValidateSemanticRoles( + RenderPackDescriptor descriptor) + { + RenderPackValidationResult unique = UniqueNonCustomSemantics( + descriptor, + descriptor.Resources, + static value => value.Semantic, + RenderResourceSemantic.Custom, + "resource"); + if (!unique.Success) return unique; + unique = UniqueNonCustomSemantics( + descriptor, + descriptor.Passes, + static value => value.Semantic, + RenderPassSemantic.CustomFullscreen, + "pass"); + if (!unique.Success) return unique; + unique = UniqueNonCustomSemantics( + descriptor, + descriptor.PipelineVariants, + static value => value.Semantic, + RenderPipelineVariantSemantic.Custom, + "pipeline variant"); + if (!unique.Success) return unique; + unique = UniqueNonCustomSemantics( + descriptor, + descriptor.QualityPresets, + static value => value.Semantic, + RenderQualitySemantic.Custom, + "quality preset"); + if (!unique.Success) return unique; + unique = UniqueNonCustomSemantics( + descriptor, + descriptor.Settings, + static value => value.Semantic, + RenderSettingSemantic.Custom, + "setting"); + if (!unique.Success) return unique; + + RenderSettingDeclaration? automaticSetting = descriptor.Settings.FirstOrDefault( + static value => value.Semantic == RenderSettingSemantic.AutomaticQuality); + if (automaticSetting is not null && automaticSetting.Kind != RenderSettingKind.Boolean) + { + return Invalid( + $"Pack '{descriptor.Id}' AutomaticQuality setting must be Boolean."); + } + if (automaticSetting is not null + || descriptor.QualityPresets.Any(static value => + value.Semantic == RenderQualitySemantic.Automatic)) + { + foreach (RenderQualitySemantic semantic in new[] + { + RenderQualitySemantic.Low, + RenderQualitySemantic.Medium, + RenderQualitySemantic.High, + }) + { + if (!descriptor.QualityPresets.Any(value => + value.Semantic == semantic && value.AutoEligible)) + { + return Invalid( + $"Pack '{descriptor.Id}' declares Automatic quality but has no " + + $"AutoEligible '{semantic}' semantic preset."); + } + } + } + + bool atmosphericExecutor = descriptor.Passes.Any(static value => + value.Semantic != RenderPassSemantic.CustomFullscreen); + if (!atmosphericExecutor) + return RenderPackValidationResult.Valid(); + + bool directionalShadowOnly = descriptor.Passes.Any(static value => + value.Semantic == RenderPassSemantic.DirectionalShadowDepth) + && descriptor.Passes.All(static value => + value.Semantic is RenderPassSemantic.CustomFullscreen + or RenderPassSemantic.DirectionalShadowDepth); + if (directionalShadowOnly) + return ValidateDirectionalShadowProfile(descriptor); + + RenderPassSemantic[] requiredPasses = + [ + RenderPassSemantic.DirectionalShadowDepth, + RenderPassSemantic.SunOcclusion, + RenderPassSemantic.SunRays, + RenderPassSemantic.VolumetricShafts, + RenderPassSemantic.BloomDownsample, + RenderPassSemantic.BloomBlurHorizontal, + RenderPassSemantic.BloomBlurVertical, + RenderPassSemantic.FilmicComposite, + ]; + foreach (RenderPassSemantic semantic in requiredPasses) + { + if (!descriptor.Passes.Any(value => value.Semantic == semantic)) + { + return Invalid( + $"Pack '{descriptor.Id}' requests the atmospheric executor but " + + $"does not declare required pass semantic '{semantic}'."); + } + } + + RenderResourceSemantic[] requiredResources = + [ + RenderResourceSemantic.MainWorldHdr, + RenderResourceSemantic.BloomPing, + RenderResourceSemantic.BloomPong, + RenderResourceSemantic.SunOcclusionMask, + RenderResourceSemantic.SunRays, + RenderResourceSemantic.DirectionalShadowDepth, + RenderResourceSemantic.VolumetricShafts, + ]; + foreach (RenderResourceSemantic semantic in requiredResources) + { + if (!descriptor.Resources.Any(value => value.Semantic == semantic)) + { + return Invalid( + $"Pack '{descriptor.Id}' requests the atmospheric executor but " + + $"does not declare required resource semantic '{semantic}'."); + } + } + + if (descriptor.SceneReplays.Count(value => + value.Semantic == RenderSceneReplaySemantic.OutdoorDirectionalShadowCasters) != 1) + { + return Invalid( + $"Pack '{descriptor.Id}' must declare exactly one outdoor directional-shadow replay."); + } + + bool usesMultiview = descriptor.QualityPresets.Any(preset => + (preset.ExecutionHints & RenderQualityExecutionHints.MultiviewDirectionalShadowCascades) != 0); + List requiredVariants = + [ + RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster, + RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster, + RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster, + RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver, + RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver, + ]; + if (usesMultiview) + { + requiredVariants.Add(RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster); + requiredVariants.Add(RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster); + requiredVariants.Add(RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster); + } + foreach (RenderPipelineVariantSemantic semantic in requiredVariants) + { + if (!descriptor.PipelineVariants.Any(value => value.Semantic == semantic)) + { + return Invalid( + $"Pack '{descriptor.Id}' does not declare required pipeline-variant " + + $"semantic '{semantic}'."); + } + } + + RenderSettingSemantic[] requiredSettings = + [ + RenderSettingSemantic.BloomStrength, + RenderSettingSemantic.FilmicStrength, + RenderSettingSemantic.Exposure, + RenderSettingSemantic.GradeSaturation, + RenderSettingSemantic.GradeContrast, + RenderSettingSemantic.VignetteStrength, + RenderSettingSemantic.SunRayStrength, + RenderSettingSemantic.DirectionalShadowStrength, + RenderSettingSemantic.DirectionalShadowReachMetres, + RenderSettingSemantic.DirectionalShadowPcfTaps, + RenderSettingSemantic.VolumetricStrength, + RenderSettingSemantic.VolumetricRayMarchSteps, + ]; + foreach (RenderSettingSemantic semantic in requiredSettings) + { + if (!descriptor.Settings.Any(value => value.Semantic == semantic)) + { + return Invalid( + $"Pack '{descriptor.Id}' does not declare required atmospheric " + + $"setting semantic '{semantic}'."); + } + } + if (descriptor.AtmospherePolicy is null) + { + return Invalid( + $"Pack '{descriptor.Id}' must declare its visible sun/day-group " + + "atmosphere policy."); + } + if (descriptor.AtmospherePolicy.DirectionalShadowLightElevationResponse is not { Count: >= 2 }) + { + return Invalid( + $"Pack '{descriptor.Id}' must declare a directional-shadow " + + "light-elevation response curve."); + } + if (descriptor.AtmospherePolicy.VolumetricShaftSunElevationResponse is not { Count: >= 2 }) + { + return Invalid( + $"Pack '{descriptor.Id}' must declare a volumetric-shaft " + + "sun-elevation response curve."); + } + + RenderPackValidationResult shapes = ValidateAtmosphericSemanticShapes(descriptor); + if (!shapes.Success) return shapes; + return ValidateAtmosphericSemanticEdges(descriptor); + } + + private static RenderPackValidationResult ValidateDirectionalShadowProfile( + RenderPackDescriptor descriptor) + { + if (descriptor.HighestTier < RenderPackTier.Tier2) + { + return Invalid( + $"Pack '{descriptor.Id}' declares directional shadows below Tier2."); + } + + RenderCapability[] requiredCapabilities = + [ + RenderCapability.MainWorldColorIntermediate, + RenderCapability.FullscreenPasses, + RenderCapability.AuthoredCelestialDirectionalLight, + RenderCapability.AuthoredWeather, + RenderCapability.DirectionalShadowMaps, + RenderCapability.OutdoorDirectionalShadowCasterReplay, + RenderCapability.AnimatedCasterTransforms, + RenderCapability.AlphaCutoutShadowCasters, + ]; + foreach (RenderCapability capability in requiredCapabilities) + { + if (!descriptor.RequiredCapabilities.Contains(capability)) + { + return Invalid( + $"Pack '{descriptor.Id}' directional shadows must explicitly require " + + $"capability '{capability}'."); + } + } + + if (descriptor.Passes.Count(static value => + value.Semantic == RenderPassSemantic.DirectionalShadowDepth) != 1) + { + return Invalid( + $"Pack '{descriptor.Id}' must declare exactly one directional-shadow pass."); + } + if (descriptor.Resources.Count(static value => + value.Semantic == RenderResourceSemantic.DirectionalShadowDepth) != 1) + { + return Invalid( + $"Pack '{descriptor.Id}' must declare exactly one directional-shadow resource."); + } + if (descriptor.SceneReplays.Count != 1 + || descriptor.SceneReplays[0].Semantic + != RenderSceneReplaySemantic.OutdoorDirectionalShadowCasters) + { + return Invalid( + $"Pack '{descriptor.Id}' must declare exactly one outdoor " + + "directional-shadow replay."); + } + + bool usesMultiview = descriptor.QualityPresets.Any(preset => + (preset.ExecutionHints & RenderQualityExecutionHints.MultiviewDirectionalShadowCascades) != 0); + List requiredVariants = + [ + RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster, + RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster, + RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster, + RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver, + RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver, + ]; + if (usesMultiview) + { + requiredVariants.Add(RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster); + requiredVariants.Add(RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster); + requiredVariants.Add(RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster); + } + if (descriptor.PipelineVariants.Count != requiredVariants.Count) + { + return Invalid( + $"Pack '{descriptor.Id}' directional shadows require exactly {requiredVariants.Count} " + + "semantic pipeline variants for its execution hints."); + } + foreach (RenderPipelineVariantSemantic semantic in requiredVariants) + { + if (!descriptor.PipelineVariants.Any(value => value.Semantic == semantic)) + { + return Invalid( + $"Pack '{descriptor.Id}' does not declare required pipeline-variant " + + $"semantic '{semantic}'."); + } + } + + foreach (RenderSettingSemantic semantic in new[] + { + RenderSettingSemantic.DirectionalShadowStrength, + RenderSettingSemantic.DirectionalShadowReachMetres, + RenderSettingSemantic.DirectionalShadowPcfTaps, + }) + { + if (!descriptor.Settings.Any(value => value.Semantic == semantic)) + { + return Invalid( + $"Pack '{descriptor.Id}' does not declare required directional-shadow " + + $"setting semantic '{semantic}'."); + } + } + + if (descriptor.AtmospherePolicy?.DirectionalShadowLightElevationResponse + is not { Count: >= 2 }) + { + return Invalid( + $"Pack '{descriptor.Id}' must declare a directional-shadow " + + "light-elevation response curve."); + } + + RenderPassDeclaration shadow = descriptor.Passes.Single(value => + value.Semantic == RenderPassSemantic.DirectionalShadowDepth); + RenderResourceDeclaration depth = descriptor.Resources.Single(value => + value.Semantic == RenderResourceSemantic.DirectionalShadowDepth); + if (shadow.Hook != RenderPassHook.ShadowDepthBeforeWorld + || shadow.ResourceReads.Count != 0 + || shadow.ResourceWrites.Count != 1 + || !string.Equals( + shadow.ResourceWrites[0], depth.Id, StringComparison.OrdinalIgnoreCase)) + { + return Invalid( + $"Pack '{descriptor.Id}' directional-shadow pass must run before the world, " + + "read no declared resource, and write its directional-depth resource."); + } + if (depth.Kind != RenderResourceKind.Image2DArray + || depth.Format != RenderFormatClass.DirectionalDepth + || depth.Extent?.Mode != RenderExtentMode.AbsolutePixels + || depth.Usage != (RenderResourceUsage.Sampled | RenderResourceUsage.DepthAttachment) + || depth.Lifetime != RenderResourceLifetime.ActivePack) + { + return Invalid( + $"Pack '{descriptor.Id}' directional-shadow resource does not match the " + + "host executor's array-depth contract."); + } + + RenderPackValidationResult shapes = ValidateDirectionalShadowShapes(descriptor); + if (!shapes.Success) + return shapes; + + RenderPassDeclaration[] outputPasses = descriptor.Passes + .Where(static value => value.Hook == RenderPassHook.ToneMap + && value.ResourceWrites.Count == 0) + .ToArray(); + if (outputPasses.Length != 1 + || outputPasses[0].Semantic != RenderPassSemantic.CustomFullscreen + || !outputPasses[0].SemanticInputs.Contains(RenderSemanticInput.WorldColor)) + { + return Invalid( + $"Pack '{descriptor.Id}' directional shadows require exactly one custom " + + "ToneMap output-copy pass sampling WorldColor."); + } + if (descriptor.Passes.Any(value => + value.Semantic == RenderPassSemantic.CustomFullscreen + && value.Hook is RenderPassHook.ShadowDepthBeforeWorld + or RenderPassHook.AfterToneMapBeforePrivateViewports)) + { + return Invalid( + $"Pack '{descriptor.Id}' uses an unsupported custom pass hook in its " + + "directional-shadow graph."); + } + return RenderPackValidationResult.Valid(); + } + + private static RenderPackValidationResult ValidateDirectionalShadowShapes( + RenderPackDescriptor descriptor) + { + RenderPipelineVariantSemantic[] semantics = + [ + RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster, + RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster, + RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster, + RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver, + RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver, + ]; + RenderPipelineBaseSemantic[] bases = + [ + RenderPipelineBaseSemantic.Terrain, + RenderPipelineBaseSemantic.WorldMesh, + RenderPipelineBaseSemantic.WorldMesh, + RenderPipelineBaseSemantic.Terrain, + RenderPipelineBaseSemantic.WorldMesh, + ]; + RenderMaterialClass[] materials = + [ + RenderMaterialClass.Opaque, + RenderMaterialClass.Opaque | RenderMaterialClass.AnimatedOpaque, + RenderMaterialClass.AlphaCutout | RenderMaterialClass.AnimatedAlphaCutout, + RenderMaterialClass.Opaque, + RenderMaterialClass.Opaque | RenderMaterialClass.AlphaCutout + | RenderMaterialClass.AnimatedOpaque + | RenderMaterialClass.AnimatedAlphaCutout, + ]; + RenderSemanticInput[][] inputs = + [ + [RenderSemanticInput.CameraMatrices], + [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms], + [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms], + [RenderSemanticInput.DirectionalShadowMaps, + RenderSemanticInput.SelectedCelestialDirectionalLight], + [RenderSemanticInput.DirectionalShadowMaps, + RenderSemanticInput.SelectedCelestialDirectionalLight], + ]; + for (int i = 0; i < semantics.Length; i++) + { + PipelineVariantDeclaration variant = descriptor.PipelineVariants.Single(value => + value.Semantic == semantics[i]); + if (variant.BaseSemantic != bases[i] + || variant.CompatibleMaterials != materials[i] + || !variant.SemanticInputs.SequenceEqual(inputs[i])) + { + return Invalid( + $"Pipeline variant semantic '{semantics[i]}' does not match the fixed " + + "directional-shadow executor contract."); + } + } + + if (descriptor.QualityPresets.Any(preset => + (preset.ExecutionHints & RenderQualityExecutionHints.MultiviewDirectionalShadowCascades) != 0)) + { + RenderPipelineVariantSemantic[] multiview = + [ + RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster, + RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster, + RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster, + ]; + for (int i = 0; i < multiview.Length; i++) + { + PipelineVariantDeclaration variant = descriptor.PipelineVariants.Single(value => + value.Semantic == multiview[i]); + if (variant.BaseSemantic != bases[i] + || variant.CompatibleMaterials != materials[i] + || !variant.SemanticInputs.SequenceEqual(inputs[i])) + { + return Invalid( + $"Pipeline variant semantic '{multiview[i]}' does not match the fixed " + + "multiview directional-shadow executor contract."); + } + } + } + + SceneReplayDeclaration replay = descriptor.SceneReplays[0]; + const RenderCasterClass requiredCasters = RenderCasterClass.Terrain + | RenderCasterClass.OpaqueWorld + | RenderCasterClass.AlphaCutoutWorld + | RenderCasterClass.AnimatedOpaque + | RenderCasterClass.AnimatedAlphaCutout; + if (replay.CasterClasses != requiredCasters || replay.ViewCount != 4) + { + return Invalid( + $"Pack '{descriptor.Id}' outdoor directional-shadow replay must declare " + + "all five headline caster classes and four maximum cascade views."); + } + return RenderPackValidationResult.Valid(); + } + + private static RenderPackValidationResult ValidateAtmosphericSemanticShapes( + RenderPackDescriptor descriptor) + { + RenderPackValidationResult result = Resource( + RenderResourceSemantic.MainWorldHdr, + RenderResourceKind.Image2D, + RenderFormatClass.HdrColor, + RenderExtentMode.RelativeToMainWorld, + RenderResourceUsage.Sampled | RenderResourceUsage.ColorAttachment); + if (!result.Success) return result; + foreach (RenderResourceSemantic semantic in new[] + { + RenderResourceSemantic.BloomPing, + RenderResourceSemantic.BloomPong, + RenderResourceSemantic.SunRays, + RenderResourceSemantic.VolumetricShafts, + }) + { + result = Resource( + semantic, + RenderResourceKind.Image2D, + RenderFormatClass.HdrColor, + RenderExtentMode.RelativeToMainWorld, + RenderResourceUsage.Sampled | RenderResourceUsage.ColorAttachment); + if (!result.Success) return result; + } + result = Resource( + RenderResourceSemantic.SunOcclusionMask, + RenderResourceKind.Image2D, + RenderFormatClass.SingleChannel, + RenderExtentMode.RelativeToMainWorld, + RenderResourceUsage.Sampled | RenderResourceUsage.ColorAttachment); + if (!result.Success) return result; + result = Resource( + RenderResourceSemantic.DirectionalShadowDepth, + RenderResourceKind.Image2DArray, + RenderFormatClass.DirectionalDepth, + RenderExtentMode.AbsolutePixels, + RenderResourceUsage.Sampled | RenderResourceUsage.DepthAttachment); + if (!result.Success) return result; + + result = Variant( + RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster, + RenderPipelineBaseSemantic.Terrain, + RenderMaterialClass.Opaque, + [RenderSemanticInput.CameraMatrices]); + if (!result.Success) return result; + result = Variant( + RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster, + RenderPipelineBaseSemantic.WorldMesh, + RenderMaterialClass.Opaque | RenderMaterialClass.AnimatedOpaque, + [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]); + if (!result.Success) return result; + result = Variant( + RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster, + RenderPipelineBaseSemantic.WorldMesh, + RenderMaterialClass.AlphaCutout | RenderMaterialClass.AnimatedAlphaCutout, + [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]); + if (!result.Success) return result; + result = Variant( + RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver, + RenderPipelineBaseSemantic.Terrain, + RenderMaterialClass.Opaque, + [RenderSemanticInput.DirectionalShadowMaps, + RenderSemanticInput.SelectedCelestialDirectionalLight]); + if (!result.Success) return result; + result = Variant( + RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver, + RenderPipelineBaseSemantic.WorldMesh, + RenderMaterialClass.Opaque | RenderMaterialClass.AlphaCutout + | RenderMaterialClass.AnimatedOpaque + | RenderMaterialClass.AnimatedAlphaCutout, + [RenderSemanticInput.DirectionalShadowMaps, + RenderSemanticInput.SelectedCelestialDirectionalLight]); + if (!result.Success) return result; + + SceneReplayDeclaration replay = descriptor.SceneReplays.Single(value => + value.Semantic == RenderSceneReplaySemantic.OutdoorDirectionalShadowCasters); + const RenderCasterClass requiredCasters = RenderCasterClass.Terrain + | RenderCasterClass.OpaqueWorld + | RenderCasterClass.AlphaCutoutWorld + | RenderCasterClass.AnimatedOpaque + | RenderCasterClass.AnimatedAlphaCutout; + if (replay.CasterClasses != requiredCasters || replay.ViewCount != 4) + { + return Invalid( + $"Pack '{descriptor.Id}' outdoor directional-shadow replay must declare " + + "all five headline caster classes and four maximum cascade views."); + } + + return RenderPackValidationResult.Valid(); + + RenderPackValidationResult Resource( + RenderResourceSemantic semantic, + RenderResourceKind kind, + RenderFormatClass format, + RenderExtentMode extentMode, + RenderResourceUsage usage) + { + RenderResourceDeclaration resource = descriptor.Resources.Single(value => + value.Semantic == semantic); + if (resource.Kind != kind + || resource.Format != format + || resource.Extent?.Mode != extentMode + || resource.Usage != usage + || resource.Lifetime != RenderResourceLifetime.ActivePack) + { + return Invalid( + $"Resource semantic '{semantic}' does not match the fixed atmospheric " + + "executor's kind, format, extent, usage, and lifetime contract."); + } + return RenderPackValidationResult.Valid(); + } + + RenderPackValidationResult Variant( + RenderPipelineVariantSemantic semantic, + RenderPipelineBaseSemantic baseSemantic, + RenderMaterialClass materials, + IReadOnlyList inputs) + { + PipelineVariantDeclaration variant = descriptor.PipelineVariants.Single(value => + value.Semantic == semantic); + if (variant.BaseSemantic != baseSemantic + || variant.CompatibleMaterials != materials + || !variant.SemanticInputs.SequenceEqual(inputs)) + { + return Invalid( + $"Pipeline variant semantic '{semantic}' does not match the fixed " + + "atmospheric executor's base, material, and input contract."); + } + return RenderPackValidationResult.Valid(); + } + } + + private static RenderPackValidationResult ValidateAtmosphericSemanticEdges( + RenderPackDescriptor descriptor) + { + if (descriptor.Passes.Count != 8 + || descriptor.Resources.Count != 7 + || descriptor.PipelineVariants.Count != (descriptor.QualityPresets.Any(preset => + (preset.ExecutionHints & RenderQualityExecutionHints.MultiviewDirectionalShadowCascades) != 0) + ? 8 : 5) + || descriptor.SceneReplays.Count != 1) + { + return Invalid( + $"Pack '{descriptor.Id}' requests the fixed atmospheric executor; API v1 " + + "requires exactly 8 semantic passes, 7 semantic resources, and the declared semantic " + + "pipeline variants, and 1 semantic scene replay."); + } + + RenderPackValidationResult Hook(RenderPassSemantic semantic, RenderPassHook hook) + { + RenderPassDeclaration pass = descriptor.Passes.Single(value => + value.Semantic == semantic); + return pass.Hook == hook + ? RenderPackValidationResult.Valid() + : Invalid( + $"Pass semantic '{semantic}' must run at hook '{hook}', not '{pass.Hook}'."); + } + + RenderPackValidationResult result = Hook( + RenderPassSemantic.DirectionalShadowDepth, + RenderPassHook.ShadowDepthBeforeWorld); + if (!result.Success) return result; + foreach (RenderPassSemantic semantic in new[] + { + RenderPassSemantic.SunOcclusion, + RenderPassSemantic.SunRays, + RenderPassSemantic.VolumetricShafts, + RenderPassSemantic.BloomDownsample, + RenderPassSemantic.BloomBlurHorizontal, + RenderPassSemantic.BloomBlurVertical, + }) + { + result = Hook(semantic, RenderPassHook.AtmosphereBeforeToneMap); + if (!result.Success) return result; + } + result = Hook(RenderPassSemantic.FilmicComposite, RenderPassHook.ToneMap); + if (!result.Success) return result; + + RenderPassSemantic[] declaredOrder = descriptor.Passes + .Where(static pass => pass.Hook is RenderPassHook.AtmosphereBeforeToneMap + or RenderPassHook.ToneMap) + .Select(static pass => pass.Semantic) + .ToArray(); + RenderPassSemantic[] requiredOrder = + [ + RenderPassSemantic.SunOcclusion, + RenderPassSemantic.SunRays, + RenderPassSemantic.VolumetricShafts, + RenderPassSemantic.BloomDownsample, + RenderPassSemantic.BloomBlurHorizontal, + RenderPassSemantic.BloomBlurVertical, + RenderPassSemantic.FilmicComposite, + ]; + if (!declaredOrder.SequenceEqual(requiredOrder)) + { + return Invalid( + $"Pack '{descriptor.Id}' atmospheric pass order does not match the " + + "renderer-owned semantic execution order."); + } + + result = Edge(RenderPassSemantic.DirectionalShadowDepth, [], + RenderResourceSemantic.DirectionalShadowDepth); + if (!result.Success) return result; + result = Edge(RenderPassSemantic.SunOcclusion, [], + RenderResourceSemantic.SunOcclusionMask); + if (!result.Success) return result; + result = Edge(RenderPassSemantic.SunRays, + [RenderResourceSemantic.SunOcclusionMask], RenderResourceSemantic.SunRays); + if (!result.Success) return result; + result = Edge(RenderPassSemantic.VolumetricShafts, + [RenderResourceSemantic.DirectionalShadowDepth], + RenderResourceSemantic.VolumetricShafts); + if (!result.Success) return result; + result = Edge(RenderPassSemantic.BloomDownsample, + [RenderResourceSemantic.SunRays, RenderResourceSemantic.VolumetricShafts], + RenderResourceSemantic.BloomPing); + if (!result.Success) return result; + result = Edge(RenderPassSemantic.BloomBlurHorizontal, + [RenderResourceSemantic.BloomPing], RenderResourceSemantic.BloomPong); + if (!result.Success) return result; + result = Edge(RenderPassSemantic.BloomBlurVertical, + [RenderResourceSemantic.BloomPong], RenderResourceSemantic.BloomPing); + if (!result.Success) return result; + return Edge(RenderPassSemantic.FilmicComposite, + [RenderResourceSemantic.BloomPing, RenderResourceSemantic.SunRays, + RenderResourceSemantic.VolumetricShafts], + output: null); + + RenderPackValidationResult Edge( + RenderPassSemantic passSemantic, + IReadOnlyList reads, + RenderResourceSemantic? output) + { + RenderPassDeclaration pass = descriptor.Passes.Single(value => + value.Semantic == passSemantic); + RenderResourceSemantic[] actualReads = pass.ResourceReads + .Select(id => descriptor.Resources.Single(resource => string.Equals( + resource.Id, + id, + StringComparison.OrdinalIgnoreCase)).Semantic) + .ToArray(); + if (!actualReads.SequenceEqual(reads)) + { + return Invalid( + $"Pass semantic '{passSemantic}' declares resource reads that do not " + + "match its renderer-owned execution edges."); + } + RenderResourceSemantic[] actualWrites = pass.ResourceWrites + .Select(id => descriptor.Resources.Single(resource => string.Equals( + resource.Id, + id, + StringComparison.OrdinalIgnoreCase)).Semantic) + .ToArray(); + RenderResourceSemantic[] expectedWrites = output is { } semantic + ? [semantic] + : []; + return actualWrites.SequenceEqual(expectedWrites) + ? RenderPackValidationResult.Valid() + : Invalid( + $"Pass semantic '{passSemantic}' declares a resource output that does " + + "not match its renderer-owned execution edge."); + } + } + + private static RenderPackValidationResult UniqueNonCustomSemantics( + RenderPackDescriptor descriptor, + IEnumerable values, + Func select, + TSemantic custom, + string kind) + where T : class + where TSemantic : struct, Enum + { + var seen = new HashSet(); + foreach (T? value in values) + { + if (value is null) + continue; + TSemantic semantic = select(value); + if (!Enum.IsDefined(semantic)) + return Invalid($"Pack '{descriptor.Id}' declares an unknown {kind} semantic."); + if (!EqualityComparer.Default.Equals(semantic, custom) + && !seen.Add(semantic)) + { + return Invalid( + $"Pack '{descriptor.Id}' declares duplicate {kind} semantic '{semantic}'."); + } + } + return RenderPackValidationResult.Valid(); + } + + internal static RenderPackValidationResult ValidatePresetCompatibility( + RenderPackDescriptor descriptor, + RenderQualityPreset preset, + RenderPackHostCapabilities capabilities) + { + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(preset); + ArgumentNullException.ThrowIfNull(capabilities); + foreach (RenderCapability required in preset.RequiredCapabilities) + { + if (!capabilities.Available.Contains(required)) + { + return Invalid( + $"Preset '{preset.Id}' requires unsupported capability '{required}'."); + } + } + if (preset.Semantic == RenderQualitySemantic.Automatic + && !capabilities.Available.Contains(RenderCapability.GpuTimestampQueries)) + { + return Invalid( + $"Preset '{preset.Id}' requires asynchronous GPU timestamp queries " + + "because Auto evaluates the complete CPU/GPU pack cost; explicit " + + "Low remains available when its resource limits fit."); + } + // Automatic is a logical selector, not an allocated resource layout. + // Its effective Low/Medium/High presets are checked independently and + // the controller chooses only from the compatible contiguous range. + if (preset.Semantic == RenderQualitySemantic.Automatic) + return RenderPackValidationResult.Valid(); + + if (preset.MaxResidentGpuBytes > capabilities.MaxPackResidentBytes) + { + return Invalid( + $"Preset '{preset.Id}' declares a {preset.MaxResidentGpuBytes}-byte " + + $"resident GPU ceiling, but this host permits " + + $"{capabilities.MaxPackResidentBytes} bytes under its " + + $"{capabilities.MemoryPolicyDescription} policy."); + } + + Dictionary overrides = preset.ResourceOverrides + .ToDictionary(value => value.ResourceId, StringComparer.OrdinalIgnoreCase); + foreach (RenderResourceDeclaration resource in descriptor.Resources) + { + RenderExtentDeclaration? extent = overrides.TryGetValue( + resource.Id, + out RenderQualityResourceOverride? resourceOverride) + ? resourceOverride.Extent ?? resource.Extent + : resource.Extent; + if (extent is null) + continue; + if (extent.Layers > capabilities.MaxImageArrayLayers) + { + return Invalid( + $"Preset '{preset.Id}' resource '{resource.Id}' needs " + + $"{extent.Layers} image-array layers; this device provides " + + $"{capabilities.MaxImageArrayLayers}."); + } + if (extent.Mode == RenderExtentMode.AbsolutePixels + && (extent.Width > capabilities.MaxImageDimension2D + || extent.Height > capabilities.MaxImageDimension2D)) + { + return Invalid( + $"Preset '{preset.Id}' resource '{resource.Id}' needs " + + $"{extent.Width:G}x{extent.Height:G}; this device's maximum " + + $"2-D image edge is {capabilities.MaxImageDimension2D}."); + } + } + return RenderPackValidationResult.Valid(); + } + + private static RenderPackValidationResult ValidateUniqueIds(RenderPackDescriptor descriptor) + { + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + IEnumerable<(string Kind, string? Id)> declarations = + descriptor.Resources.Select(static value => ("resource", value?.Id)) + .Concat(descriptor.Passes.Select(static value => ("pass", value?.Id))) + .Concat(descriptor.SceneReplays.Select(static value => ("scene replay", value?.Id))) + .Concat(descriptor.PipelineVariants.Select(static value => ("pipeline variant", value?.Id))) + .Concat(descriptor.QualityPresets.Select(static value => ("quality preset", value?.Id))) + .Concat(descriptor.Settings.Select(static value => ("setting", value?.Id))); + + foreach ((string kind, string? id) in declarations) + { + if (!IsStableId(id)) + return Invalid($"Pack '{descriptor.Id}' has an invalid {kind} id."); + if (!ids.Add($"{kind}:{id}")) + return Invalid($"Pack '{descriptor.Id}' declares duplicate {kind} id '{id}'."); + } + return RenderPackValidationResult.Valid(); + } + + private static RenderPackValidationResult ValidateResources( + RenderPackDescriptor descriptor, + RenderPackHostCapabilities capabilities) + { + long declaredBytes = 0; + foreach (RenderResourceDeclaration? resource in descriptor.Resources) + { + if (resource is null) + return Invalid($"Pack '{descriptor.Id}' contains a null resource declaration."); + if (resource.Kind == RenderResourceKind.Buffer + || resource.Format == RenderFormatClass.StructuredData + || resource.Usage.HasFlag(RenderResourceUsage.Storage)) + { + return Invalid( + $"Resource '{resource.Id}' uses a buffer/storage declaration reserved " + + "for a future render-pack API; API v1 binds image resources only."); + } + if (resource.Kind == RenderResourceKind.Image2DArray + && resource.Format != RenderFormatClass.DirectionalDepth) + { + return Invalid( + $"Resource '{resource.Id}' uses a color image array; render-pack API " + + "v1 reserves image arrays for directional depth maps."); + } + if (resource.EstimatedResidentBytes < 0 || resource.SizeBytes < 0) + return Invalid($"Resource '{resource.Id}' declares negative bytes."); + if (resource.Usage == RenderResourceUsage.None) + return Invalid($"Resource '{resource.Id}' declares no usage."); + if (resource.Kind == RenderResourceKind.Buffer && resource.Extent is not null) + return Invalid($"Buffer resource '{resource.Id}' must not declare an image extent."); + if (resource.Kind != RenderResourceKind.Buffer) + { + if (resource.Extent is null) + return Invalid($"Image resource '{resource.Id}' has no extent."); + RenderExtentDeclaration extent = resource.Extent; + if (!IsFinitePositive(extent.Width) + || !IsFinitePositive(extent.Height) + || extent.Layers <= 0 + || extent.Layers > AbsoluteImageArrayLayerCeiling) + { + return Invalid($"Image resource '{resource.Id}' has an invalid extent."); + } + if (extent.Mode == RenderExtentMode.AbsolutePixels + && (extent.Width > AbsoluteImageDimension2DCeiling + || extent.Height > AbsoluteImageDimension2DCeiling)) + { + return Invalid($"Image resource '{resource.Id}' exceeds the device image limit."); + } + if (extent.Mode != RenderExtentMode.AbsolutePixels + && (extent.Width > 1.0 || extent.Height > 1.0)) + { + return Invalid($"Relative resource '{resource.Id}' must use a scale in (0, 1]."); + } + } + + if (!TryAdd(ref declaredBytes, resource.EstimatedResidentBytes)) + return Invalid($"Pack '{descriptor.Id}' resource byte total overflows."); + } + + if (declaredBytes > AbsolutePackByteCeiling) + { + return Invalid( + $"Pack '{descriptor.Id}' declares {declaredBytes} resident bytes; " + + $"the render-pack API ceiling is {AbsolutePackByteCeiling}."); + } + return RenderPackValidationResult.Valid(); + } + + private static RenderPackValidationResult ValidatePasses(RenderPackDescriptor descriptor) + { + Dictionary resources = descriptor.Resources + .ToDictionary(static value => value.Id, StringComparer.OrdinalIgnoreCase); + var written = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (RenderPassDeclaration? pass in descriptor.Passes) + { + if (pass is null) + return Invalid($"Pack '{descriptor.Id}' contains a null pass declaration."); + if (!IsSafeAssetKey(pass.VertexShaderAsset) + || !IsSafeAssetKey(pass.FragmentShaderAsset)) + return Invalid($"Pass '{pass.Id}' declares an unsafe shader asset key."); + if (pass.SemanticInputs is null || pass.ResourceReads is null || pass.ResourceWrites is null) + return Invalid($"Pass '{pass.Id}' contains a null binding list."); + if (pass.ResourceWrites.Count > 1) + { + return Invalid( + $"Pass '{pass.Id}' writes {pass.ResourceWrites.Count} resources; " + + "render-pack API v1 supports one attachment per declared pass."); + } + if (pass.ResourceWrites.Count == 0 + && pass.Hook is not RenderPassHook.ToneMap + and not RenderPassHook.AfterToneMapBeforePrivateViewports) + { + return Invalid( + $"Pass '{pass.Id}' has no declared output at hook '{pass.Hook}'."); + } + int sampledInputs = pass.SemanticInputs.Count(static semantic => + semantic is RenderSemanticInput.WorldColor + or RenderSemanticInput.SceneDepth + or RenderSemanticInput.SceneNormals); + foreach (string read in pass.ResourceReads) + { + if (!resources.TryGetValue(read, out RenderResourceDeclaration? resource)) + return Invalid($"Pass '{pass.Id}' reads unknown resource '{read}'."); + if (!written.Contains(read)) + return Invalid($"Pass '{pass.Id}' reads resource '{read}' before it is written."); + if (!resource.Usage.HasFlag(RenderResourceUsage.Sampled)) + return Invalid($"Pass '{pass.Id}' samples non-sampled resource '{read}'."); + bool dedicatedDirectionalSlot = + resource.Format == RenderFormatClass.DirectionalDepth + && pass.SemanticInputs.Contains(RenderSemanticInput.DirectionalShadowMaps); + if (!dedicatedDirectionalSlot) + sampledInputs++; + } + if (sampledInputs > 4) + { + return Invalid( + $"Pass '{pass.Id}' needs {sampledInputs} ordinary sampled images; " + + "render-pack API v1 provides four ordered texture slots (A..D)."); + } + foreach (string write in pass.ResourceWrites) + { + if (!resources.TryGetValue(write, out RenderResourceDeclaration? resource)) + return Invalid($"Pass '{pass.Id}' writes unknown resource '{write}'."); + RenderResourceUsage attachment = resource.Format == RenderFormatClass.DirectionalDepth + ? RenderResourceUsage.DepthAttachment + : RenderResourceUsage.ColorAttachment; + if (!resource.Usage.HasFlag(attachment)) + return Invalid($"Pass '{pass.Id}' writes non-attachment resource '{write}'."); + written.Add(write); + } + } + return RenderPackValidationResult.Valid(); + } + + private static RenderPackValidationResult ValidateSemanticCapabilities( + RenderPackDescriptor descriptor, + RenderPackHostCapabilities capabilities) + { + foreach (RenderPassDeclaration shadowPass in descriptor.Passes.Where(static pass => + pass.Semantic == RenderPassSemantic.DirectionalShadowDepth)) + { + if (!shadowPass.SemanticInputs.Contains( + RenderSemanticInput.SelectedCelestialDirectionalLight) + || shadowPass.SemanticInputs.Contains(RenderSemanticInput.SunDirection)) + { + return Invalid( + $"Directional-shadow pass '{shadowPass.Id}' must declare " + + $"'{RenderSemanticInput.SelectedCelestialDirectionalLight}' and must not " + + "alias the sun-specific atmospheric direction."); + } + } + + IEnumerable inputs = descriptor.Passes + .SelectMany(static pass => pass?.SemanticInputs ?? []) + .Concat(descriptor.PipelineVariants.SelectMany( + static variant => variant?.SemanticInputs ?? [])); + foreach (RenderSemanticInput input in inputs.Distinct()) + { + RenderCapability? required = input switch + { + RenderSemanticInput.WorldColor => + RenderCapability.MainWorldColorIntermediate, + RenderSemanticInput.SceneDepth => + RenderCapability.SceneDepthSampling, + RenderSemanticInput.SceneNormals => + RenderCapability.SceneNormalSampling, + RenderSemanticInput.SunDirection => + RenderCapability.AuthoredSunDirection, + RenderSemanticInput.SelectedCelestialDirectionalLight => + RenderCapability.AuthoredCelestialDirectionalLight, + RenderSemanticInput.SunScreenPosition => + RenderCapability.AuthoredSunScreenPosition, + RenderSemanticInput.ActiveDayGroup or RenderSemanticInput.Weather => + RenderCapability.AuthoredWeather, + RenderSemanticInput.CameraMatrices or RenderSemanticInput.FrameTime => + RenderCapability.FullscreenPasses, + RenderSemanticInput.ShadowCasterTransforms => + RenderCapability.AnimatedCasterTransforms, + RenderSemanticInput.DirectionalShadowMaps => + RenderCapability.DirectionalShadowMaps, + _ => null, + }; + if (input is RenderSemanticInput.SelectedCelestialDirectionalLight + && required is { } declaredCapability + && !descriptor.RequiredCapabilities.Contains(declaredCapability)) + { + return Invalid( + $"Pack '{descriptor.Id}' declares semantic '{input}' but does not " + + $"require capability '{declaredCapability}'."); + } + if (required is { } capability + && !capabilities.Available.Contains(capability)) + { + return Invalid( + $"Pack '{descriptor.Id}' declares semantic '{input}' but the host " + + $"does not provide capability '{capability}'."); + } + } + return RenderPackValidationResult.Valid(); + } + + private static RenderPackValidationResult ValidateReplays(RenderPackDescriptor descriptor) + { + foreach (SceneReplayDeclaration? replay in descriptor.SceneReplays) + { + if (replay is null) + return Invalid($"Pack '{descriptor.Id}' contains a null scene replay."); + if (replay.ViewCount <= 0 || replay.ViewCount > 4) + return Invalid($"Scene replay '{replay.Id}' must request 1..4 views."); + if (replay.CasterClasses == RenderCasterClass.None) + return Invalid($"Scene replay '{replay.Id}' declares no caster classes."); + } + return RenderPackValidationResult.Valid(); + } + + private static RenderPackValidationResult ValidateVariants(RenderPackDescriptor descriptor) + { + foreach (PipelineVariantDeclaration? variant in descriptor.PipelineVariants) + { + if (variant is null) + return Invalid($"Pack '{descriptor.Id}' contains a null pipeline variant."); + if (!IsSafeAssetKey(variant.VertexShaderAsset) + || !IsSafeAssetKey(variant.FragmentShaderAsset)) + return Invalid($"Pipeline variant '{variant.Id}' declares an unsafe shader asset key."); + if (variant.CompatibleMaterials == RenderMaterialClass.None) + return Invalid($"Pipeline variant '{variant.Id}' declares no compatible materials."); + if (variant.SemanticInputs is null) + return Invalid($"Pipeline variant '{variant.Id}' has a null semantic-input list."); + } + return RenderPackValidationResult.Valid(); + } + + private static RenderPackValidationResult ValidateSettings(RenderPackDescriptor descriptor) + { + if (descriptor.Settings.Count > RenderPackShaderAbi.PackSettingScalarCapacity) + return Invalid( + $"Pack '{descriptor.Id}' exceeds the " + + $"{RenderPackShaderAbi.PackSettingScalarCapacity}-setting API-v1 ceiling."); + foreach (RenderSettingDeclaration? setting in descriptor.Settings) + { + if (setting is null) + return Invalid($"Pack '{descriptor.Id}' contains a null setting."); + if (!Enum.IsDefined(setting.Kind)) + return Invalid($"Setting '{setting.Id}' declares an unknown kind."); + if (string.IsNullOrWhiteSpace(setting.DisplayName)) + return Invalid($"Setting '{setting.Id}' has no display name."); + if (setting.DefaultValue is null || setting.Choices is null) + return Invalid($"Setting '{setting.Id}' contains a null value list."); + if (setting.Minimum is { } min && !double.IsFinite(min) + || setting.Maximum is { } max && !double.IsFinite(max) + || setting.Step is { } step && (!double.IsFinite(step) || step <= 0)) + return Invalid($"Setting '{setting.Id}' has invalid bounds."); + if (setting.Minimum is { } minimum + && setting.Maximum is { } maximum + && minimum > maximum) + return Invalid($"Setting '{setting.Id}' has an inverted range."); + if (setting.Kind == RenderSettingKind.Choice + && (setting.Choices is null || setting.Choices.Count == 0)) + return Invalid($"Choice setting '{setting.Id}' declares no choices."); + if (!RenderPackSettingValueCodec.TryEncode( + setting, + setting.DefaultValue, + out _)) + { + return Invalid($"Setting '{setting.Id}' has an invalid default value."); + } + } + return RenderPackValidationResult.Valid(); + } + + private static RenderPackValidationResult ValidatePresets( + RenderPackDescriptor descriptor, + RenderPackHostCapabilities capabilities) + { + if (descriptor.QualityPresets.Count == 0) + return Invalid($"Pack '{descriptor.Id}' declares no quality presets."); + HashSet resources = descriptor.Resources + .Select(static value => value.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + HashSet settings = descriptor.Settings + .Select(static value => value.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + Dictionary settingDeclarations = + descriptor.Settings.ToDictionary( + value => value.Id, + StringComparer.OrdinalIgnoreCase); + const long ceiling = AbsolutePackByteCeiling; + + foreach (RenderQualityPreset? preset in descriptor.QualityPresets) + { + if (preset is null) + return Invalid($"Pack '{descriptor.Id}' contains a null quality preset."); + if (string.IsNullOrWhiteSpace(preset.DisplayName)) + return Invalid($"Quality preset '{preset.Id}' has no display name."); + if (preset.MaxResidentGpuBytes < 0 || preset.MaxResidentGpuBytes > ceiling) + return Invalid($"Quality preset '{preset.Id}' exceeds the pack memory ceiling."); + const RenderQualityExecutionHints supportedExecutionHints = + RenderQualityExecutionHints.FusedAtmosphericPostProcess + | RenderQualityExecutionHints.MultiviewDirectionalShadowCascades; + if ((preset.ExecutionHints & ~supportedExecutionHints) != 0) + return Invalid($"Quality preset '{preset.Id}' declares an unknown execution hint."); + if ((preset.ExecutionHints + & RenderQualityExecutionHints.FusedAtmosphericPostProcess) != 0 + && preset.Semantic != RenderQualitySemantic.Low) + { + return Invalid( + $"Quality preset '{preset.Id}' may only use fused atmospheric post-processing " + + "with the Low quality semantic."); + } + if ((preset.ExecutionHints + & RenderQualityExecutionHints.MultiviewDirectionalShadowCascades) != 0 + && preset.Semantic != RenderQualitySemantic.Low) + { + return Invalid( + $"Quality preset '{preset.Id}' may only use multiview directional-shadow " + + "cascades with the Low quality semantic."); + } + if ((preset.ExecutionHints + & RenderQualityExecutionHints.MultiviewDirectionalShadowCascades) != 0 + && descriptor.Passes.Count(pass => + pass.Semantic == RenderPassSemantic.DirectionalShadowDepth) != 1) + { + return Invalid( + $"Quality preset '{preset.Id}' requests multiview directional-shadow " + + "cascades without the directional-shadow graph."); + } + if ((preset.ExecutionHints + & RenderQualityExecutionHints.MultiviewDirectionalShadowCascades) != 0 + && !preset.RequiredCapabilities.Contains( + RenderCapability.MultiviewDirectionalShadowCascades)) + { + return Invalid( + $"Quality preset '{preset.Id}' must require multiview directional-shadow capability."); + } + if ((preset.ExecutionHints + & RenderQualityExecutionHints.FusedAtmosphericPostProcess) != 0) + { + RenderPassSemantic[] requiredFusedPasses = + [ + RenderPassSemantic.SunOcclusion, + RenderPassSemantic.SunRays, + RenderPassSemantic.BloomDownsample, + RenderPassSemantic.BloomBlurHorizontal, + RenderPassSemantic.BloomBlurVertical, + RenderPassSemantic.FilmicComposite, + ]; + if (requiredFusedPasses.Any(semantic => + descriptor.Passes.Count(pass => pass.Semantic == semantic) != 1)) + { + return Invalid( + $"Quality preset '{preset.Id}' requests fused atmospheric " + + "post-processing without the complete standard pass graph."); + } + } + if (!IsFiniteNonNegative(preset.MaxIncrementalGpuMillisecondsP50) + || !IsFiniteNonNegative(preset.MaxIncrementalGpuMillisecondsP99) + || !IsFiniteNonNegative(preset.MaxIncrementalCpuMillisecondsP50) + || !IsFiniteNonNegative(preset.MaxIncrementalCpuMillisecondsP99) + || preset.MaxIncrementalGpuMillisecondsP50 > preset.MaxIncrementalGpuMillisecondsP99 + || preset.MaxIncrementalCpuMillisecondsP50 > preset.MaxIncrementalCpuMillisecondsP99) + return Invalid($"Quality preset '{preset.Id}' has invalid performance budgets."); + foreach (RenderQualityResourceOverride value in preset.ResourceOverrides) + { + if (!resources.Contains(value.ResourceId)) + return Invalid($"Quality preset '{preset.Id}' overrides unknown resource '{value.ResourceId}'."); + if (value.SizeBytes < 0 || value.EstimatedResidentBytes < 0) + return Invalid($"Quality preset '{preset.Id}' declares negative resource bytes."); + } + var overriddenSettings = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (RenderQualitySettingOverride value in preset.SettingOverrides) + { + if (!settings.Contains(value.SettingId)) + return Invalid($"Quality preset '{preset.Id}' overrides unknown setting '{value.SettingId}'."); + if (!overriddenSettings.Add(value.SettingId)) + return Invalid($"Quality preset '{preset.Id}' overrides setting '{value.SettingId}' more than once."); + if (!RenderPackSettingValueCodec.TryEncode( + settingDeclarations[value.SettingId], + value.Value, + out _)) + { + return Invalid( + $"Quality preset '{preset.Id}' supplies an invalid value " + + $"for setting '{value.SettingId}'."); + } + } + } + return RenderPackValidationResult.Valid(); + } + + private static RenderPackValidationResult ValidateAtmosphere(RenderPackDescriptor descriptor) + { + AtmospherePolicyDeclaration? policy = descriptor.AtmospherePolicy; + if (policy is null) + return RenderPackValidationResult.Valid(); + if (policy.SunElevationResponse is null + || policy.ActiveDayGroupMultipliers is null + || policy.DirectionalShadowLightElevationResponse is null + || policy.VolumetricShaftSunElevationResponse is null) + return Invalid($"Pack '{descriptor.Id}' has a null atmosphere-policy list."); + + RenderPackValidationResult curve = ValidateCurve( + policy.SunElevationResponse, + "sun-elevation"); + if (!curve.Success) + return curve; + curve = ValidateCurve( + policy.DirectionalShadowLightElevationResponse, + "directional-shadow light-elevation", + unitInterval: true); + if (!curve.Success) + return curve; + curve = ValidateDirectionalShadowHorizon( + policy.DirectionalShadowLightElevationResponse); + if (!curve.Success) + return curve; + curve = ValidateCurve( + policy.VolumetricShaftSunElevationResponse, + "volumetric-shaft sun-elevation", + unitInterval: true); + if (!curve.Success) + return curve; + + var groups = new HashSet(); + foreach (ActiveDayGroupMultiplier value in policy.ActiveDayGroupMultipliers) + { + if (!groups.Add(value.ActiveDayGroup) + || !IsFiniteNonNegative(value.Multiplier)) + return Invalid($"Pack '{descriptor.Id}' has an invalid active-day-group mapping."); + } + return RenderPackValidationResult.Valid(); + + RenderPackValidationResult ValidateCurve( + IReadOnlyList points, + string name, + bool unitInterval = false) + { + double priorElevation = double.NegativeInfinity; + foreach (SunElevationResponsePoint point in points) + { + if (point is null + || !double.IsFinite(point.ElevationDegrees) + || point.ElevationDegrees < -90 + || point.ElevationDegrees > 90 + || !IsFiniteNonNegative(point.Multiplier) + || (unitInterval && point.Multiplier > 1) + || point.ElevationDegrees <= priorElevation) + { + return Invalid( + $"Pack '{descriptor.Id}' has an invalid {name} response curve."); + } + priorElevation = point.ElevationDegrees; + } + return RenderPackValidationResult.Valid(); + } + + RenderPackValidationResult ValidateDirectionalShadowHorizon( + IReadOnlyList points) + { + bool hasExactHorizonPoint = false; + SunElevationResponsePoint? firstAboveHorizon = null; + foreach (SunElevationResponsePoint point in points) + { + if (point.ElevationDegrees <= 0d) + { + if (point.Multiplier != 0d) + return InvalidHorizon(); + hasExactHorizonPoint |= point.ElevationDegrees == 0d; + continue; + } + + firstAboveHorizon = point; + break; + } + + // Curves clamp outside their endpoints and interpolate between + // adjacent points. Without an exact zero-degree point, the first + // positive point must therefore also be zero to keep the value at + // the authored horizon (and the complete below-horizon interval) + // at zero. + return !hasExactHorizonPoint + && firstAboveHorizon is { Multiplier: not 0d } + ? InvalidHorizon() + : RenderPackValidationResult.Valid(); + + RenderPackValidationResult InvalidHorizon() => Invalid( + $"Pack '{descriptor.Id}' directional-shadow light-elevation " + + "curve must resolve to zero at and below the 0-degree " + + "authored horizon."); + } + } + + private static string? FirstNullList(RenderPackDescriptor value) + { + if (value.RequiredCapabilities is null) return "required-capability"; + if (value.OptionalCapabilities is null) return "optional-capability"; + if (value.Resources is null) return "resource"; + if (value.Passes is null) return "pass"; + if (value.SceneReplays is null) return "scene-replay"; + if (value.PipelineVariants is null) return "pipeline-variant"; + if (value.QualityPresets is null) return "quality-preset"; + if (value.Settings is null) return "setting"; + return null; + } + + private static bool IsStableId(string? value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 128) + return false; + if (value[0] is < 'a' or > 'z') + return false; + foreach (char character in value) + { + if (character is >= 'a' and <= 'z' + || character is >= '0' and <= '9' + || character is '.' or '-' or '_') + continue; + return false; + } + return true; + } + + private static bool IsSafeAssetKey(string? value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 512) + return false; + if (Path.IsPathRooted(value) || value.Contains('\\')) + return false; + string[] segments = value.Split('/'); + return segments.All(static segment => + segment.Length > 0 && segment is not "." and not ".."); + } + + private static bool IsFinitePositive(double value) => + double.IsFinite(value) && value > 0; + + private static bool IsFiniteNonNegative(double value) => + double.IsFinite(value) && value >= 0; + + private static bool TryAdd(ref long total, long value) + { + if (value > long.MaxValue - total) + return false; + total += value; + return true; + } + + private static RenderPackValidationResult Invalid(string reason) => + RenderPackValidationResult.Invalid(reason); +} diff --git a/src/AcDream.App/Rendering/Packs/VolumetricShaftRenderer.cs b/src/AcDream.App/Rendering/Packs/VolumetricShaftRenderer.cs new file mode 100644 index 00000000..da67b20d --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/VolumetricShaftRenderer.cs @@ -0,0 +1,471 @@ +using System.Diagnostics; +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu; +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.App.Rendering.Packs; + +internal enum VolumetricShaftGateReason : byte +{ + Rendered, + DisabledByPreset, + NoCurrentDirectionalShadow, + NoSceneDepth, + Indoor, + SunOffScreen, + SunBelowHorizon, + AtmosphereSuppressed, +} + +internal readonly record struct VolumetricShaftDiagnostics( + VolumetricShaftGateReason GateReason, + int Width, + int Height, + int RayMarchSteps, + float Density, + float Strength, + long RetainedGpuBytes, + double LastResolvedGpuMilliseconds, + bool HasResolvedGpuMeasurement, + int DrawCalls); + +internal readonly record struct VolumetricShaftOutput( + GpuTextureSlot TextureSlot, + VolumetricShaftDiagnostics Diagnostics) +{ + internal bool HasTexture => TextureSlot.IsAssigned; +} + +/// +/// Tier-2+ shadow-map volumetric producer. It consumes only the current frame's +/// b5/b6/b8 facts and scene depth, and owns one preset-scaled HDR result. It has +/// no clock, weather state, caster traversal, or independent sun policy. +/// +internal sealed class VolumetricShaftRenderer : IDisposable +{ + internal const string TimerName = "atmospheric-volumetric-shafts"; + + private readonly IGpuDevice _device; + private readonly VolumetricShaftQuality _quality; + private readonly float _declaredStrength; + private readonly AtmospherePolicyDeclaration _atmospherePolicy; + private readonly IReadOnlyDictionary _dayGroupMultipliers; + private readonly IGpuSampler _sampler; + private readonly IGpuPipeline _pipeline; + private readonly PackSettingsUniforms _settings; + private readonly RenderPackPerformanceWindow _performance = new(); + private Target? _target; + private bool _disposed; + + internal VolumetricShaftRenderer( + IGpuDevice device, + RenderPackDescriptor descriptor, + IRenderPackAssets assets, + RenderQualityPreset preset, + IReadOnlyDictionary? userSettingOverrides = null) + : this( + device, + descriptor, + RenderPackShaderAssets.Validate(descriptor, assets), + preset, + userSettingOverrides) + { + } + + internal VolumetricShaftRenderer( + IGpuDevice device, + RenderPackDescriptor descriptor, + ValidatedRenderPackShaderAssets assets, + RenderQualityPreset preset, + IReadOnlyDictionary? userSettingOverrides = null) + { + _device = device ?? throw new ArgumentNullException(nameof(device)); + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(assets); + ArgumentNullException.ThrowIfNull(preset); + _quality = ResolveQuality( + descriptor, + preset, + userSettingOverrides); + _declaredStrength = ReadSetting( + descriptor, + preset, + userSettingOverrides, + RenderSettingSemantic.VolumetricStrength, + 0.35f); + _atmospherePolicy = descriptor.AtmospherePolicy + ?? throw new NotSupportedException( + $"Pack '{descriptor.Id}' declares no atmosphere policy."); + if (_atmospherePolicy.VolumetricShaftSunElevationResponse.Count < 2) + { + throw new NotSupportedException( + $"Pack '{descriptor.Id}' declares no volumetric-shaft elevation curve."); + } + _dayGroupMultipliers = _atmospherePolicy.ActiveDayGroupMultipliers + .ToDictionary(value => value.ActiveDayGroup, value => (float)value.Multiplier); + _settings = PackSettingsUniforms.Create(descriptor, preset, userSettingOverrides); + RenderPassDeclaration pass = descriptor.Passes.FirstOrDefault(value => + value.Semantic == RenderPassSemantic.VolumetricShafts) + ?? throw new NotSupportedException( + $"Pack '{descriptor.Id}' declares no VolumetricShafts pass semantic."); + + _sampler = device.CreateSampler(GpuSamplerDescription.WorldClamp); + _pipeline = device.CreatePipeline(new GpuPipelineDescription + { + Name = $"render-pack-{descriptor.Id}-volumetric-shafts", + Shaders = RenderPackShaderAssets.LoadPass(descriptor, assets, pass), + VertexLayout = GpuVertexLayout.None, + Blend = GpuBlendMode.None, + Depth = GpuDepthState.Disabled, + Cull = GpuCullMode.None, + ColorFormat = GpuTextureFormat.Rgba16FloatRenderTarget, + AllowColorFormatVariants = false, + SampleCount = 1, + UsesRenderPackShaderAbi = true, + }); + LastDiagnostics = Disabled(VolumetricShaftGateReason.DisabledByPreset); + } + + internal VolumetricShaftDiagnostics LastDiagnostics { get; private set; } + + internal VolumetricShaftQuality Quality => _quality; + + internal RenderPackPerformanceSnapshot Performance => _performance.Snapshot(); + + /// + /// Builds the selected preset's optional shaft target during off-side pack + /// activation/resize. A disabled preset owns no target; enabling it later + /// through a user override is reflected in . + /// + internal void PrepareTarget(int outputWidth, int outputHeight) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(outputWidth); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(outputHeight); + if (_declaredStrength > 0f) + _ = Prepare(outputWidth, outputHeight); + } + + internal VolumetricShaftOutput Render( + IGpuFrame frame, + in AtmosphericFrameInputs inputs, + in DirectionalShadowFrameBinding shadow, + GpuTextureSlot sceneDepth) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(frame); + VolumetricShaftGateReason reason = Gate(frame, inputs, shadow, sceneDepth); + if (reason != VolumetricShaftGateReason.Rendered) + { + LastDiagnostics = Disabled(reason); + return new VolumetricShaftOutput(GpuTextureSlot.Unassigned, LastDiagnostics); + } + + (float density, float strength) = Parameters(inputs); + if (strength <= 1e-4f) + { + LastDiagnostics = Disabled(VolumetricShaftGateReason.AtmosphereSuppressed); + return new VolumetricShaftOutput(GpuTextureSlot.Unassigned, LastDiagnostics); + } + + Target target = Prepare(inputs.ViewportWidth, inputs.ViewportHeight); + long started = Stopwatch.GetTimestamp(); + AtmosphericFrameUniforms atmospheric = FrameUniforms(inputs, strength); + GpuRingAllocation frameBlock = frame.AllocateRing( + AtmosphericFrameUniforms.SizeInBytes, + GpuRingUsage.Uniform); + MemoryMarshal.Write(frameBlock.Data, in atmospheric); + GpuRingAllocation passBlock = frame.AllocateRing( + AtmosphericPackPassUniforms.SizeInBytes, + GpuRingUsage.Uniform); + var passValues = new AtmosphericPackPassUniforms( + new Vector4(density, strength, _quality.RayMarchSteps, 1f), + Vector4.Zero, + Vector4.Zero, + Vector4.Zero); + MemoryMarshal.Write(passBlock.Data, in passValues); + GpuRingAllocation settingsBlock = frame.AllocateRing( + PackSettingsUniforms.SizeInBytes, + GpuRingUsage.Uniform); + PackSettingsUniforms settings = _settings; + MemoryMarshal.Write(settingsBlock.Data, in settings); + + using (IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription + { + Name = TimerName, + Color = new GpuColorAttachment( + target.RenderTarget, + GpuLoadOp.Clear, + GpuStoreOp.Store, + Vector4.Zero), + Depth = null, + SampleCount = 1, + })) + using (encoder.BeginTimerScope(TimerName)) + { + encoder.BindPipeline(_pipeline); + encoder.BindUniformBuffer( + GpuBindingModel.UniformAtmosphericFrame, + frameBlock.Buffer, + frameBlock.OffsetBytes, + AtmosphericFrameUniforms.SizeInBytes); + encoder.BindUniformBuffer( + GpuBindingModel.UniformDirectionalShadow, + shadow.Buffer!, + shadow.OffsetBytes, + shadow.SizeBytes); + encoder.BindUniformBuffer( + GpuBindingModel.UniformPackPass, + passBlock.Buffer, + passBlock.OffsetBytes, + AtmosphericPackPassUniforms.SizeInBytes); + encoder.BindUniformBuffer( + GpuBindingModel.UniformPackSettings, + settingsBlock.Buffer, + settingsBlock.OffsetBytes, + PackSettingsUniforms.SizeInBytes); + GpuPushConstants push = GpuPushConstants.Default; + push.TextureIndexA = sceneDepth.Index; + push.TextureIndexB = GpuTextureSlot.Unassigned.Index; + push.ParamA = BitConverter.UInt32BitsToSingle(GpuTextureSlot.Unassigned.Index); + push.ParamB = BitConverter.UInt32BitsToSingle(GpuTextureSlot.Unassigned.Index); + encoder.SetPushConstants(in push); + encoder.Draw(3, 1, 0, 0); + } + + bool hasGpu = _device.Timers.TryResolve(TimerName, out double milliseconds); + LastDiagnostics = new VolumetricShaftDiagnostics( + VolumetricShaftGateReason.Rendered, + target.RenderTarget.Description.Width, + target.RenderTarget.Description.Height, + _quality.RayMarchSteps, + density, + strength, + target.RetainedBytes, + milliseconds, + hasGpu, + DrawCalls: 1); + _performance.Observe( + Stopwatch.GetElapsedTime(started).TotalMilliseconds, + absoluteReceiverCpuMilliseconds: 0d, + hasGpu, + milliseconds, + target.RetainedBytes, + transientGpuBytes: 0); + return new VolumetricShaftOutput(target.TextureSlot, LastDiagnostics); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _target?.Dispose(); + _target = null; + _pipeline.Dispose(); + } + + private Target Prepare(int outputWidth, int outputHeight) + { + int width = Math.Max(1, (int)MathF.Ceiling(outputWidth * _quality.ResolutionScale)); + int height = Math.Max(1, (int)MathF.Ceiling(outputHeight * _quality.ResolutionScale)); + if (_target is { } current + && current.RenderTarget.Description.Width == width + && current.RenderTarget.Description.Height == height) + return current; + + IGpuRenderTarget? renderTarget = null; + GpuTextureSlot slot = GpuTextureSlot.Unassigned; + try + { + renderTarget = _device.CreateRenderTarget(new GpuRenderTargetDescription( + "atmospheric-volumetric", + width, + height, + GpuTextureFormat.Rgba16FloatRenderTarget, + DepthFormat: null, + SampleCount: 1)); + slot = _device.RegisterTexture(renderTarget.ColorTexture, _sampler); + var candidate = new Target(_device, renderTarget, slot); + renderTarget = null; + slot = GpuTextureSlot.Unassigned; + Target? prior = _target; + _target = candidate; + prior?.Dispose(); + _performance.Reset(); + return candidate; + } + catch + { + if (slot.IsAssigned) + _device.ReleaseTextureSlot(slot); + renderTarget?.Dispose(); + throw; + } + } + + private VolumetricShaftGateReason Gate( + IGpuFrame frame, + in AtmosphericFrameInputs inputs, + in DirectionalShadowFrameBinding shadow, + GpuTextureSlot sceneDepth) + { + if (_declaredStrength <= 0f) + return VolumetricShaftGateReason.DisabledByPreset; + if (!shadow.IsValidFor(frame)) + return VolumetricShaftGateReason.NoCurrentDirectionalShadow; + if (!sceneDepth.IsAssigned) + return VolumetricShaftGateReason.NoSceneDepth; + if (!inputs.IsOutdoor) + return VolumetricShaftGateReason.Indoor; + if (!inputs.SunIsOnScreen) + return VolumetricShaftGateReason.SunOffScreen; + return VolumetricShaftGateReason.Rendered; + } + + private (float Density, float Strength) Parameters(in AtmosphericFrameInputs inputs) + { + float weatherTarget = inputs.Weather switch + { + AcDream.Core.World.WeatherKind.Clear => 1f, + AcDream.Core.World.WeatherKind.Overcast => 0.18f, + AcDream.Core.World.WeatherKind.Rain => 0.10f, + AcDream.Core.World.WeatherKind.Snow => 0.16f, + AcDream.Core.World.WeatherKind.Storm => 0.06f, + _ => 0f, + }; + float weatherBlend = Math.Clamp(inputs.WeatherIntensity, 0f, 1f); + float weather = 1f + ((weatherTarget - 1f) * weatherBlend); + float elevation = RenderPackAtmospherePolicyEvaluation.VolumetricShaft( + _atmospherePolicy.VolumetricShaftSunElevationResponse, + inputs.SunElevationDegrees); + float authoredEnergy = Math.Clamp(inputs.SunDirectionalBrightness, 0f, 4f); + float dayGroup = _dayGroupMultipliers.TryGetValue( + inputs.ActiveDayGroup, + out float declaredDayGroup) + ? Math.Clamp(declaredDayGroup, 0f, 4f) + : 1f; + float strength = Math.Clamp( + _declaredStrength * weather * elevation * authoredEnergy * dayGroup, + 0f, + 1f); + return (0.035f * strength, strength); + } + + private AtmosphericFrameUniforms FrameUniforms( + in AtmosphericFrameInputs inputs, + float strength) => new( + new Vector4(inputs.SunScreenUv, strength, inputs.SunElevationDegrees), + new Vector4(inputs.SunColor, strength), + new Vector4(inputs.ViewportWidth, inputs.ViewportHeight, + 1f / inputs.ViewportWidth, 1f / inputs.ViewportHeight), + new Vector4((float)inputs.Weather, inputs.WeatherIntensity, + (float)Math.Clamp(inputs.DeltaSeconds, 0d, 1d), inputs.IsOutdoor ? 1f : 0f), + new Vector4(inputs.SunDirection, inputs.SunDirectionalBrightness), + new Vector4( + inputs.ActiveDayGroup, + _dayGroupMultipliers.TryGetValue(inputs.ActiveDayGroup, out float dayGroup) + ? dayGroup + : 1f, + RenderPackAtmospherePolicyEvaluation.DirectionalShadow( + _atmospherePolicy.DirectionalShadowLightElevationResponse, + inputs.SunElevationDegrees), + RenderPackAtmospherePolicyEvaluation.VolumetricShaft( + _atmospherePolicy.VolumetricShaftSunElevationResponse, + inputs.SunElevationDegrees)), + inputs.InverseViewProjection); + + private VolumetricShaftDiagnostics Disabled(VolumetricShaftGateReason reason) => new( + reason, + 0, + 0, + _quality.RayMarchSteps, + 0f, + 0f, + _target?.RetainedBytes ?? 0L, + 0d, + false, + 0); + + private static DirectionalShadowPreset PresetOf(RenderQualityPreset preset) => + preset.Semantic switch + { + RenderQualitySemantic.Low => DirectionalShadowPreset.Low, + RenderQualitySemantic.High => DirectionalShadowPreset.High, + _ => DirectionalShadowPreset.Medium, + }; + + private static VolumetricShaftQuality ResolveQuality( + RenderPackDescriptor descriptor, + RenderQualityPreset preset, + IReadOnlyDictionary? userSettingOverrides) + { + VolumetricShaftQuality quality = VolumetricShaftQuality.For(PresetOf(preset)); + RenderResourceDeclaration resource = descriptor.Resources.Single(value => + value.Semantic == RenderResourceSemantic.VolumetricShafts); + RenderQualityResourceOverride? resourceOverride = preset.ResourceOverrides + .FirstOrDefault(value => string.Equals( + value.ResourceId, + resource.Id, + StringComparison.OrdinalIgnoreCase)); + RenderExtentDeclaration extent = resourceOverride?.Extent + ?? resource.Extent + ?? throw new NotSupportedException( + "The VolumetricShafts semantic resource has no image extent."); + if (extent.Mode is not RenderExtentMode.RelativeToMainWorld + and not RenderExtentMode.RelativeToOutput) + { + throw new NotSupportedException( + "The VolumetricShafts semantic resource must use a relative extent."); + } + int steps = checked((int)MathF.Round(ReadSetting( + descriptor, + preset, + userSettingOverrides, + RenderSettingSemantic.VolumetricRayMarchSteps, + quality.RayMarchSteps))); + return quality with + { + ResolutionScale = (float)Math.Clamp(extent.Width, 0.0625, 1.0), + RayMarchSteps = Math.Clamp(steps, 8, 64), + }; + } + + private static float ReadSetting( + RenderPackDescriptor descriptor, + RenderQualityPreset preset, + IReadOnlyDictionary? userSettingOverrides, + RenderSettingSemantic semantic, + float fallback) + { + RenderSettingDeclaration? setting = descriptor.Settings.FirstOrDefault(candidate => + candidate.Semantic == semantic); + if (setting is null) + return fallback; + string value = RenderPackSettingResolution.Resolve( + setting, + preset, + userSettingOverrides); + return RenderPackSettingValueCodec.TryEncode(setting, value, out float encoded) + ? Math.Max(0f, encoded) + : fallback; + } + + private sealed class Target( + IGpuDevice device, + IGpuRenderTarget renderTarget, + GpuTextureSlot textureSlot) : IDisposable + { + internal IGpuRenderTarget RenderTarget { get; } = renderTarget; + internal GpuTextureSlot TextureSlot { get; } = textureSlot; + internal long RetainedBytes => checked( + (long)RenderTarget.Description.Width * RenderTarget.Description.Height * 8L); + + public void Dispose() + { + device.ReleaseTextureSlot(TextureSlot); + RenderTarget.Dispose(); + } + } +} diff --git a/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs b/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs index e3d2e350..7af1b277 100644 --- a/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs +++ b/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs @@ -1,4 +1,5 @@ using AcDream.Core.World; +using AcDream.App.Rendering.Packs; namespace AcDream.App.Rendering; @@ -153,6 +154,7 @@ internal sealed class RenderFrameDiagnosticsController : private readonly IRenderFrameResourceDiagnosticsSource? _resources; private readonly IRenderFrameDiagnosticLog _log; private readonly bool _publishResourceDiagnostics; + private readonly IRenderPackDiagnosticsSnapshotSource? _renderPack; private double _elapsedSeconds; private int _frameCount; @@ -165,7 +167,8 @@ internal sealed class RenderFrameDiagnosticsController : IRenderFrameTitleSink titleSink, IRenderFrameDiagnosticLog log, bool publishResourceDiagnostics, - IRenderFrameResourceDiagnosticsSource? resources = null) + IRenderFrameResourceDiagnosticsSource? resources = null, + IRenderPackDiagnosticsSnapshotSource? renderPack = null) { _titleFacts = titleFacts ?? throw new ArgumentNullException(nameof(titleFacts)); _titleSink = titleSink ?? throw new ArgumentNullException(nameof(titleSink)); @@ -174,6 +177,7 @@ internal sealed class RenderFrameDiagnosticsController : _resources = publishResourceDiagnostics ? resources ?? throw new ArgumentNullException(nameof(resources)) : resources; + _renderPack = renderPack; } public void Publish(RenderFrameInput input, RenderFrameOutcome outcome) @@ -201,6 +205,11 @@ internal sealed class RenderFrameDiagnosticsController : { RenderFrameResourceDiagnosticsSnapshot resources = _resources!.Capture(); _log.WriteLine(FormatGpuStream(resources)); + if (_renderPack is not null) + { + _log.WriteLine(RenderPackDiagnosticsFormatter.Format( + _renderPack.CaptureDiagnostics())); + } } Snapshot = new RenderFrameDiagnosticsSnapshot( diff --git a/src/AcDream.App/Rendering/RetailDetailTextureContract.cs b/src/AcDream.App/Rendering/RetailDetailTextureContract.cs new file mode 100644 index 00000000..613fbb8f --- /dev/null +++ b/src/AcDream.App/Rendering/RetailDetailTextureContract.cs @@ -0,0 +1,47 @@ +using System.Numerics; +using AcDream.App.Rendering.Gpu; + +namespace AcDream.App.Rendering; + +/// +/// Testable CPU statement of retail's detail-pass gate and pixel math. The +/// production pixels are produced by mesh_detail; keeping these facts in +/// one small contract makes the setting, distance units, neutral point, and +/// intentional brightening independently assertable without a GPU. +/// +internal static class RetailDetailTextureContract +{ + internal const float FullDetailDistanceMetres = 10f; + internal const float ZeroDetailDistanceMetres = 50f; + + internal static bool ShouldRender( + bool settingEnabled, + TerrainAtlas.RetailDetailTextureBinding binding) => + settingEnabled && binding.IsAvailable; + + /// + /// Opaque detail must compare equal against the depth written by its exact + /// base geometry. On an MSAA target that inherits the base pass's per-sample + /// alpha-to-coverage mask without applying A2C to the detail alpha itself. + /// Transparent bases do not write depth, so their adjacent detail uses the + /// accepted less-or-equal comparison instead. + /// + internal static GpuCompareOp DetailDepthCompare(bool transparent) => + transparent ? GpuCompareOp.LessOrEqual : GpuCompareOp.Equal; + + internal static float FadeForPositiveViewDepthMetres(float depthMetres) => + Math.Clamp( + (ZeroDetailDistanceMetres - depthMetres) + / (ZeroDetailDistanceMetres - FullDetailDistanceMetres), + 0f, + 1f); + + /// + /// Effective multiplier on the existing framebuffer after the shader + /// scales both detail RGB and alpha by fade and the pipeline applies + /// DstColor + OneMinusSrcAlpha. + /// + internal static Vector3 FramebufferFactor(Vector4 detail, float fade) => + Vector3.One + fade * (new Vector3(detail.X, detail.Y, detail.Z) + - new Vector3(detail.W)); +} diff --git a/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs b/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs index 07f7f3a2..c43a3690 100644 --- a/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs +++ b/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs @@ -1,4 +1,6 @@ +using System.Numerics; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using AcDream.App.Rendering; using Arch.Core; using ArchWorld = Arch.Core.World; @@ -37,6 +39,12 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource private RenderProjectionCounts _counts; private ulong _lastAppliedJournalSequence; private ulong _indexRevision = 1; + private ulong _directionalShadowTopologyRevision = 1; + private DirectionalShadowTransformChange[]? _directionalShadowTransformChanges; + private Dictionary? + _directionalShadowPartPoses; + private ulong _directionalShadowTransformRevision; + private int _directionalShadowTransformChangeCount; private bool _disposed; public ArchRenderScene(RenderSceneGeneration initialGeneration) @@ -76,6 +84,23 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource long lookupBytes = (long)lookupCapacity * Unsafe.SizeOf(); long indexBytes = EstimateIndexBytes(); + long directionalShadowJournalBytes = + _directionalShadowTransformChanges is null + ? 0 + : checked((long)_directionalShadowTransformChanges.Length + * Unsafe.SizeOf()); + if (_directionalShadowPartPoses is not null) + { + directionalShadowJournalBytes = checked( + directionalShadowJournalBytes + + (long)_directionalShadowPartPoses.EnsureCapacity(0) + * (sizeof(int) + + Unsafe.SizeOf>()) + + _directionalShadowPartPoses.Values.Sum(static pose => + (long)pose.Count * Unsafe.SizeOf())); + } return new RenderSceneMemoryAccounting( EntityCount: _world.Size, @@ -86,7 +111,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource ProjectionLookupCapacity: lookupCapacity, EstimatedProjectionLookupBytes: lookupBytes, EstimatedIndexBytes: indexBytes, - EstimatedJournalBufferBytes: 0, + EstimatedJournalBufferBytes: directionalShadowJournalBytes, EstimatedSynchronizationSourceBytes: 0); } } @@ -163,7 +188,8 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource ref _world.Get(entry.Entity); ref RenderWorldBounds bounds = ref _world.Get(entry.Entity); - if (current == update.Transform && bounds == update.Bounds) + bool transformChanged = !TransformBitsEqual(current, update.Transform); + if (!transformChanged && bounds == update.Bounds) continue; _world.Set( @@ -171,6 +197,15 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource new PreviousRenderTransform(current.LocalToWorld)); _world.Set(entry.Entity, update.Transform); _world.Set(entry.Entity, update.Bounds); + if (transformChanged + && HasRefreshableDirectionalShadowTransforms(entry.ProjectionClass) + && _directionalShadowTransformChanges is not null) + { + RenderProjectionRecord currentRecord = ReadRecord(in entry); + PublishDirectionalShadowTransformChange( + in currentRecord, + DirectionalShadowTransformChangeKind.DynamicSynchronization); + } ref RenderDirtyMask dirty = ref _world.Get(entry.Entity); @@ -226,8 +261,10 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource ClearIndices(); _counts = default; _lastAppliedJournalSequence = 0; + ResetDirectionalShadowTransformChanges(); Generation = replacementGeneration; AdvanceIndexRevision(); + AdvanceDirectionalShadowTopologyRevision(); } public void Dispose() @@ -239,6 +276,10 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource ArchWorld.Destroy(_world); _entries.Clear(); ClearIndices(); + _directionalShadowTransformChanges = null; + _directionalShadowPartPoses = null; + _directionalShadowTransformRevision = 0; + _directionalShadowTransformChangeCount = 0; _counts = default; _disposed = true; } @@ -284,6 +325,99 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource return _indexRevision; } + ulong IRenderSceneQuerySource.GetDirectionalShadowTopologyRevision( + RenderSceneGeneration generation) + { + EnsureQueryGeneration(generation); + return _directionalShadowTopologyRevision; + } + + ulong IRenderSceneQuerySource.GetDirectionalShadowTransformRevision( + RenderSceneGeneration generation) + { + EnsureQueryGeneration(generation); + EnsureDirectionalShadowTransformJournal(); + return _directionalShadowTransformRevision; + } + + DirectionalShadowTransformChanges + IRenderSceneQuerySource.CopyDirectionalShadowTransformChanges( + RenderSceneGeneration generation, + ulong afterRevision, + Span destination) + { + EnsureQueryGeneration(generation); + EnsureDirectionalShadowTransformJournal(); + ulong latest = _directionalShadowTransformRevision; + if (afterRevision == latest) + return new DirectionalShadowTransformChanges(latest, 0, false); + if (afterRevision == 0 + || afterRevision > latest + || latest - afterRevision + > checked((ulong)_directionalShadowTransformChangeCount)) + { + return new DirectionalShadowTransformChanges(latest, 0, true); + } + + int count = checked((int)(latest - afterRevision)); + if (destination.Length < count) + return new DirectionalShadowTransformChanges(latest, 0, true); + DirectionalShadowTransformChange[] journal = + _directionalShadowTransformChanges!; + int updateTransformCount = 0; + int updateAppearanceCount = 0; + int dynamicSynchronizationCount = 0; + int activeAnimatedStaticCount = 0; + int liveDynamicRootCount = 0; + int equippedChildCount = 0; + for (int index = 0; index < count; index++) + { + ulong revision = checked(afterRevision + (ulong)index + 1UL); + DirectionalShadowTransformChange change = + journal[(int)(revision % (ulong)journal.Length)]; + if (change.Revision != revision) + return new DirectionalShadowTransformChanges(latest, 0, true); + destination[index] = change.Projection; + switch (change.Kind) + { + case DirectionalShadowTransformChangeKind.UpdateTransform: + updateTransformCount++; + break; + case DirectionalShadowTransformChangeKind.UpdateAppearance: + updateAppearanceCount++; + break; + case DirectionalShadowTransformChangeKind.DynamicSynchronization: + dynamicSynchronizationCount++; + break; + default: + throw new InvalidOperationException( + $"Unknown directional-shadow change kind {change.Kind}."); + } + switch (change.Projection.ProjectionClass) + { + case RenderProjectionClass.ActiveAnimatedStatic: + activeAnimatedStaticCount++; + break; + case RenderProjectionClass.LiveDynamicRoot: + liveDynamicRootCount++; + break; + case RenderProjectionClass.EquippedChild: + equippedChildCount++; + break; + } + } + return new DirectionalShadowTransformChanges( + latest, + count, + false, + updateTransformCount, + updateAppearanceCount, + dynamicSynchronizationCount, + activeAnimatedStaticCount, + liveDynamicRootCount, + equippedChildCount); + } + bool IRenderSceneQuerySource.TryGet( RenderSceneGeneration generation, RenderProjectionId id, @@ -300,6 +434,30 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource return false; } + int IRenderSceneQuerySource.CopyById( + RenderSceneGeneration generation, + ReadOnlySpan ids, + Span destination) + { + EnsureQueryGeneration(generation); + if (destination.Length < ids.Length) + { + throw new ArgumentException( + "The render-scene ID-copy destination is too small.", + nameof(destination)); + } + for (int index = 0; index < ids.Length; index++) + { + if (!_entries.TryGetValue(ids[index], out SceneEntry entry)) + { + throw new InvalidOperationException( + $"Render-scene projection {ids[index]} disappeared during a batched copy."); + } + destination[index] = ReadRecord(in entry); + } + return ids.Length; + } + int IRenderSceneQuerySource.CopyTo( RenderSceneGeneration generation, RenderProjectionClass? projectionClass, @@ -391,6 +549,16 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource RenderProjectionRecord prior = ReadRecord(in existing); WriteRecord(existing.Entity, in record); UpdateIndices(in prior, in record); + if (HasRefreshableDirectionalShadowTransforms(record.ProjectionClass)) + { + if (!TransformBitsEqual(prior.Transform, record.Transform)) + { + PublishDirectionalShadowTransformChange( + in record, + DirectionalShadowTransformChangeKind.UpdateTransform); + } + PublishDirectionalShadowPartPoseChangeIfNeeded(in record); + } result.Applied++; result.Updated++; return; @@ -407,6 +575,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource record.ProjectionClass); IncrementCount(record.ProjectionClass); AddToIndices(in record); + SynchronizeDirectionalShadowPartPose(in record); result.Applied++; result.Registered++; } @@ -465,6 +634,20 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource RenderProjectionRecord current = ReadRecord(in entry); UpdateIndices(in prior, in current); + if (HasRefreshableDirectionalShadowTransforms(current.ProjectionClass)) + { + if (kind is RenderProjectionDeltaKind.UpdateTransform + && !TransformBitsEqual(prior.Transform, current.Transform)) + { + PublishDirectionalShadowTransformChange( + in current, + DirectionalShadowTransformChangeKind.UpdateTransform); + } + else if (kind is RenderProjectionDeltaKind.UpdateAppearance) + { + PublishDirectionalShadowPartPoseChangeIfNeeded(in current); + } + } result.Applied++; result.Updated++; } @@ -582,6 +765,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource private void Destroy(in SceneEntry entry) { RenderProjectionRecord record = ReadRecord(in entry); + _directionalShadowPartPoses?.Remove(record.Id); RemoveFromIndices(in record); _world.Destroy(entry.Entity); DecrementCount(entry.ProjectionClass); @@ -603,6 +787,8 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource { if (IndexMembershipEquals(in prior, in current)) { + if (!DirectionalShadowTopologyEquals(in prior, in current)) + AdvanceDirectionalShadowTopologyRevision(); SynchronizeDirtyIndex(in current); return; } @@ -652,6 +838,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource if (record.DirtyMask != RenderDirtyMask.None) _dirty.Add(record.Id); AdvanceIndexRevision(); + AdvanceDirectionalShadowTopologyRevision(); } private void RemoveFromIndices(in RenderProjectionRecord record) @@ -668,6 +855,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource RemoveCell(_cellStatics, record.Residency.FullCellId, record.Id); RemoveCell(_cellDynamics, record.Residency.FullCellId, record.Id); AdvanceIndexRevision(); + AdvanceDirectionalShadowTopologyRevision(); } private static bool IndexMembershipEquals( @@ -689,6 +877,235 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource && left.SortKey == right.SortKey; } + private static bool DirectionalShadowTopologyEquals( + in RenderProjectionRecord left, + in RenderProjectionRecord right) + { + const RenderProjectionFlags eligibilityFlags = + RenderProjectionFlags.Draw + | RenderProjectionFlags.SpatiallyResident + | RenderProjectionFlags.Translucent; + + if (left.ProjectionClass != right.ProjectionClass + || left.OwnerIncarnation != right.OwnerIncarnation + || left.Source.ParentCellId != right.Source.ParentCellId + || (left.Flags & eligibilityFlags) != (right.Flags & eligibilityFlags) + || left.SortKey != right.SortKey + || left.MeshSet.MeshCount != right.MeshSet.MeshCount + || left.Material != right.Material + || left.DegradeState != right.DegradeState + || left.Source.AppearanceFingerprint + != right.Source.AppearanceFingerprint + || left.Source.DirectionalShadowTopologyFingerprint + != right.Source.DirectionalShadowTopologyFingerprint + || left.EntityPayload.IsBuildingShell + != right.EntityPayload.IsBuildingShell + || left.EntityPayload.CasterIdentity + != right.EntityPayload.CasterIdentity + || !PaletteEquals( + left.EntityPayload.PaletteOverride, + right.EntityPayload.PaletteOverride)) + { + return false; + } + + bool refreshableTransforms = + HasRefreshableDirectionalShadowTransforms(left.ProjectionClass); + if (!refreshableTransforms + && (left.Transform != right.Transform + || left.MeshSet != right.MeshSet + || left.Source.GeometryFingerprint + != right.Source.GeometryFingerprint)) + { + return false; + } + + IReadOnlyList? leftMeshes = + left.EntityPayload.MeshRefs; + IReadOnlyList? rightMeshes = + right.EntityPayload.MeshRefs; + if (ReferenceEquals(leftMeshes, rightMeshes)) + return true; + if (leftMeshes is null + || rightMeshes is null + || leftMeshes.Count != rightMeshes.Count) + { + return false; + } + + for (int meshIndex = 0; meshIndex < leftMeshes.Count; meshIndex++) + { + AcDream.Core.World.MeshRef leftMesh = leftMeshes[meshIndex]; + AcDream.Core.World.MeshRef rightMesh = rightMeshes[meshIndex]; + if (leftMesh.GfxObjId != rightMesh.GfxObjId + || !SurfaceOverridesEqual( + leftMesh.SurfaceOverrides, + rightMesh.SurfaceOverrides) + || (!refreshableTransforms + && leftMesh.PartTransform != rightMesh.PartTransform)) + { + return false; + } + } + + return true; + } + + private static bool HasRefreshableDirectionalShadowTransforms( + RenderProjectionClass projectionClass) => + projectionClass is RenderProjectionClass.ActiveAnimatedStatic + or RenderProjectionClass.LiveDynamicRoot + or RenderProjectionClass.EquippedChild; + + private static bool TransformBitsEqual( + in RenderTransform left, + in RenderTransform right) + { + Matrix4x4 leftMatrix = left.LocalToWorld; + Matrix4x4 rightMatrix = right.LocalToWorld; + ReadOnlySpan leftSpan = MemoryMarshal.CreateReadOnlySpan( + in leftMatrix, + 1); + ReadOnlySpan rightSpan = MemoryMarshal.CreateReadOnlySpan( + in rightMatrix, + 1); + return MemoryMarshal.AsBytes(leftSpan).SequenceEqual( + MemoryMarshal.AsBytes(rightSpan)); + } + + private void EnsureDirectionalShadowTransformJournal() + { + if (_directionalShadowTransformChanges is not null) + return; + _directionalShadowTransformChanges = new DirectionalShadowTransformChange[ + DirectionalShadowTransformChangeJournal.Capacity]; + _directionalShadowTransformRevision = 1; + _directionalShadowTransformChangeCount = 0; + _directionalShadowPartPoses = new Dictionary< + RenderProjectionId, + DirectionalShadowPartPoseSnapshot>(); + foreach (SceneEntry entry in _entries.Values) + { + if (!HasRefreshableDirectionalShadowTransforms(entry.ProjectionClass)) + continue; + RenderProjectionRecord record = ReadRecord(in entry); + SynchronizeDirectionalShadowPartPose(in record); + } + } + + private void PublishDirectionalShadowTransformChange( + in RenderProjectionRecord projection, + DirectionalShadowTransformChangeKind kind) + { + DirectionalShadowTransformChange[]? journal = + _directionalShadowTransformChanges; + if (journal is null) + return; + if (_directionalShadowTransformRevision == ulong.MaxValue) + { + throw new InvalidOperationException( + "Directional-shadow transform revision space was exhausted."); + } + ulong revision = ++_directionalShadowTransformRevision; + journal[(int)(revision % (ulong)journal.Length)] = + new DirectionalShadowTransformChange( + revision, + DirectionalShadowTransformSnapshot.Capture(in projection), + kind); + if (_directionalShadowTransformChangeCount < journal.Length) + _directionalShadowTransformChangeCount++; + } + + private void ResetDirectionalShadowTransformChanges() + { + if (_directionalShadowTransformChanges is null) + return; + _directionalShadowTransformRevision = 1; + _directionalShadowTransformChangeCount = 0; + _directionalShadowPartPoses!.Clear(); + } + + private void SynchronizeDirectionalShadowPartPose( + in RenderProjectionRecord record) + { + Dictionary? + poses = _directionalShadowPartPoses; + if (poses is null + || !HasRefreshableDirectionalShadowTransforms(record.ProjectionClass)) + { + return; + } + if (!poses.TryGetValue(record.Id, out DirectionalShadowPartPoseSnapshot? pose)) + { + poses.Add(record.Id, DirectionalShadowPartPoseSnapshot.Capture(in record)); + return; + } + pose.CaptureCurrent(in record); + } + + private void PublishDirectionalShadowPartPoseChangeIfNeeded( + in RenderProjectionRecord record) + { + Dictionary? + poses = _directionalShadowPartPoses; + if (poses is null) + return; + if (!poses.TryGetValue(record.Id, out DirectionalShadowPartPoseSnapshot? pose)) + { + poses.Add(record.Id, DirectionalShadowPartPoseSnapshot.Capture(in record)); + return; + } + if (!pose.CaptureCurrent(in record)) + return; + PublishDirectionalShadowTransformChange( + in record, + DirectionalShadowTransformChangeKind.UpdateAppearance); + } + + private static bool PaletteEquals( + AcDream.Core.World.PaletteOverride? left, + AcDream.Core.World.PaletteOverride? right) + { + if (ReferenceEquals(left, right)) + return true; + if (left is null + || right is null + || left.BasePaletteId != right.BasePaletteId + || left.SubPalettes.Count != right.SubPalettes.Count) + { + return false; + } + + for (int index = 0; index < left.SubPalettes.Count; index++) + { + if (left.SubPalettes[index] != right.SubPalettes[index]) + return false; + } + + return true; + } + + private static bool SurfaceOverridesEqual( + IReadOnlyDictionary? left, + IReadOnlyDictionary? right) + { + if (ReferenceEquals(left, right)) + return true; + if (left is null || right is null || left.Count != right.Count) + return false; + + foreach ((uint surfaceId, uint textureId) in left) + { + if (!right.TryGetValue(surfaceId, out uint candidate) + || candidate != textureId) + { + return false; + } + } + + return true; + } + private void SynchronizeDirtyIndex( in RenderProjectionRecord record) { @@ -709,6 +1126,17 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource _indexRevision++; } + private void AdvanceDirectionalShadowTopologyRevision() + { + if (_directionalShadowTopologyRevision == ulong.MaxValue) + { + throw new InvalidOperationException( + "Directional-shadow topology revision space was exhausted."); + } + + _directionalShadowTopologyRevision++; + } + private static bool IsDynamic(RenderProjectionClass projectionClass) => projectionClass is RenderProjectionClass.LiveDynamicRoot or RenderProjectionClass.EquippedChild; @@ -925,10 +1353,71 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource hash.Add(record.Source.AppearanceFingerprint.Low); hash.Add(record.Source.AppearanceFingerprint.High); hash.Add(record.Source.CurrentProjectionFlags); + hash.Add((byte)record.EntityPayload.CasterIdentity); } private readonly record struct ProjectionIdentity(RenderProjectionId Id); + private readonly record struct DirectionalShadowTransformChange( + ulong Revision, + DirectionalShadowTransformSnapshot Projection, + DirectionalShadowTransformChangeKind Kind); + + private sealed class DirectionalShadowPartPoseSnapshot + { + private Matrix4x4[] _parts; + + private DirectionalShadowPartPoseSnapshot(Matrix4x4[] parts) => + _parts = parts; + + internal int Count => _parts.Length; + + internal static DirectionalShadowPartPoseSnapshot Capture( + in RenderProjectionRecord record) + { + IReadOnlyList? meshes = + record.EntityPayload.MeshRefs; + var parts = new Matrix4x4[meshes?.Count ?? 0]; + for (int index = 0; index < parts.Length; index++) + parts[index] = meshes![index].PartTransform; + return new DirectionalShadowPartPoseSnapshot(parts); + } + + internal bool CaptureCurrent(in RenderProjectionRecord record) + { + IReadOnlyList? meshes = + record.EntityPayload.MeshRefs; + int count = meshes?.Count ?? 0; + bool changed = _parts.Length != count; + if (changed) + _parts = new Matrix4x4[count]; + for (int index = 0; index < count; index++) + { + Matrix4x4 current = meshes![index].PartTransform; + if (!MatrixBitsEqual(in _parts[index], in current)) + { + _parts[index] = current; + changed = true; + } + } + return changed; + } + + private static bool MatrixBitsEqual( + in Matrix4x4 left, + in Matrix4x4 right) + { + ReadOnlySpan leftSpan = MemoryMarshal.CreateReadOnlySpan( + in left, + 1); + ReadOnlySpan rightSpan = MemoryMarshal.CreateReadOnlySpan( + in right, + 1); + return MemoryMarshal.AsBytes(leftSpan).SequenceEqual( + MemoryMarshal.AsBytes(rightSpan)); + } + } + private readonly record struct OutdoorStaticTag; private readonly record struct IndoorCellStaticTag; diff --git a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs index ecea5872..5758a9d5 100644 --- a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs +++ b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs @@ -834,6 +834,22 @@ internal sealed class CurrentRenderSceneOracle : geometry.Add(fingerprint.High); } + internal static RenderSceneHash128 + CreateDirectionalShadowTopologyFingerprint(WorldEntity entity) + { + ArgumentNullException.ThrowIfNull(entity); + StableRenderHash128 topology = StableRenderHash128.Create(); + topology.Add(entity.MeshRefs.Count); + for (int meshIndex = 0; meshIndex < entity.MeshRefs.Count; meshIndex++) + { + MeshRef mesh = entity.MeshRefs[meshIndex]; + topology.Add(mesh.GfxObjId); + AddSurfaceOverrides(ref topology, mesh.SurfaceOverrides); + } + + return topology.Finish(); + } + internal static RenderSceneHash128 CreateSurfaceOverrideFingerprint( IReadOnlyDictionary? overrides) { diff --git a/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs b/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs new file mode 100644 index 00000000..e3e77ed8 --- /dev/null +++ b/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs @@ -0,0 +1,542 @@ +namespace AcDream.App.Rendering.Scene; + +/// +/// Projection-level membership. Opaque versus alpha-cutout remains an exact +/// mesh-batch decision in the dispatcher; this product deliberately does not +/// guess from an entity's texture set. +/// +internal enum DirectionalShadowCasterKind : byte +{ + OutdoorStatic, + Building, + AnimatedStatic, + LiveDynamic, + EquippedChild, +} + +internal readonly record struct DirectionalShadowCaster( + RenderProjectionRecord Projection, + DirectionalShadowCasterKind Kind) +{ + public bool UsesCurrentAnimatedTransforms => + Projection.ProjectionClass + is RenderProjectionClass.ActiveAnimatedStatic + or RenderProjectionClass.LiveDynamicRoot + or RenderProjectionClass.EquippedChild; +} + +internal readonly struct DirectionalShadowChangedPose +{ + internal DirectionalShadowChangedPose( + int casterIndex, + in DirectionalShadowTransformSnapshot snapshot) + { + CasterIndex = casterIndex; + Snapshot = snapshot; + } + + internal readonly int CasterIndex; + internal readonly DirectionalShadowTransformSnapshot Snapshot; +} + +/// +/// Accepted caster counts by the strongest class proven at render publication. +/// TerrainCommands is populated by the terrain command producer. OutdoorStatics +/// includes trees and all other outdoor DAT scenery; NonPlayerCreatures includes +/// hostile monsters and non-hostile NPC creatures because neither source carries +/// a narrower authoritative render-only discriminator. +/// +internal readonly record struct DirectionalShadowCasterClassDiagnostics( + int TerrainCommands, + int OutdoorStatics, + int Buildings, + int AnimatedStatics, + int LocalPlayers, + int RemotePlayers, + int NonPlayerCreatures, + int OtherLiveDynamics, + int EquippedChildren); + +internal readonly record struct DirectionalShadowCasterBuildStats( + int SourceOutdoorStatics, + int SourceOutdoorDynamics, + int Accepted, + int RejectedNotDrawable, + int RejectedNotResident, + int RejectedTransparent, + int RejectedIndoor, + int RejectedMissingMesh, + int IndexCopies, + int Classifications, + int DynamicTransformRefreshes, + bool TopologyRebuilt, + int CopiedTransformChanges = 0, + int DedupedChangedCasterSlots = 0, + bool TransformJournalFullRefresh = false, + int UpdateTransformChanges = 0, + int UpdateAppearanceChanges = 0, + int DynamicSynchronizationChanges = 0, + int ActiveAnimatedStaticChanges = 0, + int LiveDynamicRootChanges = 0, + int EquippedChildChanges = 0, + bool DensityBulkRefresh = false, + int BatchedProjectionCopyCalls = 0) +{ + public DirectionalShadowCasterClassDiagnostics CasterClasses { get; init; } +} + +/// +/// Reusable, streaming-bounded caster product. It copies and classifies the +/// render scene's two resident outdoor indices only when the scene's shadow +/// topology revision changes. Stable frames retain those topology records and +/// emit only deduplicated slim root/part pose changes for prepared matrix slots. +/// +internal sealed class DirectionalShadowCasterFrame +{ + private RenderProjectionRecord[] _outdoorStaticScratch = []; + private RenderProjectionRecord[] _outdoorDynamicScratch = []; + private DirectionalShadowCaster[] _casters = []; + private int[] _refreshCasterSlots = []; + private DirectionalShadowChangedPose[] _changedCasterPoses = []; + private bool[] _changedCasterFlags = []; + private RenderProjectionId[] _casterIds = []; + private RenderProjectionClass[] _casterClasses = []; + private RenderProjectionId[] _denseIdScratch = []; + private RenderProjectionRecord[] _denseRecordScratch = []; + private readonly DirectionalShadowTransformSnapshot[] _transformChangeScratch = + new DirectionalShadowTransformSnapshot[ + DirectionalShadowTransformChangeJournal.Capacity]; + private readonly Dictionary _refreshCasterSlotById = []; + private int _casterCount; + private int _refreshCasterSlotCount; + private int _changedCasterPoseCount; + private ulong _topologyRevision; + private ulong _transformRevision; + private DirectionalShadowTransformChanges _lastTransformChanges; + private bool _lastDensityBulkRefresh; + private int _lastBatchedProjectionCopyCalls; + + public RenderSceneGeneration Generation { get; private set; } + + public ulong BuildSequence { get; private set; } + + public ReadOnlySpan Casters => + _casters.AsSpan(0, _casterCount); + + internal ReadOnlySpan RefreshCasterSlots => + _refreshCasterSlots.AsSpan(0, _refreshCasterSlotCount); + + internal ReadOnlySpan ChangedCasterPoses => + _changedCasterPoses.AsSpan(0, _changedCasterPoseCount); + + internal ulong TransformRevision => _transformRevision; + + public DirectionalShadowCasterBuildStats Stats { get; private set; } + + public long RetainedScratchBytes => + checked( + (long)_outdoorStaticScratch.Length + * System.Runtime.CompilerServices.Unsafe.SizeOf() + + (long)_outdoorDynamicScratch.Length + * System.Runtime.CompilerServices.Unsafe.SizeOf() + + (long)_casters.Length + * System.Runtime.CompilerServices.Unsafe.SizeOf() + + (long)_refreshCasterSlots.Length * sizeof(int) + + (long)_changedCasterPoses.Length + * System.Runtime.CompilerServices.Unsafe.SizeOf< + DirectionalShadowChangedPose>() + + _changedCasterFlags.Length + + (long)_casterIds.Length + * System.Runtime.CompilerServices.Unsafe.SizeOf< + RenderProjectionId>() + + (long)_casterClasses.Length + * System.Runtime.CompilerServices.Unsafe.SizeOf< + RenderProjectionClass>() + + (long)_denseIdScratch.Length + * System.Runtime.CompilerServices.Unsafe.SizeOf() + + (long)_denseRecordScratch.Length + * System.Runtime.CompilerServices.Unsafe.SizeOf() + + (long)_transformChangeScratch.Length + * System.Runtime.CompilerServices.Unsafe.SizeOf< + DirectionalShadowTransformSnapshot>() + + (long)_refreshCasterSlotById.EnsureCapacity(0) + * (sizeof(int) + + System.Runtime.CompilerServices.Unsafe.SizeOf< + KeyValuePair>())); + + public void Build(in RenderSceneQuery query) + { + ulong topologyRevision = query.DirectionalShadowTopologyRevision; + if (BuildSequence != 0 + && Generation == query.Generation + && _topologyRevision == topologyRevision) + { + int refreshes = RefreshChangedTransforms(in query); + Stats = Stats with + { + IndexCopies = 0, + Classifications = 0, + DynamicTransformRefreshes = refreshes, + TopologyRebuilt = false, + CopiedTransformChanges = _lastTransformChanges.Count, + DedupedChangedCasterSlots = _changedCasterPoseCount, + TransformJournalFullRefresh = + _lastTransformChanges.RequiresFullRefresh, + DensityBulkRefresh = _lastDensityBulkRefresh, + BatchedProjectionCopyCalls = _lastBatchedProjectionCopyCalls, + UpdateTransformChanges = + _lastTransformChanges.UpdateTransformCount, + UpdateAppearanceChanges = + _lastTransformChanges.UpdateAppearanceCount, + DynamicSynchronizationChanges = + _lastTransformChanges.DynamicSynchronizationCount, + ActiveAnimatedStaticChanges = + _lastTransformChanges.ActiveAnimatedStaticCount, + LiveDynamicRootChanges = + _lastTransformChanges.LiveDynamicRootCount, + EquippedChildChanges = + _lastTransformChanges.EquippedChildCount, + }; + return; + } + + RenderSceneIndexCounts counts = query.IndexCounts; + EnsureCapacity(ref _outdoorStaticScratch, counts.OutdoorStatic); + EnsureCapacity(ref _outdoorDynamicScratch, counts.OutdoorDynamic); + int staticCount = query.CopyIndexTo( + RenderSceneIndex.OutdoorStatic, + _outdoorStaticScratch.AsSpan(0, counts.OutdoorStatic)); + int dynamicCount = query.CopyIndexTo( + RenderSceneIndex.OutdoorDynamic, + _outdoorDynamicScratch.AsSpan(0, counts.OutdoorDynamic)); + EnsureCapacity(ref _casters, checked(staticCount + dynamicCount)); + _casterCount = 0; + + int rejectedNotDrawable = 0; + int rejectedNotResident = 0; + int rejectedTransparent = 0; + int rejectedIndoor = 0; + int rejectedMissingMesh = 0; + int outdoorStatics = 0; + int buildings = 0; + int animatedStatics = 0; + int localPlayers = 0; + int remotePlayers = 0; + int nonPlayerCreatures = 0; + int otherLiveDynamics = 0; + int equippedChildren = 0; + for (int i = 0; i < staticCount; i++) + Add(_outdoorStaticScratch[i]); + for (int i = 0; i < dynamicCount; i++) + Add(_outdoorDynamicScratch[i]); + + Array.Sort( + _casters, + 0, + _casterCount, + DirectionalShadowCasterComparer.Instance); + int refreshCasterCount = 0; + for (int casterIndex = 0; casterIndex < _casterCount; casterIndex++) + { + if (_casters[casterIndex].UsesCurrentAnimatedTransforms) + refreshCasterCount++; + } + EnsureCapacity(ref _refreshCasterSlots, refreshCasterCount); + EnsureCapacity(ref _changedCasterPoses, refreshCasterCount); + EnsureCapacity(ref _changedCasterFlags, _casterCount); + EnsureCapacity(ref _casterIds, _casterCount); + EnsureCapacity(ref _casterClasses, _casterCount); + EnsureCapacity(ref _denseIdScratch, refreshCasterCount); + EnsureCapacity(ref _denseRecordScratch, refreshCasterCount); + _refreshCasterSlotCount = 0; + _changedCasterPoseCount = 0; + _refreshCasterSlotById.Clear(); + _refreshCasterSlotById.EnsureCapacity(refreshCasterCount); + for (int casterIndex = 0; casterIndex < _casterCount; casterIndex++) + { + _casterIds[casterIndex] = _casters[casterIndex].Projection.Id; + _casterClasses[casterIndex] = + _casters[casterIndex].Projection.ProjectionClass; + if (_casters[casterIndex].UsesCurrentAnimatedTransforms) + { + _refreshCasterSlots[_refreshCasterSlotCount++] = casterIndex; + _refreshCasterSlotById.Add( + _casterIds[casterIndex], + casterIndex); + } + } + Generation = query.Generation; + _topologyRevision = topologyRevision; + _transformRevision = query.DirectionalShadowTransformRevision; + _lastTransformChanges = default; + _lastDensityBulkRefresh = false; + _lastBatchedProjectionCopyCalls = 0; + BuildSequence = checked(BuildSequence + 1); + Stats = new DirectionalShadowCasterBuildStats( + staticCount, + dynamicCount, + _casterCount, + rejectedNotDrawable, + rejectedNotResident, + rejectedTransparent, + rejectedIndoor, + rejectedMissingMesh, + IndexCopies: 2, + Classifications: _casterCount, + DynamicTransformRefreshes: 0, + TopologyRebuilt: true) + { + CasterClasses = new DirectionalShadowCasterClassDiagnostics( + TerrainCommands: 0, + outdoorStatics, + buildings, + animatedStatics, + localPlayers, + remotePlayers, + nonPlayerCreatures, + otherLiveDynamics, + equippedChildren), + }; + return; + + void Add(in RenderProjectionRecord projection) + { + if ((projection.Flags & RenderProjectionFlags.Draw) == 0) + { + rejectedNotDrawable++; + return; + } + if ((projection.Flags & RenderProjectionFlags.SpatiallyResident) == 0) + { + rejectedNotResident++; + return; + } + // Transparent means a true blended projection. ClipMap/foliage is + // retained here and separated from opaque batches later. + if ((projection.Flags & RenderProjectionFlags.Translucent) != 0) + { + rejectedTransparent++; + return; + } + if (projection.Source.ParentCellId != 0 + && InteriorEntityPartition.IsIndoorCellId( + projection.Source.ParentCellId)) + { + rejectedIndoor++; + return; + } + if (projection.MeshSet.MeshCount <= 0 + || projection.EntityPayload.MeshRefs is null + || projection.EntityPayload.MeshRefs.Count == 0) + { + rejectedMissingMesh++; + return; + } + + DirectionalShadowCasterKind kind = Classify(in projection); + _casters[_casterCount++] = new DirectionalShadowCaster( + projection, + kind); + switch (kind) + { + case DirectionalShadowCasterKind.OutdoorStatic: + outdoorStatics++; + break; + case DirectionalShadowCasterKind.Building: + buildings++; + break; + case DirectionalShadowCasterKind.AnimatedStatic: + animatedStatics++; + break; + case DirectionalShadowCasterKind.EquippedChild: + equippedChildren++; + break; + case DirectionalShadowCasterKind.LiveDynamic: + switch (projection.EntityPayload.CasterIdentity) + { + case RenderCasterIdentityKind.LocalPlayer: + localPlayers++; + break; + case RenderCasterIdentityKind.RemotePlayer: + remotePlayers++; + break; + case RenderCasterIdentityKind.NonPlayerCreature: + nonPlayerCreatures++; + break; + default: + otherLiveDynamics++; + break; + } + break; + default: + throw new ArgumentOutOfRangeException( + nameof(kind), kind, "Unknown shadow caster kind."); + } + } + } + + private int RefreshChangedTransforms(in RenderSceneQuery query) + { + _changedCasterPoseCount = 0; + ulong latest = query.DirectionalShadowTransformRevision; + if (latest == _transformRevision) + { + _lastTransformChanges = new DirectionalShadowTransformChanges( + latest, + 0, + false); + _lastDensityBulkRefresh = false; + _lastBatchedProjectionCopyCalls = 0; + return 0; + } + + DirectionalShadowTransformChanges changes = + query.CopyDirectionalShadowTransformChanges( + _transformRevision, + _transformChangeScratch); + _lastTransformChanges = changes; + if (changes.RequiresFullRefresh) + { + _lastBatchedProjectionCopyCalls = 1; + for (int index = 0; index < _refreshCasterSlotCount; index++) + { + int casterIndex = _refreshCasterSlots[index]; + _denseIdScratch[index] = _casters[casterIndex].Projection.Id; + } + int copied = query.CopyById( + _denseIdScratch.AsSpan(0, _refreshCasterSlotCount), + _denseRecordScratch.AsSpan(0, _refreshCasterSlotCount)); + if (copied != _refreshCasterSlotCount) + { + throw new InvalidOperationException( + "Dense directional-shadow refresh returned an incomplete record batch."); + } + for (int index = 0; index < _refreshCasterSlotCount; index++) + { + int casterIndex = _refreshCasterSlots[index]; + RefreshOne(in _denseRecordScratch[index], casterIndex); + DirectionalShadowTransformSnapshot snapshot = + DirectionalShadowTransformSnapshot.Capture( + in _denseRecordScratch[index]); + _changedCasterPoses[_changedCasterPoseCount++] = + new DirectionalShadowChangedPose(casterIndex, in snapshot); + } + _lastDensityBulkRefresh = false; + _transformRevision = changes.LatestRevision; + return _changedCasterPoseCount; + } + _lastBatchedProjectionCopyCalls = 0; + + try + { + ReadOnlySpan records = + _transformChangeScratch.AsSpan(0, changes.Count); + // Newest-first makes repeated publications of the same projection + // resolve to the latest exact root/part payload without an ECS read. + for (int index = records.Length - 1; index >= 0; index--) + { + if (!_refreshCasterSlotById.TryGetValue( + records[index].Id, + out int casterIndex) + || _changedCasterFlags[casterIndex]) + { + continue; + } + _changedCasterFlags[casterIndex] = true; + ValidateStablePose(in records[index], casterIndex); + _changedCasterPoses[_changedCasterPoseCount++] = + new DirectionalShadowChangedPose( + casterIndex, + in records[index]); + } + } + finally + { + for (int index = 0; index < _changedCasterPoseCount; index++) + { + _changedCasterFlags[ + _changedCasterPoses[index].CasterIndex] = false; + } + } + _lastDensityBulkRefresh = _refreshCasterSlotCount >= 64 + && _changedCasterPoseCount + >= checked((_refreshCasterSlotCount * 3) / 4); + _transformRevision = changes.LatestRevision; + return _changedCasterPoseCount; + } + + private void ValidateStablePose( + in DirectionalShadowTransformSnapshot current, + int casterIndex) + { + if (current.Id != _casterIds[casterIndex] + || current.ProjectionClass != _casterClasses[casterIndex]) + { + throw new InvalidOperationException( + $"Stable directional-shadow topology changed caster " + + $"{_casterIds[casterIndex]} identity or class."); + } + } + + private void RefreshOne( + in RenderProjectionRecord current, + int casterIndex) + { + DirectionalShadowCaster retained = _casters[casterIndex]; + if (current.Id != retained.Projection.Id + || current.ProjectionClass != retained.Projection.ProjectionClass) + { + throw new InvalidOperationException( + $"Stable directional-shadow topology changed caster " + + $"{retained.Projection.Id} identity or class."); + } + _casters[casterIndex] = retained with { Projection = current }; + } + + private static DirectionalShadowCasterKind Classify( + in RenderProjectionRecord projection) + { + if (projection.EntityPayload.IsBuildingShell) + return DirectionalShadowCasterKind.Building; + return projection.ProjectionClass switch + { + RenderProjectionClass.OutdoorStatic => + DirectionalShadowCasterKind.OutdoorStatic, + RenderProjectionClass.ActiveAnimatedStatic => + DirectionalShadowCasterKind.AnimatedStatic, + RenderProjectionClass.LiveDynamicRoot => + DirectionalShadowCasterKind.LiveDynamic, + RenderProjectionClass.EquippedChild => + DirectionalShadowCasterKind.EquippedChild, + _ => throw new InvalidOperationException( + $"Outdoor shadow index carried unsupported {projection.ProjectionClass}."), + }; + } + + private static void EnsureCapacity(ref T[] values, int required) + { + if (required < 0) + throw new ArgumentOutOfRangeException(nameof(required)); + if (values.Length >= required) + return; + int capacity = values.Length == 0 ? 4 : values.Length; + while (capacity < required) + capacity = checked(capacity * 2); + Array.Resize(ref values, capacity); + } + + private sealed class DirectionalShadowCasterComparer + : IComparer + { + public static DirectionalShadowCasterComparer Instance { get; } = new(); + + public int Compare(DirectionalShadowCaster left, DirectionalShadowCaster right) + { + int order = left.Projection.SortKey.Value.CompareTo( + right.Projection.SortKey.Value); + return order != 0 + ? order + : left.Projection.Id.CompareTo(right.Projection.Id); + } + } +} diff --git a/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs b/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs index cad94968..c1125ec0 100644 --- a/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs +++ b/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs @@ -1,5 +1,7 @@ +using AcDream.App.Input; using AcDream.App.Update; using AcDream.App.World; +using AcDream.Core.Items; using AcDream.Core.World; using AcDream.Runtime.Entities; @@ -26,6 +28,7 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink private readonly LiveEntityRuntime _runtime; private readonly RenderProjectionJournal _journal; private readonly IRenderTraversalOrderSource _traversalOrder; + private readonly ILocalPlayerIdentitySource? _localPlayer; private readonly Dictionary _byKey = []; private readonly List _activeRootScratch = []; private readonly List _activeScratch = []; @@ -33,12 +36,14 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink public LiveRenderProjectionJournal( LiveEntityRuntime runtime, RenderProjectionJournal journal, - IRenderTraversalOrderSource traversalOrder) + IRenderTraversalOrderSource traversalOrder, + ILocalPlayerIdentitySource? localPlayer = null) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _journal = journal ?? throw new ArgumentNullException(nameof(journal)); _traversalOrder = traversalOrder ?? throw new ArgumentNullException(nameof(traversalOrder)); + _localPlayer = localPlayer; } public int ProjectionCount => _byKey.Count; @@ -286,7 +291,14 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink ownerLandblockId, fullCellId, entity, - spatiallyVisible); + spatiallyVisible, + record.ProjectionKind is LiveEntityProjectionKind.Attached + ? RenderCasterIdentityKind.EquippedChild + : RenderCasterIdentityClassifier.Classify( + record.Snapshot.Guid, + record.Snapshot.ItemType, + record.Snapshot.ObjectDescriptionFlags, + _localPlayer?.ServerGuid ?? 0u)); if (_traversalOrder.TryGetTraversalSortKey( entity, out RenderSortKey sortKey)) @@ -336,6 +348,31 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink } } +internal static class RenderCasterIdentityClassifier +{ + private const uint PlayerDescriptionFlag = 0x8u; + private const uint PlayerGuidPrefix = 0x50000000u; + + internal static RenderCasterIdentityKind Classify( + uint serverGuid, + uint? itemType, + uint? objectDescriptionFlags, + uint localPlayerGuid) + { + if (localPlayerGuid != 0 && serverGuid == localPlayerGuid) + return RenderCasterIdentityKind.LocalPlayer; + if ((objectDescriptionFlags.GetValueOrDefault() + & PlayerDescriptionFlag) != 0 + || (serverGuid & 0xFF000000u) == PlayerGuidPrefix) + { + return RenderCasterIdentityKind.RemotePlayer; + } + if ((itemType.GetValueOrDefault() & (uint)ItemType.Creature) != 0) + return RenderCasterIdentityKind.NonPlayerCreature; + return RenderCasterIdentityKind.OtherLiveDynamic; + } +} + internal sealed class LiveRenderProjectionResourceLifecycle( ILiveRenderProjectionSink sink) : ILiveEntityResourceLifecycle { diff --git a/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs b/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs index 10f4c3a0..5145e252 100644 --- a/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs +++ b/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs @@ -19,7 +19,9 @@ internal static class RenderProjectionRecordFactory uint ownerLandblockId, uint fullCellId, WorldEntity entity, - bool spatiallyVisible) + bool spatiallyVisible, + RenderCasterIdentityKind casterIdentity = + RenderCasterIdentityKind.Unclassified) { ArgumentNullException.ThrowIfNull(entity); CurrentRenderProjectionFingerprint fingerprint = @@ -80,11 +82,14 @@ internal static class RenderProjectionRecordFactory fingerprint.Transform, fingerprint.Geometry, fingerprint.Appearance, - fingerprint.Flags), + fingerprint.Flags, + CurrentRenderSceneOracle + .CreateDirectionalShadowTopologyFingerprint(entity)), new RenderEntityPayload( entity.MeshRefs, entity.PaletteOverride, - entity.IsBuildingShell)); + entity.IsBuildingShell, + casterIdentity)); } private static (Vector3 Minimum, Vector3 Maximum) CalculateBounds( diff --git a/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs b/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs index e3450ce2..bc410372 100644 --- a/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs +++ b/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs @@ -222,7 +222,26 @@ internal readonly record struct RenderSourceMetadata( RenderSceneHash128 TransformFingerprint, RenderSceneHash128 GeometryFingerprint, RenderSceneHash128 AppearanceFingerprint, - uint CurrentProjectionFlags = 0); + uint CurrentProjectionFlags = 0, + RenderSceneHash128 DirectionalShadowTopologyFingerprint = default); + +/// +/// Render-only identity facts retained from the authoritative publication edge. +/// Outdoor DAT scenery has no tree discriminator, and the create-object payload +/// does not distinguish hostile monsters from other non-player creatures, so +/// neither narrower category is guessed here. +/// +internal enum RenderCasterIdentityKind : byte +{ + Unclassified, + OutdoorStatic, + Building, + LocalPlayer, + RemotePlayer, + NonPlayerCreature, + OtherLiveDynamic, + EquippedChild, +} /// /// Borrowed immutable presentation payload captured at a scene publication @@ -234,7 +253,9 @@ internal readonly record struct RenderSourceMetadata( internal readonly record struct RenderEntityPayload( IReadOnlyList MeshRefs, PaletteOverride? PaletteOverride, - bool IsBuildingShell); + bool IsBuildingShell, + RenderCasterIdentityKind CasterIdentity = + RenderCasterIdentityKind.Unclassified); internal readonly record struct RenderProjectionRecord( RenderProjectionId Id, @@ -449,6 +470,66 @@ internal readonly record struct RenderSceneDigest( RenderProjectionCounts Counts, RenderSceneHash128 Hash); +internal static class DirectionalShadowTransformChangeJournal +{ + // Larger than the measured 9,498-caster dense-Arwic row so one complete + // changed-pose publication fits without truncation. Overflow is explicit + // and makes the consumer take its exact full-refresh fallback. + internal const int Capacity = 16_384; +} + +internal readonly struct DirectionalShadowTransformSnapshot +{ + internal DirectionalShadowTransformSnapshot( + RenderProjectionId id, + RenderProjectionClass projectionClass, + RenderTransform transform, + RenderEntityPayload entityPayload) + { + Id = id; + ProjectionClass = projectionClass; + Transform = transform; + EntityPayload = entityPayload; + } + + internal readonly RenderProjectionId Id; + internal readonly RenderProjectionClass ProjectionClass; + internal readonly RenderTransform Transform; + internal readonly RenderEntityPayload EntityPayload; + + internal static DirectionalShadowTransformSnapshot Capture( + in RenderProjectionRecord projection) => + new( + projection.Id, + projection.ProjectionClass, + projection.Transform, + projection.EntityPayload); +} + +internal readonly record struct DirectionalShadowTransformChanges( + ulong LatestRevision, + int Count, + bool RequiresFullRefresh, + int UpdateTransformCount = 0, + int UpdateAppearanceCount = 0, + int DynamicSynchronizationCount = 0, + int ActiveAnimatedStaticCount = 0, + int LiveDynamicRootCount = 0, + int EquippedChildCount = 0); + +// Transform-journal records carry the producer's already-current projection. +// Root matrices are values; MeshRef payloads are borrowed under the render +// publication ordering rule: a part-pose mutation must be followed by its +// UpdateAppearance publication before the frame opens a scene query. Consumers +// read newest-to-oldest, so repeated IDs always select the latest publication. + +internal enum DirectionalShadowTransformChangeKind : byte +{ + UpdateTransform, + UpdateAppearance, + DynamicSynchronization, +} + internal sealed class RenderSceneDigestBuffer { internal List Records { get; } = []; @@ -461,12 +542,26 @@ internal interface IRenderSceneQuerySource RenderProjectionCounts GetCounts(RenderSceneGeneration generation); RenderSceneIndexCounts GetIndexCounts(RenderSceneGeneration generation); ulong GetIndexRevision(RenderSceneGeneration generation); + ulong GetDirectionalShadowTopologyRevision( + RenderSceneGeneration generation); + ulong GetDirectionalShadowTransformRevision( + RenderSceneGeneration generation); + + DirectionalShadowTransformChanges CopyDirectionalShadowTransformChanges( + RenderSceneGeneration generation, + ulong afterRevision, + Span destination); bool TryGet( RenderSceneGeneration generation, RenderProjectionId id, out RenderProjectionRecord record); + int CopyById( + RenderSceneGeneration generation, + ReadOnlySpan ids, + Span destination); + int CopyTo( RenderSceneGeneration generation, RenderProjectionClass? projectionClass, @@ -512,11 +607,30 @@ internal readonly struct RenderSceneQuery public ulong IndexRevision => Source.GetIndexRevision(Generation); + public ulong DirectionalShadowTopologyRevision => + Source.GetDirectionalShadowTopologyRevision(Generation); + + public ulong DirectionalShadowTransformRevision => + Source.GetDirectionalShadowTransformRevision(Generation); + + public DirectionalShadowTransformChanges CopyDirectionalShadowTransformChanges( + ulong afterRevision, + Span destination) => + Source.CopyDirectionalShadowTransformChanges( + Generation, + afterRevision, + destination); + public bool TryGet( RenderProjectionId id, out RenderProjectionRecord record) => Source.TryGet(Generation, id, out record); + public int CopyById( + ReadOnlySpan ids, + Span destination) => + Source.CopyById(Generation, ids, destination); + public int CopyTo(Span destination) => Source.CopyTo(Generation, null, destination); diff --git a/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs b/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs index aad7adbd..58ce3b11 100644 --- a/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs +++ b/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using AcDream.App.Input; using AcDream.App.Rendering.Scene.Arch; using AcDream.App.World; using AcDream.Core.World; @@ -95,7 +96,8 @@ internal sealed class RenderSceneShadowRuntime : IDisposable public LiveRenderProjectionJournal BindLiveRuntime( LiveEntityRuntime runtime, - IRenderTraversalOrderSource traversalOrder) + IRenderTraversalOrderSource traversalOrder, + ILocalPlayerIdentitySource? localPlayer = null) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(runtime); @@ -109,7 +111,8 @@ internal sealed class RenderSceneShadowRuntime : IDisposable _live = new LiveRenderProjectionJournal( runtime, _journal, - traversalOrder); + traversalOrder, + localPlayer); return _live; } diff --git a/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs b/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs index e6140060..83c2b06b 100644 --- a/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs +++ b/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs @@ -122,7 +122,9 @@ internal sealed class StaticRenderProjectionJournal : && accepted.Source.AppearanceFingerprint == retained.Record.Source.AppearanceFingerprint && accepted.EntityPayload.IsBuildingShell - == retained.Record.EntityPayload.IsBuildingShell) + == retained.Record.EntityPayload.IsBuildingShell + && accepted.EntityPayload.CasterIdentity + == retained.Record.EntityPayload.CasterIdentity) { accepted = accepted with { @@ -245,7 +247,10 @@ internal sealed class StaticRenderProjectionJournal : tracked.Record.Residency.OwnerLandblockId, tracked.Record.Residency.FullCellId, entity, - spatiallyVisible: true) with + spatiallyVisible: true, + casterIdentity: entity.IsBuildingShell + ? RenderCasterIdentityKind.Building + : RenderCasterIdentityKind.OutdoorStatic) with { PreviousTransform = new PreviousRenderTransform( tracked.Record.Transform.LocalToWorld), @@ -317,7 +322,10 @@ internal sealed class StaticRenderProjectionJournal : landblockId, fullCellId, entity, - spatiallyVisible: true) with + spatiallyVisible: true, + casterIdentity: entity.IsBuildingShell + ? RenderCasterIdentityKind.Building + : RenderCasterIdentityKind.OutdoorStatic) with { SortKey = sortKey, }; diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs index 636cc3e6..e4d4ba9d 100644 --- a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs +++ b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs @@ -12,7 +12,7 @@ namespace AcDream.App.Rendering.Selection; /// internal interface IWorldSceneSelectionFrame { - void BeginFrame(); + void BeginFrame(FrustumPlanes? preparedViewFrustum = null); void CompleteFrame(); @@ -76,7 +76,7 @@ internal sealed class RetailSelectionScene : _lightingPulse.Clear(); } - public void BeginFrame() + public void BeginFrame(FrustumPlanes? preparedViewFrustum = null) { if (_frameOpen) { @@ -87,7 +87,7 @@ internal sealed class RetailSelectionScene : _frameOpen = true; _building.Clear(); _buildingKeys.Clear(); - _viewFrustum = null; + _viewFrustum = preparedViewFrustum; _currentRenderSceneObserver?.BeginSelectionFrame(); } diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_blur.frag b/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_blur.frag new file mode 100644 index 00000000..84cf49c2 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_blur.frag @@ -0,0 +1,17 @@ +#version 430 core + +layout(location = 0) in vec2 vUv; +layout(location = 0) out vec4 oColor; + +#include "atmospheric_common.glsl" + +void main() +{ + vec2 stepUv = uPackParams0.xy; + vec3 value = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).rgb * 0.227027; + value += ACDREAM_SAMPLE_2D(uTextureIndexA, vUv + stepUv * 1.384615).rgb * 0.316216; + value += ACDREAM_SAMPLE_2D(uTextureIndexA, vUv - stepUv * 1.384615).rgb * 0.316216; + value += ACDREAM_SAMPLE_2D(uTextureIndexA, vUv + stepUv * 3.230769).rgb * 0.070270; + value += ACDREAM_SAMPLE_2D(uTextureIndexA, vUv - stepUv * 3.230769).rgb * 0.070270; + oColor = vec4(value, 1.0); +} diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_blur.vert b/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_blur.vert new file mode 100644 index 00000000..9e98b26d --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_blur.vert @@ -0,0 +1,10 @@ +#version 430 core + +layout(location = 0) out vec2 vUv; + +void main() +{ + vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); + gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0); + vUv = vec2(triangle.x, 1.0 - triangle.y); +} diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_downsample.frag b/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_downsample.frag new file mode 100644 index 00000000..efff81e7 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_downsample.frag @@ -0,0 +1,23 @@ +#version 430 core + +layout(location = 0) in vec2 vUv; +layout(location = 0) out vec4 oColor; + +#include "atmospheric_common.glsl" + +void main() +{ + vec3 scene = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).rgb + + ACDREAM_SAMPLE_2D(uTextureIndexB, vUv).rgb; + if (uPackParams0.w > 0.5) + scene += ACDREAM_SAMPLE_2D(uTextureIndexC, vUv).rgb; + float brightness = dot(scene, vec3(0.2126, 0.7152, 0.0722)); + float threshold = uPackParams0.y; + float knee = max(uPackParams0.z, 0.0001); + float soft = clamp((brightness - threshold + knee) / (2.0 * knee), 0.0, 1.0); + soft = soft * soft; + float contribution = max(brightness - threshold, 0.0) + soft * knee; + contribution /= max(brightness, 0.0001); + vec3 bloom = scene * contribution * uPackParams0.x; + oColor = vec4(bloom, 1.0); +} diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_downsample.vert b/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_downsample.vert new file mode 100644 index 00000000..9e98b26d --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_downsample.vert @@ -0,0 +1,10 @@ +#version 430 core + +layout(location = 0) out vec2 vUv; + +void main() +{ + vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); + gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0); + vUv = vec2(triangle.x, 1.0 - triangle.y); +} diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_common.glsl b/src/AcDream.App/Rendering/Shaders/atmospheric_common.glsl new file mode 100644 index 00000000..659fcd68 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/atmospheric_common.glsl @@ -0,0 +1,34 @@ +#ifndef ACDREAM_ATMOSPHERIC_COMMON_GLSL +#define ACDREAM_ATMOSPHERIC_COMMON_GLSL + +// Render-pack shader ABI v1. These declarations are the byte-level SSOT for +// AtmosphericFrameUniforms (set 3/binding 5, 160 bytes) and +// AtmosphericPackPassUniforms (set 3/binding 7). Binding 6 is intentionally +// reserved for directional-shadow data in directional_shadow_common.glsl. +layout(std140, ACDREAM_PACK_UBO_SET binding = 5) uniform AtmosphericFrame { + vec4 uAtmosphereSunScreen; // 0: uv.xy, ray strength, elevation degrees + vec4 uAtmosphereSunColor; // 16: authored linear rgb, policy multiplier + vec4 uAtmosphereViewport; // 32: width, height, reciprocal width/height + vec4 uAtmosphereWeather; // 48: kind, intensity, delta seconds, outdoor + vec4 uAtmosphereSunDirection; // 64: surface-to-sun xyz, authored brightness + vec4 uAtmospherePolicy; // 80: day group, group factor, shadow/shaft elevation factors + mat4 uAtmosphereInverseViewProjection; // 96: screen/depth to world +}; + +layout(std140, ACDREAM_PACK_UBO_SET binding = 7) uniform PackPass { + vec4 uPackParams0; // 0 + vec4 uPackParams1; // 16 + vec4 uPackParams2; // 32 + vec4 uPackParams3; // 48 +}; + +// FusedAtmosphericPostProcess PackPass ABI (opt-in Low preset only): +// sun-rays: Params1 = (enabled, logical mask width, mask height, 0) +// filmic: Params1.z = enabled; Params2 = bloom extraction parameters; +// Params3.xy = logical bloom texel step + +layout(std140, ACDREAM_PACK_UBO_SET binding = 8) uniform PackSettings { + vec4 uPackSettings[16]; // 64 declaration-order scalar setting slots +}; + +#endif diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_filmic.frag b/src/AcDream.App/Rendering/Shaders/atmospheric_filmic.frag new file mode 100644 index 00000000..0e50f4f1 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/atmospheric_filmic.frag @@ -0,0 +1,88 @@ +#version 430 core + +layout(location = 0) in vec2 vUv; +layout(location = 0) out vec4 oColor; + +#include "atmospheric_common.glsl" + +vec3 acesFitted(vec3 value) +{ + const float a = 2.51; + const float b = 0.03; + const float c = 2.43; + const float d = 0.59; + const float e = 0.14; + return clamp((value * (a * value + b)) / (value * (c * value + d) + e), 0.0, 1.0); +} + +vec3 sampleBloom(vec2 uv) +{ + return ACDREAM_SAMPLE_2D(uTextureIndexB, uv).rgb; +} + +vec3 lowFusedScene(vec2 uv) +{ + vec3 scene = ACDREAM_SAMPLE_2D(uTextureIndexA, uv).rgb + + ACDREAM_SAMPLE_2D(uTextureIndexB, uv).rgb; + if (uPackParams2.w > 0.5) + scene += ACDREAM_SAMPLE_2D(uTextureIndexC, uv).rgb; + return scene; +} + +vec3 lowFusedBloomExtract(vec3 scene) +{ + float brightness = dot(scene, vec3(0.2126, 0.7152, 0.0722)); + float threshold = uPackParams2.y; + float knee = max(uPackParams2.z, 0.0001); + float soft = clamp((brightness - threshold + knee) / (2.0 * knee), 0.0, 1.0); + soft = soft * soft; + float contribution = max(brightness - threshold, 0.0) + soft * knee; + contribution /= max(brightness, 0.0001); + return scene * contribution * uPackParams2.x; +} + +vec3 lowFusedBloom(vec3 centerScene) +{ + const float offsets[5] = float[5]( + -3.230769, -1.384615, 0.0, 1.384615, 3.230769); + const float weights[5] = float[5]( + 0.070270, 0.316216, 0.227027, 0.316216, 0.070270); + vec3 bloom = vec3(0.0); + for (int y = 0; y < 5; ++y) { + for (int x = 0; x < 5; ++x) { + vec3 scene = x == 2 && y == 2 + ? centerScene + : lowFusedScene(vUv + vec2(offsets[x], offsets[y]) * uPackParams3.xy); + bloom += lowFusedBloomExtract(scene) * (weights[x] * weights[y]); + } + } + return bloom; +} + +void main() +{ + vec3 hdr; + if (uPackParams1.z > 0.5) { + vec3 scene = lowFusedScene(vUv); + hdr = scene + lowFusedBloom(scene); + } + else { + hdr = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).rgb + + sampleBloom(vUv) + + ACDREAM_SAMPLE_2D(uTextureIndexC, vUv).rgb; + if (uPackParams1.y > 0.5) + hdr += ACDREAM_SAMPLE_2D(uTextureIndexD, vUv).rgb; + } + vec3 exposed = max(hdr * uPackParams0.x, vec3(0.0)); + vec3 linearClamped = clamp(exposed, 0.0, 1.0); + vec3 color = mix(linearClamped, acesFitted(exposed), clamp(uPackParams1.x, 0.0, 1.0)); + + float luminance = dot(color, vec3(0.2126, 0.7152, 0.0722)); + color = mix(vec3(luminance), color, uPackParams0.y); + color = (color - 0.5) * uPackParams0.z + 0.5; + + vec2 centered = vUv * 2.0 - 1.0; + float vignette = smoothstep(1.25, 0.25, dot(centered, centered)); + color *= mix(1.0, vignette, clamp(uPackParams0.w, 0.0, 1.0)); + oColor = vec4(clamp(color, 0.0, 1.0), 1.0); +} diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_filmic.vert b/src/AcDream.App/Rendering/Shaders/atmospheric_filmic.vert new file mode 100644 index 00000000..9e98b26d --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/atmospheric_filmic.vert @@ -0,0 +1,10 @@ +#version 430 core + +layout(location = 0) out vec2 vUv; + +void main() +{ + vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); + gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0); + vUv = vec2(triangle.x, 1.0 - triangle.y); +} diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_sun_occlusion.frag b/src/AcDream.App/Rendering/Shaders/atmospheric_sun_occlusion.frag new file mode 100644 index 00000000..bb499838 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/atmospheric_sun_occlusion.frag @@ -0,0 +1,14 @@ +#version 430 core + +layout(location = 0) in vec2 vUv; +layout(location = 0) out vec4 oColor; + +#include "atmospheric_common.glsl" + +void main() +{ + float depth = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).r; + float unobstructedSky = smoothstep(0.9975, 0.99995, depth); + float enabled = uAtmosphereSunScreen.z * uAtmosphereWeather.w; + oColor = vec4(vec3(unobstructedSky * enabled), 1.0); +} diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_sun_occlusion.vert b/src/AcDream.App/Rendering/Shaders/atmospheric_sun_occlusion.vert new file mode 100644 index 00000000..b88fc359 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/atmospheric_sun_occlusion.vert @@ -0,0 +1,12 @@ +#version 430 core + +layout(location = 0) out vec2 vUv; + +void main() +{ + vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); + gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0); + // Vulkan's negative viewport preserves GL world winding; flip the sampled + // image coordinate once here so row zero remains the screen top. + vUv = vec2(triangle.x, 1.0 - triangle.y); +} diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_sun_rays.frag b/src/AcDream.App/Rendering/Shaders/atmospheric_sun_rays.frag new file mode 100644 index 00000000..a714590f --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/atmospheric_sun_rays.frag @@ -0,0 +1,58 @@ +#version 430 core + +layout(location = 0) in vec2 vUv; +layout(location = 0) out vec4 oColor; + +#include "atmospheric_common.glsl" + +float lowFusedMaskTexel(ivec2 coordinate, vec2 maskSize) +{ + ivec2 maximum = ivec2(maskSize) - ivec2(1); + ivec2 clampedCoordinate = clamp(coordinate, ivec2(0), maximum); + vec2 depthUv = (vec2(clampedCoordinate) + vec2(0.5)) / maskSize; + float depth = ACDREAM_SAMPLE_2D(uTextureIndexA, depthUv).r; + float unobstructedSky = smoothstep(0.9975, 0.99995, depth); + float enabled = uAtmosphereSunScreen.z * uAtmosphereWeather.w; + // Match the removed RGBA8_UNORM mask attachment before filtering it. + return round(clamp(unobstructedSky * enabled, 0.0, 1.0) * 255.0) / 255.0; +} + +float sampleLowFusedMask(vec2 uv) +{ + vec2 maskSize = uPackParams1.yz; + vec2 texel = uv * maskSize - vec2(0.5); + ivec2 lower = ivec2(floor(texel)); + vec2 fraction = fract(texel); + float topLeft = lowFusedMaskTexel(lower, maskSize); + float topRight = lowFusedMaskTexel(lower + ivec2(1, 0), maskSize); + float bottomLeft = lowFusedMaskTexel(lower + ivec2(0, 1), maskSize); + float bottomRight = lowFusedMaskTexel(lower + ivec2(1, 1), maskSize); + return mix( + mix(topLeft, topRight, fraction.x), + mix(bottomLeft, bottomRight, fraction.x), + fraction.y); +} + +void main() +{ + const int SampleCount = 48; + float decay = uPackParams0.x; + float weight = uPackParams0.y; + float density = uPackParams0.z; + vec2 delta = (vUv - uAtmosphereSunScreen.xy) * (density / float(SampleCount)); + vec2 sampleUv = vUv; + float illumination = 1.0; + float sum = 0.0; + for (int i = 0; i < SampleCount; ++i) { + sampleUv -= delta; + if (any(lessThan(sampleUv, vec2(0.0))) || any(greaterThan(sampleUv, vec2(1.0)))) + break; + float mask = uPackParams1.x > 0.5 + ? sampleLowFusedMask(sampleUv) + : ACDREAM_SAMPLE_2D(uTextureIndexA, sampleUv).r; + sum += mask * illumination; + illumination *= decay; + } + vec3 rays = uAtmosphereSunColor.rgb * (sum * weight / float(SampleCount)); + oColor = vec4(rays, 1.0); +} diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_sun_rays.vert b/src/AcDream.App/Rendering/Shaders/atmospheric_sun_rays.vert new file mode 100644 index 00000000..9e98b26d --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/atmospheric_sun_rays.vert @@ -0,0 +1,10 @@ +#version 430 core + +layout(location = 0) out vec2 vUv; + +void main() +{ + vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); + gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0); + vUv = vec2(triangle.x, 1.0 - triangle.y); +} diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_volumetric.frag b/src/AcDream.App/Rendering/Shaders/atmospheric_volumetric.frag new file mode 100644 index 00000000..94cd28ab --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/atmospheric_volumetric.frag @@ -0,0 +1,67 @@ +#version 430 core + +layout(location = 0) in vec2 vUv; +layout(location = 0) out vec4 oColor; + +#include "atmospheric_common.glsl" +#include "directional_shadow_common.glsl" + +vec3 reconstructWorld(vec2 uv, float depth) +{ + vec4 clip = vec4(uv.x * 2.0 - 1.0, (1.0 - uv.y) * 2.0 - 1.0, depth, 1.0); + vec4 world = uAtmosphereInverseViewProjection * clip; + return world.xyz / max(abs(world.w), 1e-6); +} + +float directionalVisibility(vec3 worldPosition) +{ + // Volumetric shafts are a sun effect. A moon-oriented shadow map remains + // valid for world receivers but must not occlude the authored sun ray. + if (uint(round(uShadowLightDirectionAndSource.w)) != 1u) + return 1.0; + uint cascadeCount = clamp(uShadowTextureAndFlags.y, 1u, 4u); + vec3 surfaceToSun = normalize(uShadowLightDirectionAndSource.xyz); + vec3 biasedPosition = worldPosition + + surfaceToSun * max(uShadowBiasMeters.x, 0.0); + for (uint cascade = 0u; cascade < cascadeCount; ++cascade) { + vec4 clip = uShadowWorldToClip[cascade] * vec4(biasedPosition, 1.0); + vec3 ndc = clip.xyz / max(abs(clip.w), 1e-6); + vec2 uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5); + if (all(greaterThanEqual(uv, vec2(0.0))) + && all(lessThanEqual(uv, vec2(1.0))) + && ndc.z >= 0.0 && ndc.z <= 1.0) { + float stored = ACDREAM_SAMPLE_ARRAY( + uShadowTextureAndFlags.x, + vec3(uv, float(cascade))).r; + float visible = ndc.z <= stored ? 1.0 : 0.0; + return mix(1.0, visible, clamp(uShadowControl.x, 0.0, 1.0)); + } + } + return 1.0; +} + +void main() +{ + float sceneDepth = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).r; + if (sceneDepth >= 0.999999 || uPackParams0.w <= 0.0) { + oColor = vec4(0.0); + return; + } + + vec3 nearWorld = reconstructWorld(vUv, 0.0); + vec3 sceneWorld = reconstructWorld(vUv, sceneDepth); + int steps = clamp(int(uPackParams0.z + 0.5), 1, 64); + float lit = 0.0; + for (int step = 0; step < 64; ++step) { + if (step >= steps) + break; + float t = (float(step) + 0.5) / float(steps); + lit += directionalVisibility(mix(nearWorld, sceneWorld, t)); + } + + float integrated = lit / float(steps); + float extinction = 1.0 - exp(-uPackParams0.x * length(sceneWorld - nearWorld)); + vec3 color = uAtmosphereSunColor.rgb + * (integrated * extinction * uPackParams0.y); + oColor = vec4(max(color, vec3(0.0)), 1.0); +} diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_volumetric.vert b/src/AcDream.App/Rendering/Shaders/atmospheric_volumetric.vert new file mode 100644 index 00000000..9e98b26d --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/atmospheric_volumetric.vert @@ -0,0 +1,10 @@ +#version 430 core + +layout(location = 0) out vec2 vUv; + +void main() +{ + vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); + gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0); + vUv = vec2(triangle.x, 1.0 - triangle.y); +} diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_common.glsl b/src/AcDream.App/Rendering/Shaders/directional_shadow_common.glsl new file mode 100644 index 00000000..fe4f2065 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_common.glsl @@ -0,0 +1,15 @@ +#ifndef ACDREAM_DIRECTIONAL_SHADOW_COMMON_GLSL +#define ACDREAM_DIRECTIONAL_SHADOW_COMMON_GLSL + +// Render-pack shader ABI v1, set 3/binding 6. This is the byte-level SSOT for +// DirectionalShadowUniforms (std140, 336 bytes). +layout(std140, ACDREAM_PACK_UBO_SET binding = 6) uniform DirectionalShadow { + mat4 uShadowWorldToClip[4]; // 0, 64, 128, 192 + vec4 uShadowSplitFarMeters; // 256 + vec4 uShadowControl; // 272: strength, softness, reach m, blend m + vec4 uShadowBiasMeters; // 288: constant, slope, normal, caster pad + uvec4 uShadowTextureAndFlags; // 304: set2 slot, count, resolution, flags + vec4 uShadowLightDirectionAndSource; // 320: surface-to-light xyz, source kind +}; + +#endif diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_receiver.glsl b/src/AcDream.App/Rendering/Shaders/directional_shadow_receiver.glsl new file mode 100644 index 00000000..435f5ca2 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_receiver.glsl @@ -0,0 +1,176 @@ +#ifndef ACDREAM_DIRECTIONAL_SHADOW_RECEIVER_GLSL +#define ACDREAM_DIRECTIONAL_SHADOW_RECEIVER_GLSL + +#include "directional_shadow_common.glsl" + +// Receiver policy shared byte-for-byte by terrain and world meshes. Cascade +// choice is camera-distance based; the terminal blend band is measured in +// world metres, so a projection/FOV change cannot move the seam. +int acdreamShadowCascade(float cameraDistanceMeters) { + int count = int(uShadowTextureAndFlags.y); + for (int i = 0; i < count; ++i) { + if (cameraDistanceMeters <= uShadowSplitFarMeters[i]) + return i; + } + return -1; +} + +// The X/Y clip gradients are the reciprocal orthographic half extent. Their +// ratio therefore gives the exact relative texel footprint without extending +// the pinned v1 uniform block. The CPU publishes the conservative far-cascade +// bias; inner maps scale it down instead of visibly detaching nearby feet, +// foliage, and building edges from their receivers. +float acdreamShadowBiasScale(int cascade) { + int farCascade = max(int(uShadowTextureAndFlags.y) - 1, 0); + mat4 cascadeMatrix = uShadowWorldToClip[cascade]; + mat4 farMatrix = uShadowWorldToClip[farCascade]; + float cascadeDensity = 0.5 * ( + length(vec3(cascadeMatrix[0][0], cascadeMatrix[1][0], cascadeMatrix[2][0])) + + length(vec3(cascadeMatrix[0][1], cascadeMatrix[1][1], cascadeMatrix[2][1]))); + float farDensity = 0.5 * ( + length(vec3(farMatrix[0][0], farMatrix[1][0], farMatrix[2][0])) + + length(vec3(farMatrix[0][1], farMatrix[1][1], farMatrix[2][1]))); + return clamp(farDensity / max(cascadeDensity, 1e-7), 0.0, 1.0); +} + +// One textureGather returns the four depth texels surrounding the continuous +// receiver coordinate. Comparing first and then interpolating is true +// bilinear percentage-closer filtering; interpolating depth before comparing +// would create false blockers at discontinuities. +float acdreamShadowBilinearCompare( + int cascade, + vec2 uv, + float receiverDepth) +{ + float resolution = max(float(uShadowTextureAndFlags.z), 1.0); + vec2 texelPosition = uv * resolution - vec2(0.5); + vec2 blend = fract(texelPosition); + vec4 gatheredDepth = textureGather( + ACDREAM_TEXTURE(uShadowTextureAndFlags.x), + vec3(uv, float(cascade)), + 0); + vec4 compared = step(vec4(receiverDepth), gatheredDepth); + float lower = mix(compared.w, compared.z, blend.x); + float upper = mix(compared.x, compared.y, blend.x); + return mix(lower, upper, blend.y); +} + +float acdreamShadowCascadePcf( + int cascade, + vec3 receiverWorldPosition, + vec3 worldNormal, + vec3 surfaceToLight) +{ + float ndl = clamp(dot(worldNormal, surfaceToLight), 0.0, 1.0); + vec3 cascadeBiasMeters = max( + uShadowBiasMeters.xyz * acdreamShadowBiasScale(cascade), + vec3(0.001)); + float depthBiasMeters = cascadeBiasMeters.x + + cascadeBiasMeters.y * (1.0 - ndl); + vec3 biasedWorldPosition = receiverWorldPosition + + worldNormal * cascadeBiasMeters.z + + surfaceToLight * depthBiasMeters; + + vec4 shadowClip = uShadowWorldToClip[cascade] + * vec4(biasedWorldPosition, 1.0); + vec3 shadowNdc = shadowClip.xyz / max(abs(shadowClip.w), 1e-7); + // Vulkan records these maps through a negative-height viewport so the + // conventional GL-up clip coordinate samples the top-left-origin image. + vec2 uv = vec2( + shadowNdc.x * 0.5 + 0.5, + 0.5 - shadowNdc.y * 0.5); + float receiverDepth = shadowNdc.z; + if (uv.x <= 0.0 || uv.x >= 1.0 + || uv.y <= 0.0 || uv.y >= 1.0 + || receiverDepth <= 0.0 || receiverDepth >= 1.0) + return 1.0; + + int radius = int((uShadowTextureAndFlags.w >> 8u) & 0xFu); + radius = clamp(radius, 0, 2); + // Weather changes the physical softness envelope without changing the + // preset's bounded kernel. Bilinear PCF removes sub-texel stair stepping; + // the higher presets place four/nine bilinear lobes in separable tent + // patterns instead of issuing nine/twenty-five blocky nearest comparisons. + float texel = 1.0 / max(float(uShadowTextureAndFlags.z), 1.0); + float softness = max(uShadowControl.y, 1.0); + if (radius == 0) + return acdreamShadowBilinearCompare(cascade, uv, receiverDepth); + + float lit = 0.0; + float weightSum = 0.0; + if (radius == 1) { + for (int y = 0; y < 2; ++y) { + for (int x = 0; x < 2; ++x) { + vec2 offset = (vec2(x, y) - vec2(0.5)) + * softness * texel; + lit += acdreamShadowBilinearCompare( + cascade, uv + offset, receiverDepth); + weightSum += 1.0; + } + } + } else { + for (int y = -1; y <= 1; ++y) { + for (int x = -1; x <= 1; ++x) { + float weight = float(2 - abs(x)) * float(2 - abs(y)); + vec2 offset = vec2(x, y) * 1.5 * softness * texel; + lit += acdreamShadowBilinearCompare( + cascade, uv + offset, receiverDepth) * weight; + weightSum += weight; + } + } + } + return lit / max(weightSum, 1.0); +} + +float acdreamDirectionalShadowVisibility( + vec3 receiverWorldPosition, + vec3 worldNormal, + vec3 cameraWorldPosition, + vec3 surfaceToLight) +{ + if ((uShadowTextureAndFlags.w & 1u) == 0u) + return 1.0; + + float cameraDistanceMeters = length( + receiverWorldPosition - cameraWorldPosition); + if (cameraDistanceMeters > uShadowControl.z) + return 1.0; + + int cascade = acdreamShadowCascade(cameraDistanceMeters); + if (cascade < 0) + return 1.0; + + float visibility = acdreamShadowCascadePcf( + cascade, + receiverWorldPosition, + worldNormal, + surfaceToLight); + int cascadeCount = int(uShadowTextureAndFlags.y); + if (cascade + 1 < cascadeCount) { + float split = uShadowSplitFarMeters[cascade]; + float widthMeters = max(uShadowControl.w, 1e-4); + float blend = smoothstep( + max(0.0, split - widthMeters), + split, + cameraDistanceMeters); + if (blend > 0.0) { + float nextVisibility = acdreamShadowCascadePcf( + cascade + 1, + receiverWorldPosition, + worldNormal, + surfaceToLight); + visibility = mix(visibility, nextVisibility, blend); + } + } + // Fade the terminal cascade over the same world-metre band used for + // inter-cascade transitions. This removes the moving ring/pop at maximum + // reach while leaving every caster and both/three/four cascades intact. + float reachFade = 1.0 - smoothstep( + max(0.0, uShadowControl.z - max(uShadowControl.w, 1e-4)), + uShadowControl.z, + cameraDistanceMeters); + float shadowWeight = clamp(uShadowControl.x, 0.0, 1.0) * reachFade; + return mix(1.0, visibility, shadowWeight); +} + +#endif diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain.frag b/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain.frag new file mode 100644 index 00000000..cc6c5cfc --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain.frag @@ -0,0 +1,4 @@ +#version 460 core + +void main() { +} diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain.vert b/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain.vert new file mode 100644 index 00000000..cf7b0b46 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain.vert @@ -0,0 +1,16 @@ +#version 460 core + +#include "directional_shadow_common.glsl" + +layout(location = 0) in vec3 aPosition; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in uvec4 aPacked0; +layout(location = 3) in uvec4 aPacked1; +layout(location = 4) in uvec4 aPacked2; +layout(location = 5) in uvec4 aPacked3; + +uniform int uRenderPass; + +void main() { + gl_Position = uShadowWorldToClip[uRenderPass] * vec4(aPosition, 1.0); +} diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain_multiview.frag b/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain_multiview.frag new file mode 100644 index 00000000..cc6c5cfc --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain_multiview.frag @@ -0,0 +1,4 @@ +#version 460 core + +void main() { +} diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain_multiview.vert b/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain_multiview.vert new file mode 100644 index 00000000..463c474b --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain_multiview.vert @@ -0,0 +1,15 @@ +#version 460 core +#extension GL_EXT_multiview : require + +#include "directional_shadow_common.glsl" + +layout(location = 0) in vec3 aPosition; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in uvec4 aPacked0; +layout(location = 3) in uvec4 aPacked1; +layout(location = 4) in uvec4 aPacked2; +layout(location = 5) in uvec4 aPacked3; + +void main() { + gl_Position = uShadowWorldToClip[int(gl_ViewIndex)] * vec4(aPosition, 1.0); +} diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout.frag b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout.frag new file mode 100644 index 00000000..38e3ed06 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout.frag @@ -0,0 +1,14 @@ +#version 460 core +#extension GL_ARB_bindless_texture : require + +in vec2 vShadowTexCoord; +flat in uint vShadowTextureIndex; +flat in uint vShadowTextureLayer; + +void main() { + vec4 texel = ACDREAM_SAMPLE_ARRAY( + vShadowTextureIndex, + vec3(vShadowTexCoord, float(vShadowTextureLayer))); + if (texel.a < 0.05) + discard; +} diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout.vert b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout.vert new file mode 100644 index 00000000..aa6a9ff7 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout.vert @@ -0,0 +1,41 @@ +#version 460 core +#extension GL_ARB_shader_draw_parameters : require + +#include "directional_shadow_common.glsl" + +layout(location = 0) in vec3 aPosition; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in vec2 aTexCoord; + +struct InstanceData { mat4 transform; }; +struct BatchData { + uint textureIndex; + uint _pad; + uint textureLayer; + uint flags; +}; + +layout(std430, binding = 0) readonly buffer InstanceBuffer { + InstanceData Instances[]; +}; +layout(std430, binding = 1) readonly buffer BatchBuffer { + BatchData Batches[]; +}; + +uniform int uDrawIDOffset; +uniform int uRenderPass; + +out vec2 vShadowTexCoord; +flat out uint vShadowTextureIndex; +flat out uint vShadowTextureLayer; + +void main() { + int instanceIndex = gl_BaseInstanceARB + gl_InstanceID; + vec4 worldPosition = Instances[instanceIndex].transform * vec4(aPosition, 1.0); + gl_Position = uShadowWorldToClip[uRenderPass] * worldPosition; + + BatchData batch = Batches[uDrawIDOffset + gl_DrawIDARB]; + vShadowTexCoord = aTexCoord; + vShadowTextureIndex = batch.textureIndex; + vShadowTextureLayer = batch.textureLayer; +} diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout_multiview.frag b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout_multiview.frag new file mode 100644 index 00000000..38e3ed06 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout_multiview.frag @@ -0,0 +1,14 @@ +#version 460 core +#extension GL_ARB_bindless_texture : require + +in vec2 vShadowTexCoord; +flat in uint vShadowTextureIndex; +flat in uint vShadowTextureLayer; + +void main() { + vec4 texel = ACDREAM_SAMPLE_ARRAY( + vShadowTextureIndex, + vec3(vShadowTexCoord, float(vShadowTextureLayer))); + if (texel.a < 0.05) + discard; +} diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout_multiview.vert b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout_multiview.vert new file mode 100644 index 00000000..a90ab9ab --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout_multiview.vert @@ -0,0 +1,38 @@ +#version 460 core +#extension GL_ARB_shader_draw_parameters : require +#extension GL_EXT_multiview : require + +#include "directional_shadow_common.glsl" + +layout(location = 0) in vec3 aPosition; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in vec2 aTexCoord; + +struct InstanceData { mat4 transform; }; +struct BatchData { + uint textureIndex; + uint _pad; + uint textureLayer; + uint flags; +}; +layout(std430, binding = 0) readonly buffer InstanceBuffer { + InstanceData Instances[]; +}; +layout(std430, binding = 1) readonly buffer BatchBuffer { + BatchData Batches[]; +}; + +uniform int uDrawIDOffset; +out vec2 vShadowTexCoord; +flat out uint vShadowTextureIndex; +flat out uint vShadowTextureLayer; + +void main() { + int instanceIndex = gl_BaseInstanceARB + gl_InstanceID; + vec4 worldPosition = Instances[instanceIndex].transform * vec4(aPosition, 1.0); + gl_Position = uShadowWorldToClip[int(gl_ViewIndex)] * worldPosition; + BatchData batch = Batches[uDrawIDOffset + gl_DrawIDARB]; + vShadowTexCoord = aTexCoord; + vShadowTextureIndex = batch.textureIndex; + vShadowTextureLayer = batch.textureLayer; +} diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque.frag b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque.frag new file mode 100644 index 00000000..cc6c5cfc --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque.frag @@ -0,0 +1,4 @@ +#version 460 core + +void main() { +} diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque.vert b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque.vert new file mode 100644 index 00000000..4bb615e9 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque.vert @@ -0,0 +1,21 @@ +#version 460 core +#extension GL_ARB_shader_draw_parameters : require + +#include "directional_shadow_common.glsl" + +layout(location = 0) in vec3 aPosition; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in vec2 aTexCoord; + +struct InstanceData { mat4 transform; }; +layout(std430, binding = 0) readonly buffer InstanceBuffer { + InstanceData Instances[]; +}; + +uniform int uRenderPass; + +void main() { + int instanceIndex = gl_BaseInstanceARB + gl_InstanceID; + vec4 worldPosition = Instances[instanceIndex].transform * vec4(aPosition, 1.0); + gl_Position = uShadowWorldToClip[uRenderPass] * worldPosition; +} diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque_multiview.frag b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque_multiview.frag new file mode 100644 index 00000000..cc6c5cfc --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque_multiview.frag @@ -0,0 +1,4 @@ +#version 460 core + +void main() { +} diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque_multiview.vert b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque_multiview.vert new file mode 100644 index 00000000..17e85a72 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque_multiview.vert @@ -0,0 +1,20 @@ +#version 460 core +#extension GL_ARB_shader_draw_parameters : require +#extension GL_EXT_multiview : require + +#include "directional_shadow_common.glsl" + +layout(location = 0) in vec3 aPosition; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in vec2 aTexCoord; + +struct InstanceData { mat4 transform; }; +layout(std430, binding = 0) readonly buffer InstanceBuffer { + InstanceData Instances[]; +}; + +void main() { + int instanceIndex = gl_BaseInstanceARB + gl_InstanceID; + vec4 worldPosition = Instances[instanceIndex].transform * vec4(aPosition, 1.0); + gl_Position = uShadowWorldToClip[int(gl_ViewIndex)] * worldPosition; +} diff --git a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.frag b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.frag new file mode 100644 index 00000000..32002dbf --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.frag @@ -0,0 +1,130 @@ +#version 430 core +#extension GL_ARB_bindless_texture : require + +in vec3 vNormal; +in vec2 vTexCoord; +in vec3 vWorldPos; +in vec3 vAmbientLocalLit; +in vec3 vDirectionalLit; +// Campaign V slice V6e: the table slot, not the bindless handle — see +// mesh_modern.vert. The lookup moved here because a Vulkan varying cannot +// carry a descriptor. +in flat uint vTextureIndex; +in flat uint vTextureLayer; +in flat float vOpacityMultiplier; // #188 +in flat vec2 vSelectionLighting; // x=luminosity, y=diffuse +in flat uint vReceivesDirectionalShadow; + +#include "directional_shadow_receiver.glsl" + +// uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test): +// 0 = opaque pass — discard fragments with alpha < 0.95 +// (lets the depth write succeed for solid pixels) +// 1 = translucent pass — covers AlphaBlend / Additive / InvAlpha; +// discard alpha >= 0.95 (already drawn opaque) and +// alpha < 0.05 (skip empty fragments — large +// transparent overdraw cost otherwise) +uniform int uRenderPass; +uniform int uLightDebug; // #176 stripe hunt (see mesh_modern.vert) — mode 3 handled here + +// SceneLighting UBO — IDENTICAL layout to mesh_instanced.frag binding=1. +struct Light { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting { + Light uLights[8]; + vec4 uCellAmbient; + vec4 uFogParams; + vec4 uFogColor; + vec4 uCameraAndTime; +}; + +// A7 (2026-06-15): per-vertex lighting moved to mesh_modern.vert (Gouraud) to match +// retail's fixed-function per-vertex T&L — a per-pixel evaluation made a hard "spotlight" +// pool. The SceneLighting UBO above is still declared here for fog (uFogParams/uFogColor/ +// uCameraAndTime) + the lightning-flash bump; its uLights[]/uCellAmbient are now consumed +// in the vertex shader. The std140 layout must stay identical to the vert + the CPU upload. + +vec3 applyFog(vec3 lit, vec3 worldPos) { + int mode = int(uFogParams.w); + if (mode == 0) return lit; + float d = length(worldPos - uCameraAndTime.xyz); + float fogStart = uFogParams.x; + float fogEnd = uFogParams.y; + float span = max(1e-3, fogEnd - fogStart); + float fog = clamp((d - fogStart) / span, 0.0, 1.0); + return mix(lit, uFogColor.xyz, fog); +} + +out vec4 FragColor; + +void main() { + vec4 color = ACDREAM_SAMPLE_ARRAY(vTextureIndex, vec3(vTexCoord, float(vTextureLayer))); + + // Two-pass alpha-test (N.5 Decision 2). + // A.5 T20: opaque pass writes alpha as-sampled so GL_SAMPLE_ALPHA_TO_COVERAGE + // derives the MSAA sample mask from it — ClipMap foliage edges become smooth. + // Discard only fully-transparent (α < 0.05); the GPU handles coverage masking. + if (uRenderPass == 0) { + if (color.a < 0.05) discard; // opaque pass — kill truly empty only (A2C) + } else { + // Transparent pass. + // + // Phase Post-A.5 (ISSUE #52, 2026-05-10): do NOT discard α≥0.95 here. + // Native AC transparent-flagged surfaces routinely include + // effectively-opaque pixels — e.g. the Holtburg lifestone crystal core + // (surface 0x080011DE) which the spawn manifest classifies as + // transparent (batch.IsTransparent=True) but whose decoded texture + // alpha lands ≥0.95 across the visible surface. Those pixels still + // compose correctly under (SrcAlpha, 1-SrcAlpha) alpha-blending, so + // discarding them here threw away the whole crystal. The original + // N.5 §2 rationale (high-α fragments belong in the opaque pass) does + // not apply when the SURFACE is dat-flagged transparent — those + // pixels can't reach the opaque pass at all. + // + // Keep the α<0.05 short-circuit as a fragment-cost optimization + // (skip fully-empty pixels — saves blend bandwidth on alpha-keyed + // sprites with large transparent margins). + if (color.a < 0.05) discard; + } + + // Only the authored outdoor directional term is shadowed. Ambient, local + // lights and material selection pulses remain exactly on the retail path. + vec3 surfaceToLight = normalize(uShadowLightDirectionAndSource.xyz); + float directionalVisibility = vReceivesDirectionalShadow != 0u + ? acdreamDirectionalShadowVisibility( + vWorldPos, + normalize(vNormal), + uCameraAndTime.xyz, + surfaceToLight) + : 1.0; + vec3 sceneLit = vAmbientLocalLit + + vDirectionalLit * directionalVisibility; + vec3 lit = vec3(vSelectionLighting.x) + + vSelectionLighting.y * sceneLit; + + // #176 stripe-hunt mode 3: show the raw per-vertex light field (texture + // ignored). Stripes visible HERE = a vertex-lighting artifact; absent = + // the pattern comes from texture/per-pixel machinery. Throwaway diagnostic. + if (uLightDebug == 3) { + FragColor = vec4(min(lit, vec3(1.0)), 1.0); + return; + } + + // Lightning flash — additive scene bump (matches mesh_instanced.frag). + lit += uFogParams.z * vec3(0.6, 0.6, 0.75); + + // Retail clamp per-channel to 1.0 (r13 §13.1). + lit = min(lit, vec3(1.0)); + + vec3 rgb = color.rgb * lit; + rgb = applyFog(rgb, vWorldPos); + // #188: multiply the FINAL alpha only — the discard thresholds above stay + // keyed on the raw sampled color.a, so the last few frames of a fade + // (multiplier crossing under 0.05) still ramp smoothly toward zero rather + // than popping invisible early against the discard cutoff. + FragColor = vec4(rgb, color.a * vOpacityMultiplier); +} diff --git a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert new file mode 100644 index 00000000..a9816627 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert @@ -0,0 +1,363 @@ +#version 430 core +#extension GL_ARB_shader_draw_parameters : require + +#include "directional_shadow_common.glsl" + +layout(location = 0) in vec3 aPosition; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in vec2 aTexCoord; + +struct InstanceData { + mat4 transform; +}; + +// Campaign V slice V2 (2026-07-27): textureHandle (uvec2, a 64-bit +// GL_ARB_bindless_texture handle) became textureIndex (uint) plus an explicit +// pad word. textureIndex is a slot into the global texture table (set 2, +// injected by tools/ShaderCompiler/VulkanGlslPreamble.cs — see +// ACDREAM_TEXTURE_HANDLE/ACDREAM_SAMPLE_ARRAY) which main() below forwards to +// the fragment stage. The pad word keeps textureLayer/flags at their original +// std430 offsets (8/12), so the struct is still 16 bytes and every existing +// CPU writer's layout is unchanged (GpuBindingModel.GpuBatchDataStrideBytes). +struct BatchData { + uint textureIndex; // slot into the global texture table + uint _pad; // keeps textureLayer/flags at offsets 8/12 + 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 + // fallback), encode it here as bit 0. +}; + +layout(std430, binding = 0) readonly buffer InstanceBuffer { + InstanceData Instances[]; +}; + +// binding=1 here is the SSBO namespace — distinct from the UBO namespace. +// SceneLighting UBO also uses binding=1 in the fragment shader; GL keeps +// GL_SHADER_STORAGE_BUFFER and GL_UNIFORM_BUFFER binding tables separate. +// Task 10 dispatcher binds: +// glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, instanceSsbo) +// glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, batchSsbo) +// Existing SceneLightingUboBinding handles the UBO side. +layout(std430, binding = 1) readonly buffer BatchBuffer { + BatchData Batches[]; +}; + +// === Phase U.3: per-cell screen-space clip gate (gl_ClipDistance) ============= +// Two SSBOs add the clip mechanism without disturbing binding=0/1 above. +// +// binding=2 — SHARED per-frame clip regions, one CellClip per "slot". Uploaded +// ONCE per frame by ClipFrame.UploadShared (shared across WbDrawDispatcher + +// EnvCellRenderer). Slot 0 is RESERVED = no-clip (count 0 ⇒ every plane passes). +// +// binding=3 — PER-RENDERER per-instance slot index, parallel to the binding=0 +// instance buffer and indexed by the IDENTICAL per-instance index +// (gl_BaseInstanceARB + gl_InstanceID). instanceClipSlot[i] selects which +// CellClip region instance i is clipped against. Default all-zeros in U.3 ⇒ +// every instance maps to slot 0 ⇒ no clipping ⇒ identical render to pre-U.3. +// +// CellClip std430 layout (144 bytes/slot): a uint count + 3 pad uints (16 bytes) +// then vec4 planes[8] (8 × 16 = 128 bytes). vec4 array stride is 16 under std430. +// ClipFrame on the CPU side lays out the bytes to match exactly (verified by +// ClipFrameLayoutTests). A clip-space vertex is INSIDE iff dot(plane, gl_Position) +// >= 0 for every active plane (see ClipPlaneSet for the plane convention). +struct CellClip { + uint count; + uint _p0; + uint _p1; + uint _p2; + vec4 planes[8]; +}; +layout(std430, binding = 2) readonly buffer ClipRegionBuf { + CellClip clipRegions[]; +}; +layout(std430, binding = 3) readonly buffer ClipSlotBuf { + uint instanceClipSlot[]; +}; + +// === Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting ===== +// retail picks up-to-8 point/spot lights PER OBJECT by the object's own position +// (minimize_object_lighting 0x0054d480), so a torch always lights the wall it +// sits on, camera-INDEPENDENTLY. The previous single global nearest-8-to-CAMERA +// UBO set (LightManager.Tick) made a wall brighten as the camera approached +// (its torches swapping into the global top-8). Two SSBOs replace that for +// point/spot lights (the SUN + ambient still come from the SceneLighting UBO): +// +// binding=4 — GLOBAL point/spot light array, uploaded once per frame from +// LightManager.PointSnapshot. The index of a light here is stable for the frame. +// binding=5 — per-instance light SET: MaxLightsPerObject(8) int indices per +// instance INTO gLights[] (-1 = unused slot), parallel to the binding=0 +// instance buffer and indexed by the SAME instanceIndex. WbDrawDispatcher fills +// it once per entity (the set is constant across the entity's parts/tuples). +struct GlobalLight { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std430, binding = 4) readonly buffer GlobalLightBuf { + GlobalLight gLights[]; +}; +layout(std430, binding = 5) readonly buffer InstanceLightSetBuf { + int instanceLightIdx[]; // 8 per instance; -1 = unused +}; + +// #142: per-instance "indoor" flag, 1 per instance, parallel to the binding=0 +// instance buffer (same instanceIndex). 1 = object parented to an EnvCell (skip the +// sun — retail's useSunlight==0 interior stage); 0 = outdoor object (gets the sun). +// Read ONLY inside the uniform `uLightingMode == 0` branch below, so the mode-1 +// (EnvCell shell) path provably never touches it — EnvCellRenderer need not bind it. +layout(std430, binding = 6) readonly buffer InstanceIndoorBuf { + uint instanceIndoor[]; +}; + +// #188: per-instance opacity multiplier, 1 per instance, parallel to the +// binding=0 instance buffer (same instanceIndex). 1.0 = unmodified; <1.0 +// while a TransparentPartHook translucency fade is in flight for the +// entity/part this instance belongs to (e.g. the "fading wall" secret- +// passage doors). Multiplied against the sampled texture alpha in +// mesh_modern.frag. +layout(std430, binding = 7) readonly buffer InstanceAlphaBuf { + float instanceAlpha[]; +}; + +// Retail SmartBox click confirmation. One vec2 per OBJECT instance, parallel +// to binding=0: x = CMaterial luminosity, y = CMaterial diffuse. Normal +// rendering is (0,1); SmartBox alternates LOW=(0,.35) and HIGH=(.99,1). +// EnvCellRenderer uses uLightingMode=1 and deliberately never reads this +// object-only binding. +layout(std430, binding = 8) readonly buffer InstanceSelectionLightingBuf { + vec2 instanceSelectionLighting[]; +}; + +// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal +// alongside gl_Position. The array is sized 8 to match the CellClip plane budget +// and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables +// GL_CLIP_DISTANCE0..7 once at startup; unused planes are set to +1.0 below so +// they pass everything (no clipping) when the slot's count < 8. +out gl_PerVertex { + vec4 gl_Position; + float gl_ClipDistance[8]; +}; + +uniform mat4 uViewProjection; +// Absolute transform prefix in the shared shadow/world pose arena. Every +// parallel per-instance array remains local to this submission, so only the +// transform lookup keeps the absolute index. +uniform uint uTextureIndexB; + +// Phase Post-A.5 (ISSUE #52, 2026-05-10): per-pass offset into Batches[]. +// gl_DrawIDARB resets to 0 at the start of each glMultiDrawElementsIndirect +// call, so the transparent pass — which begins later in the indirect buffer +// — was fetching Batches[0..transparentCount) instead of its actual section +// at Batches[opaqueCount..end). The lifestone crystal (a transparent draw) +// ended up reading the FIRST OPAQUE batch's TextureHandle every frame. As +// the camera moved and the opaque front-to-back sort reordered which group +// landed at BatchData[0], the lifestone's apparent texture flickered to +// whatever was first — frequently the player character's body parts. +// +// WbDrawDispatcher.Draw sets this to 0 before the opaque MDI call and to +// _opaqueDrawCount before the transparent MDI call, matching WorldBuilder's +// uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845. +uniform int uDrawIDOffset; +uniform int uLightingMode; // A7 Fix D: 0 = OBJECT (plain Lambert + sun), 1 = ENVCELL (half-Lambert wrap, no sun) +// #176 stripe-hunt isolation modes (ACDREAM_LIGHT_DEBUG, throwaway diagnostic): +// 0 = off; 1 = ambient-only vLit (all point/sun contributions killed); +// 2 = DYNAMIC point lights killed (purples + viewer fill off, statics stay); +// 3 = handled in the frag (raw vLit visualization, texture ignored). +uniform int uLightDebug; + +// SceneLighting UBO — binding=1 in the UBO namespace (GL keeps the SSBO and UBO +// binding tables separate, so this coexists with the binding=1 BatchBuffer SSBO +// above). IDENTICAL std140 layout to mesh_modern.frag. +// +// A7 (2026-06-15): lighting moved from the FRAGMENT shader to HERE (per-VERTEX) so +// torch/point lights Gouraud-interpolate across each triangle the way retail's +// fixed-function T&L does (D3D DrawEnvCell vertex bake + minimize_object_lighting for +// objects). A per-PIXEL evaluation made a tight bright "spotlight" pool on flat walls; +// per-vertex spreads it into a soft, broad gradient with no hard edge. +struct Light { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting { + Light uLights[8]; + vec4 uCellAmbient; + vec4 uFogParams; + vec4 uFogColor; + vec4 uCameraAndTime; +}; + +// Faithful calc_point_light (0x0059c8b0) contribution from ONE point/spot light — +// the wrap + norm shape, factored out so the per-object SSBO loop shares it. D = +// light − vertex, used UN-normalised (length = dist); N is the unit vertex normal. +// Returns the RGB to ADD, already per-channel capped to the light's own colour. +vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) { + int kind = int(L.posAndKind.w); + vec3 toL = L.posAndKind.xyz - worldPos; // D (un-normalised) + float distsq = dot(toL, toL); + float d = sqrt(distsq); + float range = L.dirAndRange.w; // falloff_eff = Falloff × 1.3 (static) / × 1.5 (dynamic) + if (d >= range || range <= 1e-4) return vec3(0.0); + float intensity = L.colorAndIntensity.w; + vec3 baseCol = L.colorAndIntensity.xyz; + + // #143: DYNAMIC lights (viewer fill, portal, server-object lights — flagged by + // coneAngleEtc.y==1 from GlobalLightPacker) use retail's D3D hardware attenuation + // (config_hardware_light 0x0059ad30): a POINT light is given Attenuation1=1 ⇒ + // att = 1/d (inverse-LINEAR), plain Lambert N·L, hard range cutoff. That spreads + // softly across the room (the portal tint, the viewer fill) instead of the static + // bake's 1/d³ distance-cube, which makes a tight concentrated pool. No per-light + // cap — D3D accumulates then saturates, which accumulateLights does via min(pointAcc,1). + if (L.coneAngleEtc.y > 0.5) { + if (uLightDebug == 2) return vec3(0.0); // #176 stripe hunt: dynamics killed + vec3 Ldir = toL / max(d, 1e-4); + float ndl = max(0.0, dot(N, Ldir)); + if (ndl <= 0.0) return vec3(0.0); + if (kind == 2) { // dynamic spot: hard cos-cone gate + if (dot(-Ldir, L.dirAndRange.xyz) <= cos(L.coneAngleEtc.x * 0.5)) return vec3(0.0); + } + return (intensity * ndl / max(d, 1e-3)) * baseCol; // att = 1/d + } + + // ── STATIC dat-baked lights: retail's per-vertex bake (calc_point_light 0x0059c8b0) ── + // A7 Fix D D-3: angular term by lighting path. ENVCELL bake (mode 1) keeps the + // half-Lambert wrap (lights surfaces angled away, retail calc_point_light); OBJECT + // mode (0) uses plain Lambert max(0,N·L) so a torch BEHIND a character contributes + // nothing (retail's hardware path). toL is un-normalised (length d). + float angular = (uLightingMode == 1) + ? (1.0 / 1.5) * (dot(N, toL) + 0.5 * d) // half-Lambert wrap (EnvCell bake) + : max(0.0, dot(N, toL)); // plain Lambert (object/hardware) + if (angular <= 0.0) return vec3(0.0); + // NORM branch (distance-cube): >1 m → distsq·d ≈ inverse-square soft far halo; + // <1 m → just d (dodge the near singularity). "Punchy near, soft far." + float norm = (distsq > 1.0) ? (distsq * d) : d; + float scale = (1.0 - d / range) * intensity * (angular / norm); + if (kind == 2) { + // Spotlight: hard-edged cos-cone gate layered on the point ramp. + vec3 Ldir = toL / max(d, 1e-4); + float cos_edge = cos(L.coneAngleEtc.x * 0.5); + float cos_l = dot(-Ldir, L.dirAndRange.xyz); + if (cos_l <= cos_edge) scale = 0.0; + } + // Per-channel no-blowout cap to the light's OWN colour (un-intensity-scaled): + // a single light can't push a channel past its colour. Summed lit clamped in frag. + return min(scale * baseCol, baseCol); +} + +vec3 accumulateAmbientLocalLights( + vec3 N, + vec3 worldPos, + int instanceIndex, + out vec3 directionalLit) +{ + vec3 lit = uCellAmbient.xyz; + directionalLit = vec3(0.0); + if (uLightDebug == 1) return lit; // #176 stripe hunt: ambient only + + // SUN / directional — OBJECT path only (mode 0). retail's EnvCell path + // (minimize_envcell_lighting) enables only dynamic lights, NEVER the sun, so + // EnvCell walls (mode 1) get no directional sun wash (A7 Fix D D-4). + // #142: within mode 0, also skip the sun for indoor objects (ParentCellId is an + // EnvCell). This mirrors retail's per-draw-stage useSunlight toggle: the interior + // stage runs useSunlightSet(0) (PView::DrawCells 0x005a49f3), so indoor objects + // get no sun even in windowed buildings where the player's frame is not sun-killed. + if (uLightingMode == 0) { + if (instanceIndoor[instanceIndex] == 0u) { // #142: outdoor objects only get the sun + int activeLights = int(uCellAmbient.w); + for (int i = 0; i < 8; ++i) { + if (i >= activeLights) break; + if (int(uLights[i].posAndKind.w) != 0) continue; // directional only + vec3 Ldir = normalize(uShadowLightDirectionAndSource.xyz); + float ndl = max(0.0, dot(N, Ldir)); + directionalLit += uLights[i].colorAndIntensity.xyz + * uLights[i].colorAndIntensity.w * ndl; + } + } + } + + // POINT / SPOT torches: their OWN accumulator (A7 Fix D, D-1). Retail's + // SetStaticLightingVertexColors sums the static point lights from BLACK and + // clamps the SUM to [0,1] before anything else (a baked emissive term), so a + // few warm intensity-100 torches can't push the whole pixel to white the way + // folding them into ambient+sun did. Mirrors LightBake.ComputeVertexColor + // (LightBakeConformanceTests). Per-light cap inside pointContribution is unchanged. + vec3 pointAcc = vec3(0.0); + int base = instanceIndex * 8; + for (int k = 0; k < 8; ++k) { + int gi = instanceLightIdx[base + k]; + if (gi < 0) continue; + pointAcc += pointContribution(N, worldPos, gLights[gi]); + } + lit += min(pointAcc, vec3(1.0)); // clamp the torch sum on its own (retail baked emissive) + + return lit; // frag still does the final min(lit, 1.0) +} + +out vec3 vNormal; +out vec2 vTexCoord; +out vec3 vWorldPos; +out vec3 vAmbientLocalLit; // authored ambient + capped local/point lights +out vec3 vDirectionalLit; // authored outdoor directional sun, shadowable +// Campaign V slice V6e: was `flat uvec2 vTextureHandle` — a raw 64-bit +// GL_ARB_bindless_texture handle handed across the stage boundary. A varying +// cannot carry a Vulkan descriptor, so what travels is the table SLOT and the +// fragment stage does the lookup (see mesh_modern.frag). Under GL the value +// sampled is bit-for-bit the one the vertex stage used to forward; the SSBO +// read simply happens one stage later, and `flat` keeps it one scalar load per +// primitive rather than per fragment. +out flat uint vTextureIndex; +out flat uint vTextureLayer; +out flat float vOpacityMultiplier; // #188 +out flat vec2 vSelectionLighting; +out flat uint vReceivesDirectionalShadow; + +void main() { + int transformIndex = gl_BaseInstanceARB + gl_InstanceID; + int instanceIndex = transformIndex - int(uTextureIndexB); + mat4 model = Instances[transformIndex].transform; + vOpacityMultiplier = instanceAlpha[instanceIndex]; // #188 + vSelectionLighting = (uLightingMode == 0) + ? instanceSelectionLighting[instanceIndex] + : vec2(0.0, 1.0); + + vec4 worldPos = model * vec4(aPosition, 1.0); + gl_Position = uViewProjection * worldPos; + + // Phase U.3: per-instance clip gate. instanceClipSlot is indexed by the + // SAME instanceIndex used for the binding=0 transform above, so the slot + // travels with the instance through the MDI BaseInstance offsets. Slot 0 + // (the U.3 default) has count 0 ⇒ the second loop sets all 8 distances to + // +1.0 ⇒ nothing is clipped. + uint _slot = instanceClipSlot[instanceIndex]; + CellClip _c = clipRegions[_slot]; + for (uint i = 0u; i < _c.count; ++i) + gl_ClipDistance[i] = dot(_c.planes[i], gl_Position); + for (uint i = _c.count; i < 8u; ++i) + gl_ClipDistance[i] = 1.0; + + vWorldPos = worldPos.xyz; + vNormal = normalize(mat3(model) * aNormal); + vAmbientLocalLit = accumulateAmbientLocalLights( + vNormal, + vWorldPos, + instanceIndex, + vDirectionalLit); + // EnvCell-parented objects keep the authored indoor result. The separate + // EnvCell shell renderer never selects this receiver variant at all. + vReceivesDirectionalShadow = (uLightingMode == 0 + && instanceIndoor[instanceIndex] == 0u) + ? 1u + : 0u; + vTexCoord = aTexCoord; + + BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB]; + // Campaign V slice V6e: forward the table SLOT untouched. V2 looked the + // handle up here and passed the handle; the lookup now lives at the sample + // site in mesh_modern.frag, which is the only form Vulkan can express. + vTextureIndex = b.textureIndex; + vTextureLayer = b.textureLayer; +} diff --git a/src/AcDream.App/Rendering/Shaders/mesh_detail.frag b/src/AcDream.App/Rendering/Shaders/mesh_detail.frag new file mode 100644 index 00000000..0ce6f64d --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/mesh_detail.frag @@ -0,0 +1,42 @@ +#version 430 core +#extension GL_ARB_bindless_texture : require + +in vec2 vBaseUv; +in vec2 vDetailUv; +in float vDetailFade; +in flat uint vBaseTextureIndex; +in flat uint vBaseTextureLayer; +in flat uint vBatchFlags; +in flat uint vDetailCategory; + +uniform uint uTextureIndexA; // category detail texture, layer 0 + +out vec4 FragColor; + +void main() { + // Object command replays may contain ordinary instances; only building + // shells survive. Bit 0 means this command came through retail's built-mesh + // DrawMesh path. Unlike the land-polygon path, RenderMeshSubset receives + // curr_detail_surface for every built-mesh material subset. + if (vDetailCategory == 0u || (vBatchFlags & 1u) == 0u) + discard; + if (vDetailFade <= 0.0) + discard; + + vec4 base = ACDREAM_SAMPLE_ARRAY( + vBaseTextureIndex, + vec3(vBaseUv, float(vBaseTextureLayer))); + if (base.a < 0.05) + discard; + + vec4 detail = ACDREAM_SAMPLE_ARRAY( + uTextureIndexA, + vec3(vDetailUv, 0.0)); + + // Pipeline blend is retail's DstColor + OneMinusSrcAlpha. Scaling both + // source colour and alpha makes fade=0 exactly neutral while fade=1 keeps + // retail's measured factor: dest * (detail.rgb + 1 - detail.a). + FragColor = vec4( + detail.rgb * vDetailFade, + detail.a * vDetailFade); +} diff --git a/src/AcDream.App/Rendering/Shaders/mesh_detail.vert b/src/AcDream.App/Rendering/Shaders/mesh_detail.vert new file mode 100644 index 00000000..c71e2e54 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/mesh_detail.vert @@ -0,0 +1,94 @@ +#version 430 core +#extension GL_ARB_shader_draw_parameters : require + +layout(location = 0) in vec3 aPosition; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in vec2 aTexCoord; + +struct InstanceData { + mat4 transform; +}; + +struct BatchData { + uint textureIndex; + uint _pad; + uint textureLayer; + uint flags; +}; + +layout(std430, binding = 0) readonly buffer InstanceBuffer { + InstanceData Instances[]; +}; +layout(std430, binding = 1) readonly buffer BatchBuffer { + BatchData Batches[]; +}; + +struct CellClip { + uint count; + uint _p0; + uint _p1; + uint _p2; + vec4 planes[8]; +}; +layout(std430, binding = 2) readonly buffer ClipRegionBuf { + CellClip clipRegions[]; +}; +layout(std430, binding = 3) readonly buffer ClipSlotBuf { + uint instanceClipSlot[]; +}; + +// Object renderer only: 1 for a retail building shell, 0 for ordinary +// scenery/creatures/players. EnvCellRenderer sets uParamB=0 and ignores the +// value, but still binds one valid word because Vulkan sees this static use. +layout(std430, binding = 9) readonly buffer InstanceDetailCategoryBuf { + uint instanceDetailCategory[]; +}; + +out gl_PerVertex { + vec4 gl_Position; + float gl_ClipDistance[8]; +}; + +uniform mat4 uViewProjection; +uniform int uDrawIDOffset; +uniform uint uTextureIndexB; // absolute transform prefix in the shared pose arena +uniform float uParamA; // detail UV tiling +uniform float uParamB; // 1 = require building instance, 0 = EnvCell category + +out vec2 vBaseUv; +out vec2 vDetailUv; +out float vDetailFade; +out flat uint vBaseTextureIndex; +out flat uint vBaseTextureLayer; +out flat uint vBatchFlags; +out flat uint vDetailCategory; + +void main() { + int transformIndex = gl_BaseInstanceARB + gl_InstanceID; + int instanceIndex = transformIndex - int(uTextureIndexB); + vec4 worldPos = Instances[transformIndex].transform * vec4(aPosition, 1.0); + gl_Position = uViewProjection * worldPos; + + uint slot = instanceClipSlot[instanceIndex]; + CellClip clip = clipRegions[slot]; + for (uint i = 0u; i < clip.count; ++i) + gl_ClipDistance[i] = dot(clip.planes[i], gl_Position); + for (uint i = clip.count; i < 8u; ++i) + gl_ClipDistance[i] = 1.0; + + // System.Numerics' perspective projection used by every gameplay camera + // makes clip.w the positive view-space depth. Retail get_alpha_for_z uses + // that same metric in metres: 255 through 10 m, linearly to 0 at 50 m. + float positiveViewDepthMetres = gl_Position.w; + vDetailFade = clamp((50.0 - positiveViewDepthMetres) / 40.0, 0.0, 1.0); + vBaseUv = aTexCoord; + vDetailUv = aTexCoord * uParamA; + + BatchData batch = Batches[uDrawIDOffset + gl_DrawIDARB]; + vBaseTextureIndex = batch.textureIndex; + vBaseTextureLayer = batch.textureLayer; + vBatchFlags = batch.flags; + vDetailCategory = uParamB > 0.5 + ? instanceDetailCategory[instanceIndex] + : 1u; +} diff --git a/src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_blur.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_blur.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..081ba8ddf06c52df3aec31d105bd1ba3712cadc0 GIT binary patch literal 2496 zcmZ9M*=m$Q5QRI*7?-$2z}l>_Rdc2EvX|dNbgeJ7N^$Kg6sNaX?^{g*YB(y zslApT9iJNSd-&*j-+_aB+_^Bd)edm)qBJj6SjD|7ckY{50M>vi*bb&aCwr`!4My=h zSX;qZnpN|0Q?Gxp;5=Whv*6q#S1mZz$SH1=TfDyq)bFat8;GqluQu~q#O6I)ZQa=) zqgG#=1=JO>XNRuf)SyLO=pQM#(0{z(LjPF7`Bt7cUT|uVn=H8Y%$+SbwOMze;O1oR zBC9=aV*SjF^|uuM)`nlH%`SSguD_9MOD7B8GvllW4Zl*Gd+hJxb?v=}bL>}tIiC8f zUWW5z!}Hu9HJ-+7_;JoGCL{Hc8tuKn~$Du;>!!obIO_ACbs4l zh?(5Qw=Z+i(*yi+CJ%|5^*@4>*Fyhee7REp6Jl%4mHM9&dyo0h{|sMVXwCYc!&y`6 ze?e>y=0e8|zPuLPYkWD+4DLOwz2aPLOAYfS$?9*n^AL~ zUr}pC-I+df&UcIh^;9!SZm+*JQ*iZb`gpfC@SOTb)Rv;25#OV>O0M1qH)#5;19ex@ zHptZq;2H<#v#d>Y>$mHOSpMMq&OERCpSMF#A6a0qlUW;aFeb|ohGe@K8HkRZD(Na5W#^ck(@ZK)j=NFVN5YYRr+JwG4jX z3$GOY5O-Qarsmp1tb52iVb-(?x;WFbv|V4G=FxF=+dDXB&nxk4Mb~c?yp37A%3ceY z9csI>Z7(`JtDV`ieEVhmS|2s-4t^IiSAl10f0b>f)XgdOrZM`Jp`UQ?`rRstr{^}Q zh%py!zZ`*_|3 z(DmP&q1g8_He)R8;*T%gD$X_jZn0d?`vKg$TSx1qJ(IQVysqciUk?4=qxk0I{QBn0 zKnLgq!#UsOSwE6(|4Y=2u`m2Tv9HyQu}A!0QP;L7dO#H%CdNDV0X0WxtKn)5U@F&e z4P0N>dcg_ez1z3IGh9EE<`(r{n|0S5?&E#qZtLOR&AyAfZGfxC-TL8baX0_>yqkL5 z&Hq33$YnEJt(?miY<1y?TyB%Uy|ERTuYTj))jr(?>}~tm9`-zI^*wOyC9(GFXx8lG z|9Qo6X2(bTT^j>xzHOa17yY~!&HL{LvG+c>`tQWrOZ$OZ)T5TQsK*4h^-zy`9Du7w zJr2Uv%Jn#etu7q1>v0&(n&|g|yd!_-cT~)pJpuYO1O3OqKUmgi8o2H^7xg?1k9zve z#huTmwq+U*M6S=Nc3Kzrv02+{o`YxG}E99)G}N z5AWk0BhQ;~WAu4M?!L(#@C3LP^}GjOwz2aPLOAYfS$?9*n^AL~ zUr}pC-I+df&UcIh^;9!SZm+*JQ*iZb`gpfC@SOTb)Rv;25#OV>O0M1qH)#5;19ex@ zHptZq;2H<#vy)JlQT?{l7W?!D)} zo%`Kw95QowZCFEXRPDUlV?%27GQKvX)&R18>imKF>8k!Q^{Cpgx_$Y|H7i?g*?fJ= zWtY#j;h0)uy}fP6)<)KbQY-5}d&33|ZAcfQ(vS4vIF2^@3`drO^qG!WqR#*u+H!n5 zE4%_|O1;0C0DE2iDDY=;%DB+AzuCw5VRNS(Hb4L2DC^+MlTUl_%AlFlH!z%8o zf-^S#t}VE8D{dXNbG(V#Sd3L{v5h5M;?F$OM!%-QuMsTg2DzTDz3ZFxR-b|Br@fzg z8e%b)W(InaJr{qVz%2z{RN(dkcNDn0z&!Fz|R!; z*#iHfz`rc;uL}GJ@CDVJ|5)IY1^!ckFJgnTE|(N|L4g+*xUIlj3w&#Vw^euoyANca z8teNao&@qdH6fGPC-+a?eN#WL()FE#u5Bvv`$J8g(VNhFUp&1i`YiN|c1{_zUyR;9 za`NKXo6%>l8`}}xGh~TAudZ!h{NCD#+I+BMvPAWAc0GkiTe0Xj6}*62y@^;CQd{KO zD&0I_ac+yK?b~~I0l(X+<aw|F+*$*^Dduvl&cTd_KKw0i>VXz@8u5eVN+$GoRg+t?Z{4?zc~@9Xtp3p|71h zfAE~#kG}up-u7mCvEK&}+vumwagDbRX(;pu!I_`q9Y7{O^HOW#Ka5W4O#9~s^jgL_ zhVGe+WPT?qKj-%{*fG5yuB&=~#iid@(A}R4vA;@fEOLqCHSk}KuX-W+>*(X!jy@m# z&jb44dYl^Y?`*D}Tkr1;bo;QKvFfjn@x4jynmMlhdA?2~+1E2**U0md@rE#=XHWIE zM{h(Q_xi!X{TYgW{I)N4Wc+i`ZKIzyYxRsb0nE>!ekF=;Qu@DJW9?gm^V?O|w|5x- zKCrPGpX;mcyDpb~I>ZFb6_N0V;hW$i;U9rF7e&H93f~O>e;GJ$N8ml{k!E(&{r)z5 z#=HU!l?Vrl@x2A(zPIt)eeex%FWj$iz6EDsfDS zbkEilbZtLkT<_(DU^(OeCo4A%YuUM?ypUmY+2b#gXFdGpk)>pA$Gl%+?^f_hu=h#cyCk;nm#JMF>-616 z-^b{~@eYC~All5G`@knE+r-Ltlr{C9y@J1X@5dR|a8hMQt?!5SR}p!~6dS{9)aurW z!Eud4pE1;9k;JecYz*4QSGL4(3Ql`scu!?d4BFp75SAe^y%RDk4PCj|06)cxL;@>cG_Td}h z-!bM9`OL|`XXKMdI`u)yIW0k#S1f1GX(_z(($6!P_^or#9;$pim-;)m^1L<_z7K=V zJDzFV+0Q=aU|wDY*6z1ya(Fj-a(ExSbJMp6UR(TD7k=eC{t0+(quC+btpPhnzgx5& zpx#l9e>J)>*tdJC|FvLw+j*|#J_$ZdEw`)UJ_XiS+i2cT{nmjUYYw8_GwQca`ne8H zA0_d94xIQL*EaG0Ji2WhH}QP|Y#VLq;|6g0a6Zm4ao&h-8-4Z=yXSTT*mc*I^}HFJ zb$(nvV@%y$&ncJdi-G}tk< zxdz5R27D0F=D8b7?H#k;j*oB7c(C#Kou^OU3v;;o1Zvw@r_DsL@uf}Pxk+#t`(par zi{6LW&op%VnN96n{pPy_+(lh}^UZ;i&wXwN%h|^J?Ei7R&+@s?Ens@3YIJ5U4|~NSgZ^^@qQS>nDxuO`v};3=b0<>-Ewqo`DR@KmdiKm z$G{ff8f_m%v1Kub1_eS94w#pSOu2LdUS*3vK~EP*F!$*u^KF&^|%@= zSFXo3=<gfZu{T(=wHwdL)*3CvHuA8mR2l7rU6=@ZHL8{qh>_YTQ?0dk8eItI!554f(($9Theg@76AbT78{gvH4^n1r~ZIik01v^*$veplR z`KkA*J?Ad(*h6soSi(JAanj)qR2)me{tmDI&GJn++v~60nEV^I53KE7Ms7v^2lRh} AF#rGn literal 0 HcmV?d00001 diff --git a/src/AcDream.App/Rendering/Shaders/spv/atmospheric_filmic.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_filmic.vert.spv new file mode 100644 index 0000000000000000000000000000000000000000..c493caf5fdc55435e07a6281e1c59996895e1d82 GIT binary patch literal 956 zcmZ9J$x1^(5JfwqVoYKj;uPgMYa${zAcz`Qx^SWR1%ewF>Owz2aPLOAYfS$?9*n^AL~ zUr}pC-I+df&UcIh^;9!SZm+*JQ*iZb`gpfC@SOTb)Rv;25#OV>O0M1qH)#5;19ex@ zHptZq;2H<#vYclQtX zS3Z1vv$D2++moZIQD5NMu{4~TNb&CV+xKpa0FI|iU=?hGan9&72)bzcw1F$^MfCpK zZltqW-@{kkbqeh98@6|ipWv3TUEybra-D)RhTM~avm&{@f-_&a{em+Gxr2fm$lUXS zGdKNS7Ti$gj*wmYF^Bv@qtxdM{lZ_pS@-ZFwoAWTNb|Qob(e8m!9~r{n>eFiRroc~ z;#y$Tor+TJ;-^Iq%oE4P2ggCUW*Y_HUqHKRVtCdl~!tuj8GIWa+H!&HrEz z%)vKiKKh!2_G;#$#yi+9ZEL#=qK37sn^*YXLysKw-^{l09%kK~jHm7U3V-i;3{v{E zvq5K#<7{2}{DiUJyZqYk8Q@Hu!#1AAn+5WR+4g&u^Ncg`oAyn~d&c?rZOdyrj|w=8 zXPhyoB~SrZaF@|5&<76iT0~v}`nuN!FF0?lSAj9ykMq|ukKo-8)p2g3d!I9Kw$4+( zU3TsDI&jaKI0y4JzYXAC_}xd>&so_2@Oyyn-sfD`7P@EUgX^H1hkFP7_b~sbpi{sZ PJz^rx&b{Acy$SvR(~U)D literal 0 HcmV?d00001 diff --git a/src/AcDream.App/Rendering/Shaders/spv/atmospheric_sun_occlusion.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_sun_occlusion.vert.spv new file mode 100644 index 0000000000000000000000000000000000000000..c493caf5fdc55435e07a6281e1c59996895e1d82 GIT binary patch literal 956 zcmZ9J$x1^(5JfwqVoYKj;uPgMYa${zAcz`Qx^SWR1%ewF>Owz2aPLOAYfS$?9*n^AL~ zUr}pC-I+df&UcIh^;9!SZm+*JQ*iZb`gpfC@SOTb)Rv;25#OV>O0M1qH)#5;19ex@ zHptZq;2H<#v)lJt7cK^ddaoI%oPn+vRpAU!b;7&L~4 zed#7yR-~q-6{Qvyl$VOGkflL+NwW{;C8K`7XXmWbOT%;CIp=@QoH_HKd7pVI+x4$0 zRacZcmAaI=w<{H`d#PQi0?mFykJkRJ)W1vLsZ?F~Crp|;ss64dbLvMl40YfcrOINw zW4jVnMXxNJwP2BsAxHx<8JUNyL23!^#yI0@kO^qU^+jx9_d~b53B+EU^d@4}ZS9dJ ze#16bw=%3hwk_hUQ>-z=oI`AUhPf)SrVO)Qu}K+b4Pwn1R-LeGGtAnIyFSBe6E=h1 zHC|0`E!L`TbBryps6W;jV~nfI;wsU_hGtmQ-DP0n)emI)!8X0BIHUQP*%TLJZprjHnf_>|znAIzl71!($U$G`bq(rgQNR4wA%vCA z!FC;D`p+h^1AQH$uOq#-zJY0MPjtr)f=9>pLd2Aa>5XoS7}qAI7-P=!5ZV}HoM!+c zrt~6WAiZ(UWh?K`rPe{<#`Q)YOs`!>{(5?w*r;UtpSQU#_tM;+(VqMcqZiYbqv7;* zh^-Ir8UORf$Wt!HDdtj~dqmP5Ho>K{-3x;Yr2NsJvY`hX#1awbKsoK z$u@pU((^U?KFVvHmf6Pn9x9bu+G@P1u$PVN1H=9o%jFTboa622?w8_z?V#6h zjS=$|y6ab*uZD@`%6!hJ?Y$NJ$yZkryt;bz<*}#Tu$Q;Aj2g*FGPh^f=9uFkp0BZR z>E67A$dep+Rz?$T&Q*xMh)&pD=M7G?eaVy!kdD<}~I3FxRjVabEd(B*ksQHdfyWxY$fDC-z5q zYdNk1@$Qj49Ya1u!dH#Yz0r3v^I1a$K5NkTY38d&cOzVzzEg-f*J8QNf!TAlYtM9e$G`S#M=oKN2#J=cb$2?YmHmXT-GoH-I(R%(7q8}%sN*j>}G6n=aM7uq?zaq z^xjo+G@O1GnE2FW&qfz>jQ5t`DnsbS9pfG5H;ecbV*KCDMRz`1VDl0t+zfY(^z$){ z)#v|oI^%s`EWkFW{uqB7y0}eVoac6IpfB+`QAQ>ZBG3${wZ{ETX6U^wotye#sx3Wp&OI){ya8M!Mpx)-d_MS zCg=S{Y@R~&NB))Q`f}bc!v4F3tm1$HzwzOJ2p?jyZ&Vma?WV~gj!@59b{ zKY(qVEqFVaFyY|+Hw7U*yhw9_`UGXhi3FmUK2h4ca8vAk-U4J#= zS@y0thWKr(FXH@$HD(pP^Z4By$9zz&a<)d7HTv at+NZ5Owz2aPLOAYfS$?9*n^AL~ zUr}pC-I+df&UcIh^;9!SZm+*JQ*iZb`gpfC@SOTb)Rv;25#OV>O0M1qH)#5;19ex@ zHptZq;2H<#v>R#H*< z2F9pOjF*-#Bqk)@K`52tR4hojwN((1p#FZ(d;Xgv@#LM(%zx&b znK`%TZ2M=84m5N6nxSTHGd8Pf+bPYgrVmMe`+^?7qwqW7L(QDlKDA+b!`LUU-ZJ*~ zl`HHxujz05+xHF4aMOWHTQ^_1O~W#19kdrZ06h#n1N{S$x3Y%T9jV;z` zZRM9b*H>8T+)!buv$yWO7+KTHDnD~Gmk(ChV8K2Fx5h*8eVh^H2P^rZ8M)K$`w?`T zen;WvZ+*(Px@NJ|oPC!*`gNi7P0cI7TqDnyYi91zT@(G(J$rR7#|V`Ck5}@EBEOML z&222q`JKo=x!?;cV;@I1$J6zE}Qm!J+bvqP4|J-bGskim?L~0y^mZ=_ef3VESaa|*!ROv zg6v~#^#cXdez3^)Q~xvEIL@2?>V8MX%*}C+K*#=b?dlPeE^fBK@3kI9P9xIq>Zp6!b@GzvWHs|33^mi`*0vY2l zlrbMi7PsAvpY!zuBz^?CiCn~&TIN!2_|0m{TRBqeK(T7bN_L4$JAz>+T080!?%Jh zgO5SZk>`3FnD|b7?0*%qnB%;!8En^iJF>WBlew3(wpSOMdwQ6eckVw0re6orK1_b% zpDy@0#mDbR_S0vuX%FonC->PkVCJAbb$$^!b-IVFGc|k(n>FZ@8orDyF4<&t4PPy` zx`wZT>1PevQ^U@JTZ2Az4ZF~_hf>4!VCJAb`?eoB`}Ri0%D(M@Yx`{3BeRjkcHpy( zY`lm0Gp7+BW{uPbk@?No_9G7fxEF`u?nSx#+;vz^@$L`z=h@)%D%)GId1q-Gg~w)` zMd+S0ZA(kPACRYK=M;49#{DOG&MkIK?YYrD42heM@}AOn0kVCq!}E43JpHsSgv9Kp zY+V^g{1e2puG5emCv&$LUCgkDEqC}gz|B{lHUB2^I(#Of`mXvGxVD_1ZzJ<-N!wQH^S=KMvbI~wynPp0tc%SY zZbG(sPigxeBxWAU?S3Fnb#e2$4F8*v&1)L!LheQ5eIHz#^{d~4%&)yiv~`ikA@3O9 zQ@4VpKF9D**$pP1vv?b_m}HYVb=K}2?*R~V9DOr)KSUPW4Ec@BdHE5zww%v>$ov}5 zr#AQbSIEz?Z%220ecU5rcObj2+MJ&s!<`?w=f%2DBSB+2X9LKyp}l1d?gZ2B+_>)t ziyilN9kkyCi94>cHF+oBjciSxDaWuT@8zF@Ys(#b53+gMg56s%;b1>27(>n6l|Kiw zzy8|myYd(4#?mM2^GjrL$tH8W#Q0UQ)pzBu!SwU4)Sk8YO~G9Yed@dNx9HkKxhwaB znS=Jk{2j8mE#Ji7qlhKJ^?4Q&Gp=&(wdasM zU*2oEGd$1FgKNvX`53a8_n7bI;{{~TY`z&Up=YmX%X|1` zWczB*e7u4zZVUD*`s?TDH864gZzig7|BWo}yt!t%A1S@9^**rFs(+tz1$l?7&qAg< ze`ED*j^S9b_ZPeGL~-NU$2H0w=3J|L4?5@a>zF&|*89Cht8)swl zO{A~m%tLn^xpn#WS&Ho2B6STFpSke7EtVq>LU~(^Av>;k)@cQD-hK5ty%k*BwPk<3 z4Oz_l$?w!kGBb(sv5&3Im HaUt|yqVPlJ literal 0 HcmV?d00001 diff --git a/src/AcDream.App/Rendering/Shaders/spv/atmospheric_volumetric.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_volumetric.vert.spv new file mode 100644 index 0000000000000000000000000000000000000000..c493caf5fdc55435e07a6281e1c59996895e1d82 GIT binary patch literal 956 zcmZ9J$x1^(5JfwqVoYKj;uPgMYa${zAcz`Qx^SWR1%ewF>Owz2aPLOAYfS$?9*n^AL~ zUr}pC-I+df&UcIh^;9!SZm+*JQ*iZb`gpfC@SOTb)Rv;25#OV>O0M1qH)#5;19ex@ zHptZq;2H<#v literal 0 HcmV?d00001 diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain.vert.spv new file mode 100644 index 0000000000000000000000000000000000000000..c79fd88cdaa785195a28609ef3bb1c3a7a88c0b7 GIT binary patch literal 1436 zcmYk5PfwFU5XP6b7PazEEQn%#OF`5s;h=_uppcl9Lk}1|c<^H4#e-hp1NasEwq8j5 z{r24(1IJLThQ%~k_g0rsNYQ?EXuBkY+$sJal8stvl z)a@OOf!ZV9!MC1wwuy~7^j$GW#PuA(*~6t~Ioh>-D=y~vSaJ6C?0Ln-9P^5cIW8*B z89e)?;?yShwc>6U?i<#b-5&whI@79a%aw9z7P1JBtH00gG+SC2x68nzj?6Zt*fOYL-yaHSgiG54fRafX(E3rC!qvpst z$2wN+=8y1vgXUNxV2-}}hFI^zJoOp-e&j}|_II|vx@y=%cn9r0_R!njhi9y1?0?le zirF6FJG;Ffm%a85dinRVul<~5jJ0;;@8Ek+^42$=6wdnQ?tzs4e6ao$&$G_@jyG#< z@5Lqm55_ll##b%dK)q@_AyZvXfczQNeNRt;_v3Hmo_XqRfP2R3+XZU-L~V6FD;(yA y+b^6mMc(tmsr3ToYJUMdZ@&A^_?Nowy#x&~$2pw&74VIz@ja6on|ICa9fAMVRV|7D literal 0 HcmV?d00001 diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain_multiview.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain_multiview.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..86b5c126ec9284c4b19d605e1ef5ad02e0c22779 GIT binary patch literal 152 zcmZQ(Qf6mhV`SiF;ALQAfB-=TCI&_zlN%@kqTPLhee{Y;QuItr4L~aR7??p6SdO28 zm4OAw2I1Vq%sh~|08k#pX9r?opjk{nS`jD)(gk9h0rh}rkQxvGiT?nKumb77KvOM% F7yyFJ2$%o> literal 0 HcmV?d00001 diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain_multiview.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain_multiview.vert.spv new file mode 100644 index 0000000000000000000000000000000000000000..1810e56bf662c433613566aa1a2236725bb81a41 GIT binary patch literal 1136 zcmYk4yH0{Z5QP^|@Q#XtS6swvObCS<6XUJIfp%26AD=IsXC~hoZ95NBb*xKuHn?}9kqbk zbG(gjKV$FDb3XAF#@=NUxN4kGEiJ08*RSK*D}n~~p3zt>%X}xW5x9H%Ya?Y8R_O9j3H?V3`mw99FTF(3$)><_w0LnZ8lvDHI9wYz_? z=Z0#Cl11*A36r4!i<`^qTMJcC2W`*+HTtcWz!1-R4`^B3$ImBml+4;=%{|M&e+}8w zfFIGQkHkNJ{?9KY|8+*QPYu~VeQW-!!B_6ls{yKPn8x7{zx`BTfCX)T$hi305nF)Bnj9O=bI?vPC zH#2uamzsSy&u!ir_4|J2jn%sd)SB&F!dL4Zb#--txifYeuK;JXEEnUO^L(dt`R+Yn zZw%aV!MtzW2WqgM&99|-&-g;0=idb8y_XM8zYS~O=(4s2%y}pJg=Q{%;b%Gd2SrEV E7jF(ItpET3 literal 0 HcmV?d00001 diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout.vert.spv new file mode 100644 index 0000000000000000000000000000000000000000..6e077a144a53d584cf5aacbb465229f7533b31ce GIT binary patch literal 2668 zcmZA2S#wlX5C`y^OcoXe680@l5)maD76YObOA2&i5-}_aRKbH)yzn3>;5%QzZ|jRy z{(pCF!!1u$Pj~pG zM)ZT>)^unizopMcKkz?nIl3jzq41f|tyUj2hr=gBVv?<6Au&t;;6G^VW*18jiy^D@ zrLo%T`a{+zXYxh(IM;M5!}Vmz$L`Evcwu~CPPH&H%(=?_Rd~Oz^U

vQ=kQdjG5n zt6t@SsiVar6ece0Ko3(7Y}mup2D{M1)Bw8`Ox^B&F{Jj=mm-Vj%r1pjA7rcgD81iD zVRBe%h9%uNZx5?J?({Hu`EK;E>SMcyRUbP&Ob>j`Z{<}R>~Rm<)nQLN>~^?ipT!qe zHOpUE)htF~gUEb83OPglsKq;QhTn(R8IrA<@zN`|{iDt<7TNu%rSzr97JVz+9B$^B z)Ajyg=BL_sOqXA5Z+c%VPqi}JIQF1@>t*xq%kN;k`7g#_ujIqYrN?22|4OItj%*F1 z?+ItK?^>s;6?P<%*y4lf{wqRJfdaVm}=|5rXrl*ZJtL>TQ~LH}(Hn?8A`7PCpwO$Czq9kIVrZ=mDmW)o{7TXSJ*SZrCGNkv(Mj{7JaO^C9`@ zz5}p@Ow*-s@!eaW+WAhw)npaS0DefzmBZFEBS8W=D-r; zpCHrW*MrW4^YsSTo7wg~oQUj=xI;GF-udv8A$Gbq@kz*gxR;2|WU<4--eXH0rr+AP z++k|lNnUsRX^6l5?8DICiOY5-B)2{5!OYAzA#-Jxs+X%BzMjs^(RU$ptOvI0<6304 zihn&aOdj#g+|7_T$M;4~j&5J&|1q-sW~cIRMuwNZ9oa0sOH60|Q^+2;8qL6*^G%tHedYy%~W0u{RiYO Ba4i4; literal 0 HcmV?d00001 diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout_multiview.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout_multiview.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..16f7b30768ba1730e0315a4919ee230ea8f1d6a4 GIT binary patch literal 924 zcmZ9JOD_Xa7=}+f)rM-ds;a6?D{+rlAQI`qs+%r|g|)=Sf=Kvjh{Rvu_qZey&oiB| z@FnlO-+kV5PLy9Qg<>w0LnZ8lvDHI9wYz_? z=Z0#Cl11*A36r4!i<`^qTMJcC2W`*+HTtcWz!1-R4`^B3$ImBml+4;=%{|M&e+}8w zfFIGQkHkNJ{?9KY|8+*QPYu~VeQW-!!B_6ls{yKPn8x7{zx`BTfCX)T$hi305nF)Bnj9O=bI?vPC zH#2uamzsSy&u!ir_4|J2jn%sd)SB&F!dL4Zb#--txifYeuK;JXEEnUO^L(dt`R+Yn zZw%aV!MtzW2WqgM&99|-&-g;0=idb8y_XM8zYS~O=(4s2%y}pJg=Q{%;b%Gd2SrEV E7jF(ItpET3 literal 0 HcmV?d00001 diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout_multiview.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout_multiview.vert.spv new file mode 100644 index 0000000000000000000000000000000000000000..028829ea1936549cd25f565c02cd37d9b9f526c9 GIT binary patch literal 2676 zcmZA2*>Y7?5C-6#oI`>J8D$V5oFpo0G(ZFjwG1a=NDR?p6fV56${R1h0Um%?@NK!F z%KzJYx7o$2`cHTNy}En#8ul5@uFN-cGtENtLG!BV#<6C$nTbl?JJ!jkkqgaS%Wv;J z+FN`1>i4y)*Di}V+>F{lF-Mx?tv=50^WXk>!B_}R2aCaKa3xp|wt_psZty605X>&Z}$-@f6<;>6(WY;km)y;b|`$YEcXVpm^u zqxP!o;a(M2y{dz=j+Tp3oV>WB1I~JIlL2RKxXT018gSEa*6sY40&6dOIl6pocSE1g za_7|Fh;GPX6qj@@r)F`r&VvI^Ua^M)`EN$5?anx(X{0#!fo{}&n6}n_ZS%K!zg%>^ zSWDT<(G7ENMa~9t(~UZlvWI6<+)6jK?F(1EPNVA;C$D+82i&OVb_QIX$=-mgGr2$D z?1_Aj2b{IxyvwRc+MlMmGWGSV9O)n%{tT;=^Lw!Ju)U*8@*;PkN; zsdl$l2ixz6IcgQmp{wVQ!x>%*)MNWD;MmS>J$KDEXi@D9WBGsCE2bJHRf?T&F-8nIGvG4R;-O0PLX9DxLd!O9Z z>+Zn+Q*`UFZhQM6QZ4<9e;BEEym+=ZhvWBt)ha*xNzd^=jc%@9#IwCgT-n|pyUzZ3 zbVK}$Nb3_{{=cJJ-}QW(NcX^y<4qXfn;*jJS*$m>+03`^;cRqo#2)gI=Gy;{1AewQ zaUt*?9wehZS?aln|GDL!>-M0Vx6*Ugc0YC9n@%`dCy}%s2HM&PX2U~$U{PH`$KLbAJDxb6bE5O&Bf1;b?Y-`TT=yA@Xj S)7>Ze= literal 0 HcmV?d00001 diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque.vert.spv new file mode 100644 index 0000000000000000000000000000000000000000..8b49cd0fb8348f6af04081fdf9f25b100c8c0bfb GIT binary patch literal 1820 zcmZ9M$!=3Y5JlT|Vg~|AAY=d%#{;v$3Bd_Nl*E>h?1dLWuwcOki46eFv5YwZG8*uBy$Z2J(dDZM{Lmbn%SUEZEt=F?x!uav=g z#(hpNICVMaXYMD+ysv%i=u6!dvkE((RdCU(dE~68;821yuH0J2S&v*N@RdZF283ZmHrvAlK>6I=b{~ZuJf3Vyq6Z?m=Z=LU(nrFC+E2@1(MQ4|1DC zT0wTe7QR)awtZ~#dmSmK-Da=fj@&M~--oum?_b+|o5(9b&V1Uwe>wd(E8RHe+(DYt zp7ie`Ye3%f+I|~y_80Z0^yA$A@4M+P-nQHec)^()iw6 z4s!^562^Ll^v*qFEbURn#hW<7ju{_UKI3}+CDOd+(>_DG*IJMHF7(-#Oa2#(@A?$h z7~ZXKYLZ&T{oX?N&aFwG(!IvN4fJb!$9us0eM3b1=~f)#FW0NMTE_J&&e|@>Ypr*H z=iRTr1AY=$-(6sC_e|iN!E@l8oK5udqT(lX=3HI@@5LVUMIR^V`Xc@*x}158?+nj@ SZ^*M}OitT literal 0 HcmV?d00001 diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque_multiview.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque_multiview.vert.spv new file mode 100644 index 0000000000000000000000000000000000000000..4b3f2f7b78197bf7b8f4850dea95d0da6f6a1314 GIT binary patch literal 1504 zcmZA1OHWim5C!0d83vySB8Up(4ERO?5eQ0DSPcsn8W*}XapOWa`UCt6{#!RDp6}ji zo!H5lsyfxTUR{~a^u}!IO_ht~aycsPSt!$GDk}MYp~Tl~?9th~v#oF6KW**p@3=Wv zI?V^}Udb(|8+`fr`70fBJ?ICU!P8(j808ip;7?;OMmjUWX@09c8+-mgZX>oM&b7#1 zaNO9PvJ^QTh)H)=0x_%o@B2bImk5UQdpE{APnA zE9V=W=~a%7Ge5bXL{9d#9lQ2Lcj~OFJvpn&)n4VnsiWbdR8Cyn`~;^S++c!J8*XQU zQv>ceoVva7Fi?B7*Q1Na_Wt_(kiA+=M{*D2<)<&@j&BD+LS{c&`+Rg~5c@(TpL<7R zyUutyk>;9vcCVYgw5RP{S7Kj{j?+s&ckLN&ExKJ|d zi{z7&{X9~=;vHmP`Q&!+zrxtJmtnoV`Fr=1GTXet&FJ36uJDoWihnEMXM2a+fj4-Y zh-Nt)a}j^J^)aX3dT(RQsqIbjs`XCbd-wSV!Oz6yyBo;uPObY{baOaK7iO>@n3H<> iYRw1H`D(1g=r}P?65qUD1m4uQuQDaJd%rW~!{85Kk2M|u literal 0 HcmV?d00001 diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..783356616b64beb801b3a17258c0c1aabdc5793e GIT binary patch literal 17360 zcmZ|W3HYyLnFsK{ju66#B&Acr$>Cr)meboN~`2`_nqdv>CLa1v-#Xj zI9O?F#>5XFD^IO9wRBqbu>B7?oMNlg(^BU@VQT6vsqaetx76cOKc9L^>Q__GNWZabmzthZDCocy_ATD?cZgKeF|qS1Qvlp4$_rUJRu0sfZC5 z_Y%&gOXL}obF$K)b7s~Kwx&B|#oiYAyE&Ke+ei2vOYqW@Gp5gC|1&1%t^S-ny2X1j zW9t2p^Izt)^aKY^#xh4bWqgLpjn@a)^_h-+`|8f_#UrlkGk?T&eRdjgJwv;UI5G3L z$A}XfZm$vNYUB1vwNCC?=MS6zlP0>QC-;vmR(s(tk`=OhNftNR9VLsIZ}+vnVLIcp zb&vn%YsTbG*Vg@}TYR@?Ox+xZ-Pc=2_|_4AW)`J3Yme}=MtJuT-eZJcKEh{bA?xRy z5k7Z>?;7E|NBEu*UN6Dd&-x=gYrs#*s%Dxw>oX?n2R}6^E~aLtuAVirRA*GoJZuoyuRwpBHR?`aeG{TVvLKI#^jPn)zwV?-<;EI|u7$ z?$z%Z{MQtI4;=L7M$hih)Rb+=#(Vul{9FGwL~h*dPgK8u^=9pV(#W3v=$Osk(cD35K|%31hUcy`YW{&rfj`J9?o!C7bWX^0g1(-*^u zedo6@oO43957!Se`x}DAY9Bkl+k%f+_1JkECQ9e*_EcPJ{qB&!`K$cW$SV)B`JEAY zYU&4*bs?{mfz~+V=Uc5Ezn)3P99QRX`|xypubnL58G{?!!D8PbeEyr*SPQae2#)@! z03q08da~yRj{cnkwny}2?;-TH_go;aoi`o%+&MDmaphk%N0sk?kF8v)4_{>OHaPkZ z47ls3-^apTcIi$p>^^-ua?;b1SmbE2=)YjV_J@D6cP9GEa`sr3v&XWWUD@2_X)^!k z54iKWJhC%n?fECm;;8~(peUUSJg_+8|wsp)On@Ar|}sC4`r-?KnG z8)TIEX&;XKya7Kt(ao6b!!^mPTV$m|rRU-zu0>(2--GG1h%)7oVE6r@$n@G5rkDMD zYq0aN`yhL#z-=2Z?$z6aPfLr}$DQOU zAEn$e1845L#L6BY8;&gnPAT?chA~c^ zHoQKcORIGKjvMT?UB3?mcm1qQ*YAVj>E4y`CigiQXPgt#8drONo|*JlBqvU%<@?jA zC#Rk|%;lrO^v9=tApSlx=wm*a5B9y~PU-L2-;~IZFcYQnetE2Z!upVsrr<3=75!rKu?$NY# z;`?k`MSN`R70&otW8ui!j&)URjc0uCoou_#+XT~3hd0KykriXmO{e1cgpG0ZAmjBp zad@Eh%v><&wV%wIII9bT_LB0TF>R?f}cchAFoffhw|J-;qc7` z7YE~1rMcjkVJ|+P*qkAIvEp#%(#xZbC}9GJeLgE#O!V&`xU+)~jU8R%I5*f@wUj?^ z;Q3a{|6t%ByDmQr&zF8|8sGWBjgL+J{3y8N(wAR2@VGJmlYxKix?B{VFa6kb+>3)d zE}O2)CBb5%@48$XY+dM_P3Prj!TQfiwQhdbePCF}%OmTty! zWjXuq2s>loej2%B(EIJwcbUs0(<|k!7&v`Azr=ZEICs?@?tHEaW=q$3UmeURUHx4X zYz%AKIDZpaexTD=oWBkBjHIjGwZU=-zqR{AFk8BgabqxEDRPVR`nF0Q8NyZ?6%y!~fm|9cnj zicH_?Tv(^O!-;{uW8NE#SGqs9hB*k=Z$~r(_j8_bdk9Gv$dECdG5ee@ms}Jcr=(GM+w52lHc( zJez8x?elb*V17N1*z4ahmkp*j=89<*W8#-f#TgTB>?;JDpV*CA8*RsYaxlN*V6T72 zTq&5|m`_Qo7!$v8D$ba2=ecUIF+0!NXglU=!TgGYz5b2)sloO^%wHMSZjEsI&~=9v$#;-!hIioZn&)?Z=KrrW82TI^vtY$q(#=W5Svy$XbFZ!o zcDKai{^9-@j9(Beui$QoZ0zL+eb?y5@LeOnUBqsUZVFG=?+G^t&PnA*KX&c&XfWSOxxWsae)aQ&e7vb2eb|is zEE9eAq|veM>;Wyk#4t8-3?zsaew&sU*!?C5~qxoUIU}MlvPwW@xfLW2pxdCo|*dRK-BsqD z&cKcH!zSUZpVB+JY2@B(e)MBk-0M$SKb)kIcJ^h9y{~e_Srkw8E>EF5%*Ugxkn%QhP>Y( zYu#h_@we&kI|G}Kq<(17$w7aNP7b2GE_kf_!$^1Sp!?2{XZDXhzv7Z-K05e~IJw94 z2c+V~2aE5(LDn|U7*`x@j!Qjwq%*EK=-xNdnb+^an-|^jgRb!%6q&y9oiOMdAH8w# z;)C5?eh&$DMzWm~AN_Xmy72NAeec|%!T9z)EVzBGPx~GoUSIn5Jt7$2clslP`NP-d zjlt$o`OT5}ZO%I?G8=qz)LVk_{w%HB+alvm~f_IcB&k2v^X z*Ry&|)|ibm+nncqy03KQjth3b*)^xRtG%1!oZv2!1C!^Dsjd4#*D^~6O`IFko`}fdi zt#IBg=cLt7Y@N@JIS|gR{B&wE&HS`Ghr|0m>b>%HX%+94bT3QA84vF7pk0F9FTVJ6 zSH!Vvc)Hr{HrUBS3sY;idw9Cee~;kKpU$7byZ3zV8JS-;wcjha_MRnddXDxUbeo1} zr|;a<{u#->!EE$1=R<-ObEexb6=%*co7V~?rQf$dl{KcE6r_R9yom(`zhh@ zSH^ee_|?ez)3xt6g88$4jp>__edDClS4`gu_KlOScBcjV#>sE(z7x!ru49}Tj91E? zHE?1sclN-ErN6n)38z1MdTaOb#N?TBZe(YLO}}xT7mQaG{`u<|KNxgbmL+H6hvC>| z>!#?t*XIwsy=F81#(6^VR+NNn|{(eJ_rz?|5%7 ziHv90{B!9b)Ab$bvS2>x>hEX4{8^KJbGsrkJ7=@s{(cb|FR%P6ts?i}uS~_sEpWfd z{W6#zd*m5V8?EPyT>k4|e&rFjKiT`6N&X{oLd1J6KJI~r^JLb*7{ECCU{*C$8VEZ8Ee!Kir zWPRv5&TYZ$j8nVYBeSIw^POoGG1J|ViW4(zO*|LK_q<9~ope zeP;h9*f_=>f8%^KJYD_%UvT}>@!RiJ9z^Cd9nM-V6IrpAbW5e;tR39<{_h66TjFv5 zd=vRzIQ)X(zW1LI+1TFu>AOZ}26v6d-#E_-PuKT;e}@*gZN~78)8C`@-jDBlzrRn5 z?|c9EgFV;#96UdAa{yi6=PwA>mwq}vn{R&{*_j(xYboDe7@hgR?D%M2xhR-kUM+XY zz{xe`E(>Q}ls+SW8kt}3^Ze+?u6-^K=36Ou#lY!TKUamTAAQ)2{roy|_oRKU8TAnd zAMD2W#u@0DSuWU_*)iifGY3XioEf?&rEZey%)tCCKgim?XD`b8sQb_6FTop*bPtSl zkBoHg-+hs%hkkR?N54~ZKIGOX2h+O~zCCO_=p#on)$UKh`0>5}b7Z_Rdhc(K>>RrH z^u70Y1V7%rzcV~t@4delkL$hncjMiAeDD1|!T7Gn{lV^i@5cj?#j2EhaNxw&{IDn- zTRJ|PA07%e2L1HJesKbfO{nudnaei1T`-dCnhZ*6lpVB+J zbhzGYe)MBk-^&E^qm)~2;Pk7X6~fhzK5WK*R*u}gYoAp{eZ;{ByS}qI|Lz03a_)=! z<5|<+weEuRD{qYNoU=}LkDYmK`^*V;#@lE4!Di#M`p7-{$T#Fo2U+VLyN|c0zi)wT z_D_A)pi47#Z*+1H-95o$-Q6SIU4yRQ5I2uKzv7Z-4v4I;A`jBfO~s237T*?wtZkk# zt~l7dGIicaXIyd6?K#q!*B#-_i*DaR*Z8)MOyBtS8}yBj-Z*&i!R{`!!rOM~&_Z=5?u#*cH}&XMusoVQD4IghUXW*RT@IOjb(9DYIO)SS0YWOJAE=$rGN z8~k`VZ{6^8&3Wqu-xSn2cmm&TkQ4m zt&h)s)6+KI<&v1#`L?OA@!0m8r?$4s4z|9#>c@|}xO3)nSg^Y&7cES6ui|Rz@bGk9 zgCl~w26W@^oJU69DwR#`-w<4T&l5JC>rsPl)9~!{^_{cdE#4ArjoCHMw+1)P{yq}t z+rrb;_uGSwJA2R>o3EbJcSg^~813_KLm&5wt@UDOJ&#UnKehKy{xx>q&!cCzNjUAi zp`A|4m$lc|zcqie6h6$zOS7feAY-lH`u=^l85Qf3&!!`e&N0! zSwA{?^Zc|Ir24l<<8}Mdz&HQ>I5MuaeHVt)cl>+$!pQi>aZ#{w@r~o+U_R;i^6zFY zN%ik$woaVpn6RfV9r*s1x-2s8`sn@#@ zd{pv?(mx~EHM03=JD=TxJD+i$*gZVm;lqCJ5sd48{%bI9I{uCIieSZl((RdwGahU` z#Y4tBJ2%9B?_lHi?0B~B6As^X*f$ubD%LAqCV9m=>=)j=*?C8?AFm2#x80!cd|w^D z^Bv!t{lnAM?tqcq_>LSHo~}9VpkSQ-&5;KO8+%sjZK-T(|GHqYvF|sp*9YVEYwkKM zm=F1mzWJ`}c0@SyaG&hGbvrT~zU%geV4SLS-I^QT6y7+k#%H4z(@j?RbB zuu@5KN~Orwps9Z<8XY9sbxJi2n&hxUeLuhFzIK0Oy1Crf^}P=Fbzk@4cfU^!r_7l? zn6}nn-NDSk!^1(>HW*A9tQAf_(fYkN7{q2;hwrh^{`+im^f8BRv%`+taaeCKa^$oA zU=lPK3mtLDM~}q6EA&X{+0dLA?GrjUbX@3+&{sm|guWWOGIU+&N1>mGej8d9dLgv- zl)+#|Xl7`W(A>}#p>0F+LT?D|9@-=M?4Pm14)#nYcx`HE&-}OK>5*r>2%8gGE4vIk zfGy}@;=4zR^NY+Az(=wWhWcVG`&yI}`6Y(;vFohIiM=_}`& z8?17k2w!%?@O&-e9;wrV>E*JZlZS(c((_}F*Vt=?)mRJ*sX33lP>SOU-tGNz1VnTiC40*H1cLiqF;;2 z$Tb#TtNm1Re?Jvgoq}#oe@S^jn3*KQ*#eYhH4H%?n#I z=Gfr0`1NyM8oBC?*DB|d*Z2>+`>}F%S5A&xx_Xn>`l?PrP?k;&Oi!6_kJRDpRx$?kkl}E|_Jg$sQ z<$<4!eZ?lP$z!bhju>0B{y&UvqTL7-IvxR{56P z&(}As^2JZazG9R6`Ti=hvFcp>I&bn<*@W)(5x4D-)ujxP^KHxb%6c{|@^da~M*)n=pocf<4(A$e}q#KT*SFQ46-?41j%T;3gCZ1(SII&mwvPlwmY z;w}vxwc)9|mV9pHYreDP{F1MVJQysQKOXB9;f&R%in}tny*fK_A56a@1RGD~9!d}E z`{CvIaC)_4V@~8p8b<$UwKOMhv;D%u?8}MtV$qMsYcevM{v6rVJU0(-p6ujDp3|@ze{N)F z$UME7diOjW>|^-YsPD-b*iX z#_8$h1v3WO`=oOHLgU3@|K-T;0b{Z!d$YidQ|}eyc#n|zot<7x7(cT2M~!n{Mw)dVWy?EXeWaDkzcykh;>`hVj_K7z| z*}pox*w&dn*&74qe)dM_*Js2JyLSSe`gjL;HyD4L^lAYUkLXU-|pqSH5?L5z9L9zo*I8weHw^gT-aAQ}(akeX5D^Oc3|s8-u#fj!&H9LJNbs zFFqUc{d147G2fFKeq!@EIWp{&_=vG2yg2ZMSv_~e7enq1?+7}*c=q&{ntoyG>ztn- za`xGX#m@P;py}R}%)b(zEKlFyi^I!vQK;r}Nig_#V&e^RX?U2pPbbFZ;kD-ypYF2I zw?k@zmyh%Rt&lsRpO5`wftWDndFj6ZvNch)(HOZWZAYR^U;-7~j@)Um%lw>G@| zZi@`#&u@A9pN8C7{LG2WP7S>aei7c?)32eu^vhs$?A#T<3%Nho(TUIhH>nSux0O2a zy(<{p9&-*J3iW-*V{a|)O>b@V)`IUju@%eu{3GOK3N%_Ui|-Ltoxcis&22) zvxkd|m#4G0G$arH@>J(XqKB9K*U0K~etfL&W8vAr)xkIOZz1)Z7rj_&3S+w@e&!%= zIyU`z%8UGXdim*V-si^NygHlUPlRgTPlcynnRw>?WYb6N=1tEYE-t?EeL8&QD~5bw z_FLuqY-GC1xAGJ3oq6Bc`_G5BuaKs*pT%1hf!=;s*R`Usf3@T-z7`*P>4~K^tM{XYrueo#SJ`#e-4{zVfpA6@7Mf&o&G)Wv8 z8O8=*cg!ck`+MZYcg!b)(^XEN3J+5o@t1|y@S^jjFh3IZfz1>Tn2~R&Wxj3Jf zH+@uH?(`>w;KsyX-+WIB&sT2dyd-`3(VZNE@x#l#;=mW?e5%1I;nhHnm21Bir$*1F zYH?b4*flMWrQu=x<#9%MP3`GU55eStmxmgV;kAE1AAU~qnH#G8`-Nce+P_~64?|i^ z6J^ag`%(m$bH=vr<1@oMU-UDwV%GSq@N8cj^7d!ryW=dK6TV-QMZqekbEBiLoX!gm zLt5oj^}QhYi(~ccdtvl!&P+Y+rHjIg2`}v8@NhYQuGw80-oB&j*R=MYSoR_tb!V^M z?r!_iUbNoMaerUiBjjc2t&zU=`S)YLAok9vxMf>sS6{X_HCtz%pE#$rv9AojB!#l? zjrEPl#?qJnMa^H#<>7B_I=NpM*?yzDEksvm>ZP$u;5S z#@`*WIQe}yvfm1fM_>2iwc+V%-0Q=`>Kxn{UULrUZV17Qi8pU~li|iYG~@gryx$JQ z5zqO!C;9v?L|5-6-^<@Oylno^Z2Xp1`}L2(;AQitX5(*&vbirjylnp5Y-*nx>;7PH zXZ)!2T78f1OJ3%o-#7U>U-yTXAJe)!9tZ}nv$L|qr zLooBkTTgW%!z+h>hgV-Y)V!Z=7+kA)m(R1&t2g|Xmd|s+;C>gC&uf~meCSq%V8+MS zxABGWhldt5zb=vR{OJB0g7L%m=K-(#au{Ad?o0JkD{mv~G!`9w(dUyhd}=W3L|^N- zcKG#!(N*kq!dGl}F`HVOb;Hxc&3k5eP5yKZ?) z`l`XJ!i!5U_f6Ala;KXeg2^4F=NqvOM- zzD-+%x1Z_yK2wM^+2*%WmKBZ0TyB>>s{=Mn4)^ zGgrC;LNN2d*P6X2e65+h`*+iU(a{&x-E>ee`-;BK>HEUpAJQt%gCoQH=k)!Njpv-w z*In^}@Z#28@xkzaY$PV147qy$9U2`UcC~K~4-YrS)v2;NKNucvFR7dLt2*dw{g#EN zx2Am5sLtuf!#k&J)Xm*?WOzBfI#l}pKJ-0%D*x{?wqFmwAiZy$-nT;U&ehLOzeULX zLbj*Hm!ESg#x3!=D6&|_xizx2)YrRjd30>Nk=VE-Bsvw3)9nG<{C*hhX}niHPh z`%nz|`t2OaFImtgR+Say#E5pO({D%1R!S5cP?)mJ9T|)l@ Dy3*3V literal 0 HcmV?d00001 diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..75674e4a148f433c3827f43ad472092aa5a08c68 GIT binary patch literal 2332 zcmZ9NTW^d}6o$X)Oj}g7)w4rQ6-B9{Eh43jGR4rLRgDNOZd?!vf5ENaA+8Y#_ip$( z;*dx@&)2=0FSfj0Yrku+!(QK-t=u%ynzfX&wyZsSoNcVGtSKv@DnImW$d5^WPO{va z;nR|ztJRn4<1b%58K0b*Fv5!C|1|DNqB8U2)!lQj2AX_ZOyr@lIL;Xl=kKv2Cf zMZTv8>cjlK8xPDO5ny(>6-EjL@> zj4L-^;Jk<2LV>duxq5-K2DwIov)6LV1{gabA-(d)^$@(T{XlhiCeL zoOP&IdP{lC;FQ+pci0V_?Jw!hdlTn;8+#wpWo&K!UgeCf9 zXMtz68RIVgRpdEf4cfzhCh^Y9r_`s5=<@zH?9&|5WuLUo0y+Dn9(@WFYoG?4 z^|Ir{Qb`T#-8*`{&!~nGv9nObI$p0xu$hS zR$t0yW@l!aIGlp5L%%V9%b$S&J*;>{qU%z?oO>`T|!@-1-7%ug3KhIBSsGSm5dtx2eEcn{k^9 z+-ZrsBj;+fg-BQQ6?U<&;LPQ6Ru!aYc7Sf4Q42C=rS6&!oI%)A`^$M<`pqRDF=1Z_ z=hF7MHs@CLtYzrcv+6Q+m(R`Rd2%HnR|e+jKw7W!wBAl+)T{1_dc!W(+m-z0Qf~o~ zTf11V&&spny|@|O)mjyKjdG>LS(A0kS(9--r{FwCuA#uid*K<@v;pav`*XXT-GYq% z)LqeE*in!3@Oz2pp>BWfH@9}Nzd>xDNBI55rk+o2{=F6betX*XYfsN3U2%SJ)8}EF zXBZdrkSiz7d05{I$e6diyol{Szgu-z^d5GcZM^qF^sa92)?#k$V(%l^zE?Lk>M!Sa z$*dGRinLGb)8`!%K6QQZ{(1L=PhFqip!>X&!l$m!{;cQ2q(`0!?C4e96}?X6*3_KM zJ(>LGQn%OBLD z7-4SP%Y5v!k#gFeH3wM%u4bk_7io;Si!e@@yPCh2{nftQV;$N}iHo}ZH>vhho6pQX z%+pMr3z4360eS_gE#HFFwpVB6J1FNF+P+tE`Y%eldF=O6r1d#Z{g)w2K;He@-gR=; zXzXH;Wdm!pt!*jrTUda7HPRTlW^C_0=O=IeUzc?ET4xW^oYgvK6O4M^`nXYD}x?XouP-ML+T z#(U6>sR8DH8fkuc^J#lu%IV*Sw3hH|4<#<*hp~;1`VXM{|Lp#QNS~o+HuE-nKNjbz z?p(bi^*LAXKyCNGglqtEo~P}dC#U}q()`8mc?8><^&dtWYi{i~k=E+D=F=WcT-ZmZ z*<;ge@4RBaZ)5w;$GpeUS!?Xe`_QxOYXYfl9UmdBYXW$#_9T!qk9&@RQnH=j3GmtR z_y-Z#DEv;Qj+ zhxngsOPq6xxGNK9j~hAHo|gdk8?S#c7$&d2tAJ-4W53=zD?r@GQQx(Rk9{k#<=pQ+ z@2AzkJIubWW3t-Dbutm}of|+G(C>U=mfh(3jO#;g0=>XEedc%n*Fc|lN9_G3@y^)y z;8#%GdlLI^p6{(lpR@9JVts~R0CUxW2f%IMEv$azoj||&|KOS2j@0k^2V1|kym{5F z(|7H2U>)wa&PL?jKtAkyb3NmZs6OBOQjENF*oxc+oaYu`E@vud>;U`h!MgPsQ|!~a zw6`OzPZ@oGiEdxUJPLMz==&jb{Y!y;KZw-tvTyy`^5#{KdLKrQdd*?Ia=vd-ujlED zdZRw`MV`md<2@QccV>PAJAuBzG_xnrcP0Mu#65{Ee*!dcGr8|6Py&8a`ga3svc5s2 ze$Uv0+z*C;zP-uzxgJD+1~h>KAZmIx@%xBz|8u|`^5z&$oH-7mzXHr*|K_Miz6#{S zehqyDI5YjP17qZ!(P88p!2bfj5&iLve21>D_>FvzEgvyIq?l7k&zyt&5y(f(Pw3_u z01@*uy1adjBF(FQ1nGYDF{E+kac1uT`}KdqJhPGS0{L-b`jPJ?ZXEe1IX*zTeBboF z4?YFHZ|crq9r9x^38vpCpCq5}zq#D!{-P}(-@{kv?lJc~Y ABLDyZ literal 0 HcmV?d00001 diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json index 586be187..7184bd38 100644 --- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json +++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json @@ -1,6 +1,102 @@ { "note": "Campaign V slice V6c. Regenerate with tools/compile-shaders.ps1.", "shaders": [ + { + "name": "atmospheric_bloom_blur", + "vulkanReady": true, + "stages": [ + { + "stage": "vert", + "sourceSha256": "81222e42a52d0b560f5916b0f312bcc5370e42e596c821477d71c61883c3d025", + "compiled": true + }, + { + "stage": "frag", + "sourceSha256": "4b22872f61b462cdbc82d4af38c7693212bc7446c7c68faa32dd5585b08c43ae", + "compiled": true + } + ] + }, + { + "name": "atmospheric_bloom_downsample", + "vulkanReady": true, + "stages": [ + { + "stage": "vert", + "sourceSha256": "81222e42a52d0b560f5916b0f312bcc5370e42e596c821477d71c61883c3d025", + "compiled": true + }, + { + "stage": "frag", + "sourceSha256": "a01f09dfcc62acc5376f6e61be6aa2e8c5b0506550c0fa318f4434cbf3e6a17a", + "compiled": true + } + ] + }, + { + "name": "atmospheric_filmic", + "vulkanReady": true, + "stages": [ + { + "stage": "vert", + "sourceSha256": "81222e42a52d0b560f5916b0f312bcc5370e42e596c821477d71c61883c3d025", + "compiled": true + }, + { + "stage": "frag", + "sourceSha256": "240d2fe5e13e3850ceb79f178f1c248c71875fdc1973ff8cab1c274660ebbc4b", + "compiled": true + } + ] + }, + { + "name": "atmospheric_sun_occlusion", + "vulkanReady": true, + "stages": [ + { + "stage": "vert", + "sourceSha256": "c21f381f2de05afc4415b2e348884a3cf6440da279b57d9a05a5d6ae5e9ac453", + "compiled": true + }, + { + "stage": "frag", + "sourceSha256": "bfbc8c508b21dec84b21b2760877bfcb736c4f233e8557f6d1f8b83600683d5e", + "compiled": true + } + ] + }, + { + "name": "atmospheric_sun_rays", + "vulkanReady": true, + "stages": [ + { + "stage": "vert", + "sourceSha256": "81222e42a52d0b560f5916b0f312bcc5370e42e596c821477d71c61883c3d025", + "compiled": true + }, + { + "stage": "frag", + "sourceSha256": "13875d9f6fd28f1049d1f94741db13086f72cc7170659eb73c369b4c99b00a4f", + "compiled": true + } + ] + }, + { + "name": "atmospheric_volumetric", + "vulkanReady": true, + "stages": [ + { + "stage": "vert", + "sourceSha256": "81222e42a52d0b560f5916b0f312bcc5370e42e596c821477d71c61883c3d025", + "compiled": true + }, + { + "stage": "frag", + "sourceSha256": "8d6177707a95cf0230881bbfb7467c0591632eb67dce3c758596c83cafb14ffc", + "compiled": true + } + ] + }, { "name": "debug_line", "vulkanReady": true, @@ -17,6 +113,134 @@ } ] }, + { + "name": "directional_shadow_terrain", + "vulkanReady": true, + "stages": [ + { + "stage": "vert", + "sourceSha256": "e863894aa1d66508daad86806af2b2a3509088109754c491a3607477f78ed644", + "compiled": true + }, + { + "stage": "frag", + "sourceSha256": "2b9ebbabc96c3ba53ce58cea175c52932c81d1d93820550f29a0734131dba5a3", + "compiled": true + } + ] + }, + { + "name": "directional_shadow_terrain_multiview", + "vulkanReady": true, + "stages": [ + { + "stage": "vert", + "sourceSha256": "2f7d7bba3aa19a4891c30000e29c0bb84a2afdc26a44248d4c48bd0ffed8a04a", + "compiled": true + }, + { + "stage": "frag", + "sourceSha256": "2b9ebbabc96c3ba53ce58cea175c52932c81d1d93820550f29a0734131dba5a3", + "compiled": true + } + ] + }, + { + "name": "directional_shadow_world_cutout", + "vulkanReady": true, + "stages": [ + { + "stage": "vert", + "sourceSha256": "8331583c1d63ee59b3f7898e25b2e9730df98fd9da28273661caad948fb46ae5", + "compiled": true + }, + { + "stage": "frag", + "sourceSha256": "cd9c404a9379715061358cb18a99b9310acbdd5062f0ad982ee9a37cd9f99922", + "compiled": true + } + ] + }, + { + "name": "directional_shadow_world_cutout_multiview", + "vulkanReady": true, + "stages": [ + { + "stage": "vert", + "sourceSha256": "a4bb3b86283af310a22d3943dd1afadb8a72b42c9e5501efc9abf2b5124a1446", + "compiled": true + }, + { + "stage": "frag", + "sourceSha256": "cd9c404a9379715061358cb18a99b9310acbdd5062f0ad982ee9a37cd9f99922", + "compiled": true + } + ] + }, + { + "name": "directional_shadow_world_opaque", + "vulkanReady": true, + "stages": [ + { + "stage": "vert", + "sourceSha256": "c2586df5f09518c40d052bbacdfff2c00a82b1eefec4868749d0580571198067", + "compiled": true + }, + { + "stage": "frag", + "sourceSha256": "2b9ebbabc96c3ba53ce58cea175c52932c81d1d93820550f29a0734131dba5a3", + "compiled": true + } + ] + }, + { + "name": "directional_shadow_world_opaque_multiview", + "vulkanReady": true, + "stages": [ + { + "stage": "vert", + "sourceSha256": "35b4d524153623691ab523a049212aa35551f96c169c28c08f62d93e31d9e68d", + "compiled": true + }, + { + "stage": "frag", + "sourceSha256": "2b9ebbabc96c3ba53ce58cea175c52932c81d1d93820550f29a0734131dba5a3", + "compiled": true + } + ] + }, + { + "name": "mesh_atmospheric", + "vulkanReady": true, + "stages": [ + { + "stage": "vert", + "sourceSha256": "d4f8bc12ee84379ced84f5b703cf8be95798a23a7063773e214bc1eb6ebbb65e", + "compiled": true + }, + { + "stage": "frag", + "sourceSha256": "62b001a72080ea74bdaef0fb2a4ed92533feaeab76187ce4215813c6327432e7", + "compiled": true + } + ] + }, + { + "name": "mesh_detail", + "vulkanReady": true, + "stages": [ + { + "stage": "vert", + "sourceSha256": "0273312da9fefb5084d3aee120f33c542e1adcfb846e6c7b3fc2b068c1348fb3", + "compiled": true + }, + { + "stage": "frag", + "sourceSha256": "1fbc3ffb12d260cbe0d111551f28c69f15754ba5e5e11fe3ec43747bebef4883", + "compiled": true + } + ] + }, { "name": "mesh_modern", "vulkanReady": true, @@ -97,6 +321,22 @@ } ] }, + { + "name": "terrain_atmospheric", + "vulkanReady": true, + "stages": [ + { + "stage": "vert", + "sourceSha256": "06258c7ead0e123740e325c802156987ed17dabe355d591d0e90facb420b8e04", + "compiled": true + }, + { + "stage": "frag", + "sourceSha256": "9927abe9cc4fb83429d3e2ec8ee8d13f0b1936cebf484a4e529dcfdab431c3b8", + "compiled": true + } + ] + }, { "name": "terrain_modern", "vulkanReady": true, diff --git a/src/AcDream.App/Rendering/Shaders/spv/terrain_atmospheric.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/terrain_atmospheric.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..bbf43111bc4c3b7b68c89d49670c6b3b6864315b GIT binary patch literal 21944 zcmZ|X3Dl=WbtdpX`>yOeJP=h!EbH=#jXflZ= z#w2dzB*L(xKtLlJaRC7rP(eiuvZ#Qdvdr`Q_xmt)`j|RhUp@D^w{G34x9Yx2_tz^d zzx67!m6w~XFKe0oS!cHVY`GxqW$Q0(FQ3}ard?yU@?3xL;foL7<-eVM$}TV1 zZC4$iHCticU*BiXHk_@PR(;*cuX#Pi;?$c`PhNgD`$p=uF~2|cvD6h~xO(ausT-zl zo_bX3(W$RYJt_5^)U`84n%OF;2L~OMcIDK4=kf}(W75X;?2zbSWu??Z^0TtOYcIoX z9a*WJIn23$a{S`f9=L=)=e8U;@v+-=;KYF2H=MYw#a5|eue>mrKeBbAS1Qwsf&9t< zea++G^m#=ZBssUN?v!d@xYu?$eIW34@Ky6d~;z;%6g4QG7kXRFxoS$W|z@(S~_ z^{h$foGl1meJ-ysdpPo?Im`CWBLjZ)8NA~BTp%F8t_*KeD8p7 z%|&Ybw+;CA0k575SDQ5kyyk%S9q@hw-haTK$OW&TPY(D~1ODECzdzs~40ye4-uhX8 zz#B|>z3e1=X{+o7dj{Sx$o||ib%X4|gf++Z*hZ1pO-s*~yy+zCPu^vcbLnRrN0ys# zWd4pwyJf1f<0Ky$Ouu=4x8wW5*f zHr@qu@&EUKcjU&+{-V?mUU&QR+y9cmp8nFgeC{*;u8OSxPRT&7+YPD4R5wXh`BGZ_ z%t2YU-7I_I%i-B=5&Umy$>#Hwvw-KHy7$`) z;vTDgpo&v9?$=|RV?FO+qwIb<%I>FR;~zO;`-(rZ`)lpV?ysZl{yNGhO>+0=>5-km*6xWj!~gYP zZSu_idqd>eY~Ov_?~Re!sC44{leAfmXXa0K|Hkc*pG(rdEAsXezA$*XV0&S^yqDDX zDZ%63dtr8oYZUW&;qUP<_Rd~m?U4sUx>nbQHO_;7(WkIeiM7Thf;C;!26}2 zJ@|`Y`_$g#+kX6IIQ-&C_FRS2N6zqUC6D0sk#9U>;mP*Pm!@*-()i4+b_|d z9{F!0JD2h{UFY?YV7m8YEPkFyAHML%gfsW2BIA{EPfwg{e{`(unG8L*r8D-4>2I$t z7hb&dzs`DeU9^u*IbpS26M4a!v+tTYBP&I=&*kpMy>euHug5CExL%Ldf)&?;Zq-zr z>j8J&)(W<6WU)Rwvau?!6YNY?UN6|0>N;E&tWQFpU76^Yq`f>cUM#h{Dl%WA-PMut zqun);jWOC?8yU~;x@nB-BXxIeUH98f>EjyUyH;P1j91EiW8%jBc5^tk zbjI#}yCqnB^zDCZuk~Kl)e$w%u3S+CC7y^Sf#|{rKr#y)U@?++JNL*IKd8bU#bQ zS!cNGe1Gt=d-Z{E_^z|}EL_+5phfH7~j2mSjG}h_v+!1yI1MDSC0s$>t1~{ zee6|y*Z1+rc%|I0CvLoEzX`{d&e*+XzYP{2efvKdELV(s^{L4C?$xyugJKQoo=(MC zL%3@wp)R{um(w@3Yv?@`*ERGmY7OySL+_;c#<*Ir7?pCXPuw`~HNvr_6MyHuW@J9; z+y7a?_;IhEoxOy&PTi}Wr?&g=(b%!EfAp~i_G&W-(Y^aSJw+}uC-V9 zh^$y=y7g0W)*0?PZxFofUfnPpzU%B=9@lmDK5w1zU1#t0`0mwXg2mIldTiwGRl4rg z#ldvls|$kdReaZXt6;oRZsEj@*KF%>Z0U^MYqm{f@zJ;cw!!XK<6hk^9KL(?_k$H{ zNVg~zXAR-5;r7AH?$sT_;k$-A2IIPhI|W-qeAm!-4}4>MUa%OIa?hW*ao#To$Cgg~ zo%e3ReA2i7i-PgvUOgv!32&XcS36H__umt-V`Km5V-4)p?kjC=AB)~zwSV>F$G$u@ ztzu0*Z=4+Y)v4AL=8p{--?&c+ma7|IZM4R8kA5WulC$gfQ` z25d|=WPHawBiNbin6=S%%%`R?`C+fWG5;uS$MhWZ#>i)-8Ur>a8#2CQo*iuMI%aLO z9rNjFOn%tw|E?rPYw(s}>maUj=Y+%S_s+@gt&y!2-I~+-y*=2zT9iuf{kQh#PWF74 zJ8$BQRqnq}oUx9|6TRo_3&Oc}s$z_|o}YR83!`UajK=)#U}G7he%=%8dZ@~>e%>2B z8-3rAdGh_fVE*aq`vbvZ7HfU~MX+^Om1TYZW%O+Ht?&O8%s*Y2WCeE52J-v5)G@SnI>0K{-s_zQgp2dx)cNFg}`q@kJkav`g zVz*)H_>Qu1WPIOIHVMWxFK!mBI8St&rsC{nSU&Skwjg+XN7*v6vFJU|Ee!S^-+6vI zaj`Maa-Rvu4%d6#CE?gMo?izm;-R}V6(=6pT)o3y7QF1e?(%T>=IJYfar$_Ny)syy z#y3x26^!q_?x|q$^j`OLaPM_=`Z=3^(Dhz-ZLl@McfQw$!z<-(m^gc)`|R`K*wPuh zIsXg6;-hc>F9uszIi=V1#&Gzq;p4%IHKhAeD$W|hUBfR2FMF^1w{ZBb;a7rjUBjD# zts%Z^_*GnLW4t9;j7qt0PTV-}Tf?!X6MyG@TQHyW?fc2eLJ$eN7wg?3v%Z1 z){brW*&UJXzpl|;!HPAayE7GMjo^;^{a|}`T<;%*!}H~wwx8BHT@wE8V1BJPdp_Mu z9!skjm(36Hsm6u7ruPPqYx=Xujia&MKXLq(dtlIzaoT=+bniGc z9KP`#7K~Gs&U4KqS8H>4c-(=hY@HeWF~LWt*6!HBj;?mc1s|2#m`)7#jr_RyU?ZlN zgu^$cKMcmHN@H@S&zRp3^6nSx?}|2#>^y7TNBrH@Cc)-yjJr~e5`8M8yU_}n0+aeWbOxT_m6B%!hTA!_g&D~tAQN-E8{4kgOCw=dG z+k_YIyHm&KiRXl;yLyUk+h9KMVq26}5gXlhsW`E~V{CYR-knzI`kg)5YrB4b6x{W* zHeJ6z4o~;ijOUy4n}Tu1d2?FhYMp!f1=)|jKl6QI>iMY`Pjh)|Fg+i?iogFc>0>^h z5B=Lx@%+MFuXBUNcUmeR;tMrf-_Ctu^!^UQda&i6&K~8blL3d$VI`RF_w2JuHxK=phYmJ2? zYdh9fu{ECY&q{x`UFV&G>0Jk7>=Icq2Hno7I6h%xoH5CGea>5DX`k+yizdDH16h+a zvrFhHWyqLz3b@jK${CL4~K6qxFQ&*D$NDwP1oW*iOn85 zEjsUGuFreJvD+b)-rVgSfB*dc$i~E37vmlt{O956YWIP`j=$P{FqrO`_!H7!2J72W z{;wup97_2QO}u-Y(&y3-hv!Q_Hq9A-6U>L4!KQvb65Mg=%YSs@aU=f^6K`C6$Nk6f zeCfxg<9;ldopIT?51H@BgT+MOd*LU7&!2RS<5S_RRZIC#PdwjB`G1=DW!L4O!}Fyd zo5uH<;Ks+Mel8B~xb)>OnRwjDUpDc}uFK`&`O=R~$GsxB*n7xc-OVI*F@HbP0!G^!R(zO`ktXaKU|%T&ImhWxc7WEa>trQCHBr%#`;t`Fy|I>VjM4Z&>bI`7X1^GR2KHwGKSnl{caMV24v^cClqgWV(P zYIjqx9Kvtyz8cJyu4CL1j91EibK=aS+^rKQmVN`cEu8-B>CN-~iOI9b?U9`;HqAlb z4#ukr|NM1~?@l_;p4}&RgkzVf%;1>==o zpSvUD&kxt@^TWt^T>JhwvcBSL-+LnC&kxr<@RLcVJ38y*+r+)WeA3n5zX$VYO?vOW zKQcS}^^;iz_q_)qrrKzGKYciuU-u*S`ghDn zg6WO<>$Hk7@sFnBj0rdP$AisJ?8dB(wqyP#nBRFk_|%U1+hBTQ=19(qG4W5N;*1G* zo=*oGv-7Ntwqq_A%&$1u>))7Hh|YBo^T%_otoMrH^r7oGD^GUDsog5!*wTr4wX}+u z=~hj}i5a#go(ahK8#12f={18lO$`3+OrNpW3WxXak^0>DtjKddcWd|SwIkb$JEzjQ zH+vTD^XNL^7DqlVwa>@KTQ@x28xm)Rovjy)>sebrm|tg&&YYWTH<)BLeXiUv*f_@S zb1%Oeg{Q0Ejf3l#j^DmNY#P~o`nJ6PUxuD%xRY>CJD!+kv%e?zdm zg1b4gvGXcF*LRI>3EwrkAUe-0*65qzm+F?vw?@Y8moa>&yDj*pw0M2|UH|RD_%o+< z{Z6oFrRJ$SBR2=oos`(*&hG{5OK<+ow?Bw%Z;sVk>T`E==Cf-mJ3g9MeiTffQ}hgX z&%}-S_ug>UMd^L>r;+(J20!|-tMB`Q`Buu^KXLli&qLwrM;|t$pNAv&nzYX&LmzSQ z!EQV^_CWW{!eD#mw2bTgy)9U=XXv(0Jv7yxf%(~HlC|=KeY$V1k@L^yfZ&4$-QI)l z4+fp{_n#xXUiux9s^7)gA9Bcc!Sv3A_p}#J`iS%R=oh8p#RrRT`$^U|J~4@r&3>sn z4>~c4k#3(sC%*l{i;wPrN!R#xicH`54xIFjkKQy8=*WT-%7nu#d zXY%>McxSR_celuRd7<2kg7F)qx)(dUdjvb3i;1 z4gOu{ezovRbxY5^f0vExx%Y3goqK%Gy?>*P?|M8t*tzfdSUa*3>#QHs_?iWzyxc z&Gw8=4x-y5c+~y=pxb@YT{7jFBV*66xa66)PJScK^9=n_sd(|h;yZehwaqie6$hKM zQ;!>T#uW$M8wZ_vy)e9a(Y=^NjhCw=3iHx6EWuzNPYCj{Fg`RqOS(Qg+g zhL^YKd*)sejBnqU2Dh*EY2TNH*O$J1UmlF_GyN-q`NP-dRl(*_`Q*s_Hs`%MG8=qz z)RJJle?wmG)X4aaQ_KCAVEmZ#UK?4?qpRH>Srq*Gsm-f$>;g{-`=Dd9)te?`nb6jM8JrnVxAG`WK zA((Ha+)E}-zxsJuxcbqD&FJSbdg7w2J3Sx;;~I#)JDFv{$h6by_N)&Pu4+-uXdSyM2P$$wPOg)^6YMbe;cx z!JR*y-#zI!h65t=%ck}R2G`!bgiZI+A(QU8;o0fCFtvY2a%eCc{ml7Du?NgZZ<5jp;*? zedDClS4;AGN{1=1y zm6O=(-!X3trZ?tIX%%DQzm$qICfwM+5^R2AH)d_L9rLTf{ECCU{vGpc!Su%bW?IFV z_^+qpj0tz1w*(v0UNmNHv>o%-V1C8HUjN2?JDe)!e!KiuWPRv5&Ub>@8K-vNjm(x# z%y*_$#7uWbDo)I>_}mxB_r<2U4_w0WUHjc5!Z=Cmqr>oze z1=lYfzx`hIKxFeV2W$CouwpIg9!$kqJGjsNAI*9?TjJ?+|KEqh-;nBr~!{~Ot!8>_XHZ!e9`d_2#y? zOTW$1N56}+KjhZmgrj#Re0$h@(np-fNB`SYym(;oJ&~5IZ46=(Bb)VO?-!J-6O$O} z)*W;aXUB#YAKgZiuJJt`j=u42Jn3UTkB{Csc=5p9*UJa%TY1IE=GuGRN|D*%dnQ*7 z#ygWeyQ@aV%M0aJkBpaR+>4#vwIVy?bhZ0dFn&Du-;RtQ&;55J+lS6Qeb4AWabjzJxF;N2IzE~o zeiCd9`Y}KJH1e1m;O2+>qT^eBV8=(tzCW0L%nuJv+?XGJ9?tqHJ)^&f+;h#3e(dV| z;b6X%a*s}&e)aQsxcbqD&FJTe$h~&$^W@M+9DK0rGn@VIJg_V0yf{DZHGQvj7VKYn zV?1*khbKG7_Pn-zoNs%)eVRu$pZdr>`fM@D3np3Xtme|p4oZLD0@)mydibQvR+$|h zog74WSn#MjbkMzc()AnSRBJ7Cb6*TLbBNbxId8LYZ0Y!D&f7fL81!S# zTM+p_%6VHx$G4ovj*pJLFqnSKdD~3fnDe#`XZ@7so$Vs?>zRlj{n*v__Q8BB<#wDn z{px3zaP^}Po6*nnBKO+0&+~^q;^2c__v-#xV>b3|bDs0*ywa6(o}F)Y&1ueR&*qpD zoJF!6XI#%Oa-}@Swz?z9lTLo2>-)muVC%Igm5=c~ z2K~g=^*uhxx7WlsQGI;tpN$^TPz8-#_SsBDZ!1i??<6#r{)J;eV-O=+#M&KvH9vg zJw19h#%Q1aI`x^q&&1Yxv9q3Mq;);DkIwv0kBKXKKDUUV-EL}kPRp0Iw~oG%y=uj! z|6gO}lOvy$DwonP3C8j1jNnd*tRJ2A#Jwiiyq+`Z{Vs$#(Y-dG6NAOZzTY$cFc`02_vK52`LLJi zyO;ONoc#{W%fp$+_;1W#5f0yPpRWwYsS1DYGrjMf6y7+k#M8tLoW^d9uK@bpT#w@;ig%KhoYEsbyK_l0xA>Cc|tdD}bttcGfCXM4Nw|$yo;x<}z(Yh^6L~3H^<(OA=i~ zadHcB+=&xPx+vXrP>~bmT5`WEUEZ(X|MUF&o_&tT=Xu`G`*}Xk=lOg--`~u@m>J`H z3aG=*88}`QZ){15x%sjC-u5Zs@u*ZVg%a2|-DsV+RL6E zT|VnQnb{Cy6qfLj8ai0jHQ2%A5$jUO|5WB~pPr6A(xl^|6Z=&eC{aUy3KZ9*>wlqU3T@_ zJ!aQh?ljxIu$KGHp3rP}on5uK_w050{(;_svDKwVbitJv*4%VEc41{etZu z(Z`RcII-TEGy6pB>J#0-H`ERT!$xAp_P;CBVvn5rh&??v-fM!wrzQ75<7}LDtvMJm zUK@|P1|yrq=gipR*v^El7hC1u^vRK4?}VX^!twdo5rC&8;-M`-VNg6vSPfmw)?~?H5@X@y*;JO#9nE z0{>As>;oF+JNnaxEo%5s=HeaHcuP9GrP1MC-eHY*Xoq)rbU0qsc0_cw&5TrSM@ENN z9m}G_y7@oWux`D}gTcGJPc&Y)-eZEnYfXJVA>tdoDtl)SM_|6wmju)AlOtmF@x^rd zGntLm=_%i*Bj-o-pI&jFi#{A#84>q|%!W9=&qr28#L-{QjKhXko-aioj`(em=gXN5 zdH7C^oE8yBmj{OpuRLc&AC8_if4SD#!8u@xe99p-t}Ze}&g-;I^y`Fua|p2+3bSNX-r+pSmK z#eF>xbBC&o}an)dSUe82)>?u zH?yH2J7j)yfn95%f2TpN8jQg`9{=!QIe*F|oKh@;Dc!-iL$8>0_Lx_4o>oGV<7br&ku zT<7pqdaobyi;=fmueyu-&v50kCvoz0d!$#zeX`}%M{)At*14FA`;Yin#&&k%@w+?jk~n#)54S|;UzyoG`C(>5KlyHr+#b#a@~>HnqsWg z-GjXp_nB~i9Q)RYIC*x=Ui_Jp&+o?F(f<_rWAokH?DG9N_WhAbsT22sNPbd#?}_lq zcVA|>-=;@1KNfj7@<>FUiJ2ddz<15Bu{=*S3~tDol1A70d&8`QJ1_f}`?<)ok(EvK zwbpn(I)AP4LUb50YK?iR;l?}opJ(U9Z@qDuC&q>!)%HCpI_&1`X-fR-M~6+0?!Ma7o>>|`7@f~r{u~UiwKj=u zt!=}pwKk0nFV5>5XQ;!O91LFdoEKeO*%w5|sroL8j?=C01E~Y9KEC2!8oeJMy!NtJ zxNuy)db?LdUlh!_`3C$tw$cB_N53+1L&Te>@80gf)zQ7LOudsDc1?^~vH6O3UE|5= z+u(g)9}JhzySAqM)~UK~jIJ(yRoAa#8|vb_DRNsx9KF{2L3DK)RoBhI)Wui4TN^La zDDQ`j$5*xfD7tt)wW`CK)~;G_kFHkdtJXVX8*1hINdzX2UN!tIdiAidI~pdw>i4W@g#kY2M5B5^rm&3DHy%Z-;_iUU|abJqx8M+VR7oO8w*e-^#mV;PSc0?x*+Vza#nT?Q9l(dZhc7H;)Z3&K8a14%Zu;84OHg^A%6=d^PQ97GM91mAuf0%ug2r6d;Cgt@qB7khc&HTwf;A{T8FHWJ2Jjg zD_`h+Oq_qJhB48rhlP!8SfWPjT05Bf`KqpQ(Z%zr%bN0=>m5wWnai(Md~0|2U@yg; z7_PnQr8s%IXXA{DyKel>(0vdmPt`v$I=@ladJPMG^nLmN1h{5Tw za`z1vj>}hfW3%Xo2dnor6x;B=_%@Hsia0xZ^=ymibvNoeI})t!RNN`?J45$DoIF+kd!q9jh0Sl6 zUf1u}F2Qj5+++9C`Dx^HH`)C3>2m(0ao7+rjqyMMTFT)w&+i=rkMcg!czjjsQPIWosZ|};w070HJi1zkBHq_0V;gGaJ30arN3R+_ z9=&>4*f9+gU-hpDrhdMv>)7bx`P5bU&GioE>%tgV%dnm7Lth@42H%;Hb0h8sy?S<5^tu~$56%we9`F_K+l?n@-HmgC;qo~{Yszn(s_Q$^ z)m887y4Z%g_|A)58WBgY^)86M=KH!Zn7a6i_ua-zi%0MA#f`^TwO$fkJfB+CVNGjS zt(QettMm1~u8nP|mGANhOdP#xxFUM>u&}EdCcgT5bujhwRbAIa7tg1z%5ScBuv5-l zezoFTySoQ_Dek;*?Nu+u$XH?v|@jFBJL7Y5Q|M#Qw8-?A}Fukt#b#pLWKKI!D z^uFeY#OLko6@BlBzc;J*H6i*w4X^&}8yn{T!PT1kMgLQ{Mq&FmEK@)K!BGy0`2S@q zgL#_|X8usatJWi8%O~Di&f>Jx=kMH(jE#?@=8ZBhi!6@FX-{lC{X8nOdtlbj72zz% z-f{K#v%xtO- zhJ(rP@5bb?hJBqFjIZpIqT5IP9o)&$ap3dv_f;Ea{&d8?E0?{)aEDW?+U?IeINg4z zjlC+f8qL+8_qDy@d@b_DNbUVA(fLoyZ11Nuf718&&W{h5mtOUr5xwe_L%lHPTlHFx Ruj;M( 0.0) { + t0 = sampleTerrain(vec3(baseUV * terrainTiling(pOverlay0.z), pOverlay0.z)); + if (pOverlay0.w >= 0.0) { + vec4 a = sampleAlpha(vec3(pOverlay0.xy, pOverlay0.w)); + t0.a = a.a; + } + } + if (h1 > 0.0) { + t1 = sampleTerrain(vec3(baseUV * terrainTiling(pOverlay1.z), pOverlay1.z)); + if (pOverlay1.w >= 0.0) { + vec4 a = sampleAlpha(vec3(pOverlay1.xy, pOverlay1.w)); + t1.a = a.a; + } + } + if (h2 > 0.0) { + t2 = sampleTerrain(vec3(baseUV * terrainTiling(pOverlay2.z), pOverlay2.z)); + if (pOverlay2.w >= 0.0) { + vec4 a = sampleAlpha(vec3(pOverlay2.xy, pOverlay2.w)); + t2.a = a.a; + } + } + return maskBlend3(t0, t1, t2, h0, h1, h2); +} + +vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) { + float h0 = pRoad0.z < 0.0 ? 0.0 : 1.0; + float h1 = pRoad1.z < 0.0 ? 0.0 : 1.0; + vec4 result = vec4(0.0); + if (h0 > 0.0) { + result = sampleTerrain(vec3(baseUV * terrainTiling(pRoad0.z), pRoad0.z)); + if (pRoad0.w >= 0.0) { + vec4 a0 = sampleAlpha(vec3(pRoad0.xy, pRoad0.w)); + result.a = 1.0 - a0.a; + if (h1 > 0.0 && pRoad1.w >= 0.0) { + vec4 a1 = sampleAlpha(vec3(pRoad1.xy, pRoad1.w)); + result.a = 1.0 - (a0.a * a1.a); + } + } + } + return result; +} + +vec3 applyFog(vec3 lit, vec3 worldPos) { + int mode = int(uFogParams.w); + if (mode == 0) return lit; + float d = length(worldPos - uCameraAndTime.xyz); + float fogStart = uFogParams.x; + float fogEnd = uFogParams.y; + float span = max(1e-3, fogEnd - fogStart); + float fog = clamp((d - fogStart) / span, 0.0, 1.0); + return mix(lit, uFogColor.xyz, fog); +} + +void main() { + vec4 baseColor = vec4(0.0); + if (vBaseTexIdx >= 0.0) { + baseColor = sampleTerrain(vec3(vBaseUV * terrainTiling(vBaseTexIdx), vBaseTexIdx)); + } + + vec4 overlays = vec4(0.0); + if (vOverlay0.z >= 0.0) + overlays = combineOverlays(vBaseUV, vOverlay0, vOverlay1, vOverlay2); + + vec4 roads = vec4(0.0); + if (vRoad0.z >= 0.0) + roads = combineRoad(vBaseUV, vRoad0, vRoad1); + + vec3 baseMasked = baseColor.rgb * ((1.0 - overlays.a) * (1.0 - roads.a)); + vec3 ovlMasked = overlays.rgb * (overlays.a * (1.0 - roads.a)); + vec3 roadMasked = roads.rgb * roads.a; + vec3 rgb = clamp(baseMasked + ovlMasked + roadMasked, 0.0, 1.0); + + vec3 surfaceToLight = normalize(uShadowLightDirectionAndSource.xyz); + float directionalVisibility = acdreamDirectionalShadowVisibility( + vWorldPos, + normalize(vWorldNormal), + uCameraAndTime.xyz, + surfaceToLight); + vec3 lighting = vAmbientLocalLit + + vDirectionalLit * directionalVisibility; + vec3 lit = rgb * min(lighting, vec3(1.0)); + + float flash = uFogParams.z; + lit += flash * vec3(0.6, 0.6, 0.75); + + lit = applyFog(lit, vWorldPos); + + fragColor = vec4(lit, 1.0); +} diff --git a/src/AcDream.App/Rendering/Shaders/terrain_atmospheric.vert b/src/AcDream.App/Rendering/Shaders/terrain_atmospheric.vert new file mode 100644 index 00000000..10fb57aa --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/terrain_atmospheric.vert @@ -0,0 +1,197 @@ +#version 460 core +#extension GL_ARB_bindless_texture : require + +#include "directional_shadow_common.glsl" + +// Phase N.5b: terrain shader on the modern bindless dispatcher. +// Math identical to terrain.vert (Phase 3c per-cell mesh + Phase G AdjustPlanes +// lighting). The only structural change is the version + bindless extension +// — sampler access in the fragment stage is unchanged at the GLSL level. + +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in uvec4 aPacked0; +layout(location = 3) in uvec4 aPacked1; +layout(location = 4) in uvec4 aPacked2; +layout(location = 5) in uvec4 aPacked3; + +// Campaign V slice V6f-1: uView/uProjection converged into the single +// uViewProjection that GpuPushConstants already carries, so terrain can be +// expressed in Vulkan GLSL at all — two loose mat4 uniforms are 128 bytes and +// cannot both fit the pinned 96-byte push block, and Vulkan GLSL has no default +// uniform block to hold them loose. The product is now formed on the CPU +// (camera.View * camera.Projection) instead of per vertex here; the two are the +// same transform, and System.Numerics' row-vector layout uploaded untransposed +// reads in GLSL as the transpose, so (View*Proj)^T == Proj^T * View^T is exactly +// the uProjection * uView this replaced. +uniform mat4 uViewProjection; + +struct Light { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting { + Light uLights[8]; + vec4 uCellAmbient; + vec4 uFogParams; + vec4 uFogColor; + vec4 uCameraAndTime; +}; + +// === Phase U.3: terrain screen-space clip gate (OutsideView region) =========== +// Terrain is a single global region (the OutsideView), so it needs one set of +// clip planes, not a per-instance slot table like the mesh shader. A std140 UBO +// at binding=2 carries it. The UBO binding namespace is distinct from the SSBO +// binding namespace, so this does NOT collide with the mesh shader's SSBO +// binding=2 — and within THIS shader binding=1 (SceneLighting) is the only other +// UBO, leaving binding=2 free. uTerrainClipCount == 0 (the U.3 default) ungates +// terrain entirely (the second loop sets all 8 distances to +1.0). Uploaded by +// ClipFrame.UploadShared each frame; TerrainModernRenderer binds it before draw. +// +// Campaign V slice V6i-2: ACDREAM_UBO_SET is what puts this in set 1 under the +// Vulkan dialect and expands to nothing under GL. Omitting it left the block at +// set 0 binding 2, which the storage layout declares as a STORAGE buffer — see +// plan §5.5.12 finding 2, measured on the committed SPIR-V rather than inferred. +// sky.vert declares the SAME block correctly and is the precedent. +layout(std140, ACDREAM_UBO_SET binding = 2) uniform TerrainClip { + int uTerrainClipCount; + vec4 uTerrainClipPlanes[8]; +}; + +// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal. +// Sized 8 to match GL_MAX_CLIP_DISTANCES >= 8. Host enables GL_CLIP_DISTANCE0..7 +// once at startup; unused planes are set to +1.0 below so they pass everything. +out gl_PerVertex { + vec4 gl_Position; + float gl_ClipDistance[8]; +}; + +out vec2 vBaseUV; +out vec3 vWorldNormal; +out vec3 vWorldPos; +out vec3 vAmbientLocalLit; +out vec3 vDirectionalLit; +out vec4 vOverlay0; +out vec4 vOverlay1; +out vec4 vOverlay2; +out vec4 vRoad0; +out vec4 vRoad1; +flat out float vBaseTexIdx; + +// Retail's N·L floor from FUN_00532440 lines 2119/2138/2157/2176 at +// chunk_00530000.c (AdjustPlanes). The decompile reads: +// if (fVar3 < DAT_00796344) fVar3 = DAT_00796344; +// applied to the clamped Lambert result BEFORE it's multiplied into +// dirColor. DAT_00796344's exact literal isn't pinned by the decompile +// but every other "floor" use in retail clamps negatives to zero (the +// physically-correct Lambert half-space). Our previous 0.08 was a +// defensive guess from early acdream days that made back-lit terrain +// visibly brighter than retail (user-observed 2026-04-24 "acdream +// warmer / less blue than retail"). Reverting to 0.0 matches retail +// per the decompile and lets ambient fill in the back side. +// Cross-ref: docs/research/2026-04-24-lambert-brightness-split.md. +const float MIN_FACTOR = 0.0; + +vec4 unpackOverlayLayer(uint texIdxU, uint alphaIdxU, uint rotIdx, vec2 baseUV) { + float texIdx = float(texIdxU); + float alphaIdx = float(alphaIdxU); + if (texIdx >= 254.0) texIdx = -1.0; + if (alphaIdx >= 254.0) alphaIdx = -1.0; + + vec2 rotatedUV = baseUV; + if (rotIdx == 1u) rotatedUV = vec2(1.0 - baseUV.y, baseUV.x); + else if (rotIdx == 2u) rotatedUV = vec2(1.0 - baseUV.x, 1.0 - baseUV.y); + else if (rotIdx == 3u) rotatedUV = vec2( baseUV.y, 1.0 - baseUV.x); + + return vec4(rotatedUV.x, rotatedUV.y, texIdx, alphaIdx); +} + +void main() { + // Unpack rotation fields from aPacked3. Bit layout (data3): + // .x (byte 0): bits 0-1 rotBase (unused), 2-3 rotOvl0, 4-5 rotOvl1, 6-7 rotOvl2 + // .y (byte 1): bits 0-1 rotRd0 (= data3 bit 8-9), + // bits 2-3 rotRd1 (= data3 bit 10-11), + // bit 4 splitDir (= data3 bit 12) + uint rotOvl0 = (aPacked3.x >> 2u) & 3u; + uint rotOvl1 = (aPacked3.x >> 4u) & 3u; + uint rotOvl2 = (aPacked3.x >> 6u) & 3u; + uint rotRd0 = aPacked3.y & 3u; + uint rotRd1 = (aPacked3.y >> 2u) & 3u; + uint splitDir= (aPacked3.y >> 4u) & 1u; + + // Derive which of the 4 cell corners this vertex represents from + // gl_VertexID % 6. The CPU-side LandblockMesh emits vertices in a + // specific order for each split direction; the tables below must stay + // in lockstep with LandblockMesh.Build's SWtoNE/SEtoNW branches. + // 2026-04-21 fix: geometry re-derived to match ACE's ConstructPolygons + // convention. SWtoNE (cut BL→TR, y=x diagonal) now maps to the {BL,BR,TR} + // + {BL,TR,TL} triangle pair; SEtoNW (cut BR→TL, x+y=1 diagonal) maps to + // {BL,BR,TL} + {BR,TR,TL}. + int vIdx = gl_VertexID % 6; + int corner = 0; + if (splitDir == 0u) { + // SWtoNE order: BL, BR, TR, BL, TR, TL → corners 0, 1, 2, 0, 2, 3 + if (vIdx == 0) corner = 0; + else if (vIdx == 1) corner = 1; + else if (vIdx == 2) corner = 2; + else if (vIdx == 3) corner = 0; + else if (vIdx == 4) corner = 2; + else corner = 3; + } else { + // SEtoNW order: BL, BR, TL, BR, TR, TL → corners 0, 1, 3, 1, 2, 3 + if (vIdx == 0) corner = 0; + else if (vIdx == 1) corner = 1; + else if (vIdx == 2) corner = 3; + else if (vIdx == 3) corner = 1; + else if (vIdx == 4) corner = 2; + else corner = 3; + } + + vec2 baseUV; + if (corner == 0) baseUV = vec2(0.0, 1.0); + else if (corner == 1) baseUV = vec2(1.0, 1.0); + else if (corner == 2) baseUV = vec2(1.0, 0.0); + else baseUV = vec2(0.0, 0.0); + + vBaseUV = baseUV; + vWorldPos = aPos; + vWorldNormal = normalize(aNormal); + + // Retail AdjustPlanes bake (terrain.vert:124-134 — identical math). + vec3 surfaceToLight = normalize(uShadowLightDirectionAndSource.xyz); + vec3 sunCol = uLights[0].colorAndIntensity.xyz * uLights[0].colorAndIntensity.w; + float L = max(dot(vWorldNormal, surfaceToLight), MIN_FACTOR); + // Preserve retail's authored lighting values, but keep the outdoor + // directional term separate so the receiver shadows no ambient/local light. + vAmbientLocalLit = uCellAmbient.xyz; + vDirectionalLit = sunCol * L; + + float baseTex = float(aPacked0.x); + if (baseTex >= 254.0) baseTex = -1.0; + vBaseTexIdx = baseTex; + + vOverlay0 = unpackOverlayLayer(aPacked0.z, aPacked0.w, rotOvl0, baseUV); + vOverlay1 = unpackOverlayLayer(aPacked1.x, aPacked1.y, rotOvl1, baseUV); + vOverlay2 = unpackOverlayLayer(aPacked1.z, aPacked1.w, rotOvl2, baseUV); + vRoad0 = unpackOverlayLayer(aPacked2.x, aPacked2.y, rotRd0, baseUV); + vRoad1 = unpackOverlayLayer(aPacked2.z, aPacked2.w, rotRd1, baseUV); + + // Retail zFightTerrainAdjust (acclient_2013_pseudo_c.txt:1120769 = 0.00999999978, + // applied per terrain vertex inside ACRender::landPolysDraw at line 702254, + // address 006b6402). Render terrain 1 cm below its physical Z so coplanar + // building floors win the depth test. Physics path is unaffected — it reads + // the un-nudged heightmap via TerrainSurface.SampleZ. + // Closes issue #100; supersedes the hiddenTerrainCells cell-collapse hack. + vec3 terrainPos = vec3(aPos.xy, aPos.z - 0.01); + gl_Position = uViewProjection * vec4(terrainPos, 1.0); + + // Phase U.3: terrain clip gate against the single OutsideView region. With + // uTerrainClipCount == 0 (U.3 default) the first loop is skipped and the + // second sets all 8 distances to +1.0 ⇒ no clipping ⇒ identical terrain. + for (int i = 0; i < uTerrainClipCount; ++i) + gl_ClipDistance[i] = dot(uTerrainClipPlanes[i], gl_Position); + for (int i = uTerrainClipCount; i < 8; ++i) + gl_ClipDistance[i] = 1.0; +} diff --git a/src/AcDream.App/Rendering/TerrainAtlas.cs b/src/AcDream.App/Rendering/TerrainAtlas.cs index 64a73db6..fcdc7ab6 100644 --- a/src/AcDream.App/Rendering/TerrainAtlas.cs +++ b/src/AcDream.App/Rendering/TerrainAtlas.cs @@ -28,6 +28,30 @@ namespace AcDream.App.Rendering; ///

public sealed class TerrainAtlas : IDisposable { + /// + /// Retail's category-scoped detail surface, resolved from one + /// TexMerge.TerrainDesc entry. The texture-table slot samples a + /// one-layer 2-D array so it uses the same backend-neutral table contract + /// as every other world texture. + /// + internal readonly record struct RetailDetailTextureBinding( + GpuTextureSlot TextureSlot, + float Tiling, + uint SurfaceTextureId, + uint RenderSurfaceId, + int Width, + int Height) + { + public bool IsAvailable => + TextureSlot.IsAssigned + && SurfaceTextureId != 0 + && RenderSurfaceId != 0; + } + + private sealed record DetailTextureResource( + IGpuTexture Texture, + RetailDetailTextureBinding Binding); + public IReadOnlyDictionary TerrainTypeToLayer { get; } public int LayerCount { get; } /// @@ -54,6 +78,18 @@ public sealed class TerrainAtlas : IDisposable /// RCode for each RoadMap, parallel to . public IReadOnlyList RoadAlphaRCodes { get; } + /// + /// Retail detail category 1. DrawBuilding is the only live object + /// path that consumes it; ordinary scenery and terrain do not. + /// + internal RetailDetailTextureBinding BuildingDetailTexture { get; } + + /// + /// Retail detail category 2. DrawEnvCell consumes it for interior + /// cell shells. + /// + internal RetailDetailTextureBinding EnvironmentDetailTexture { get; } + /// /// Campaign V slice V6i-2: both arrays are s the /// device created, and both slots were registered at build time, so @@ -72,6 +108,8 @@ public sealed class TerrainAtlas : IDisposable public IGpuTexture Alpha { get; } = alpha; public IGpuSampler AlphaSampler { get; } = alphaSampler; public IGpuSampler? TerrainSampler { get; set; } + public IGpuTexture? BuildingDetailTexture { get; set; } + public IGpuTexture? EnvironmentDetailTexture { get; set; } public GpuTextureSlot TerrainSlot { get; set; } = GpuTextureSlot.Unassigned; public GpuTextureSlot AlphaSlot { get; set; } = GpuTextureSlot.Unassigned; } @@ -106,7 +144,9 @@ public sealed class TerrainAtlas : IDisposable IReadOnlyList roadLayers, IReadOnlyList cornerTCodes, IReadOnlyList sideTCodes, - IReadOnlyList roadRCodes) + IReadOnlyList roadRCodes, + DetailTextureResource? buildingDetail, + DetailTextureResource? environmentDetail) { _rhi = new RhiArrays(device, terrain, alpha, alphaSampler); TerrainTypeToLayer = map; @@ -119,6 +159,10 @@ public sealed class TerrainAtlas : IDisposable CornerAlphaTCodes = cornerTCodes; SideAlphaTCodes = sideTCodes; RoadAlphaRCodes = roadRCodes; + _rhi.BuildingDetailTexture = buildingDetail?.Texture; + _rhi.EnvironmentDetailTexture = environmentDetail?.Texture; + BuildingDetailTexture = buildingDetail?.Binding ?? default; + EnvironmentDetailTexture = environmentDetail?.Binding ?? default; _rhi.AlphaSlot = device.RegisterTexture(alpha, alphaSampler); ApplyAnisotropic(RetailMaxAnisotropy); } @@ -323,6 +367,9 @@ public sealed class TerrainAtlas : IDisposable int mipLevels = Wb.RhiWorldTextureArray.MipLevelsFor(maxW, maxH); IGpuTexture? terrainTexture = null; IGpuTexture? alphaTexture = null; + IGpuSampler? detailSampler = null; + DetailTextureResource? buildingDetail = null; + DetailTextureResource? environmentDetail = null; try { terrainTexture = device.CreateTexture(new GpuTextureDescription( @@ -378,6 +425,31 @@ public sealed class TerrainAtlas : IDisposable MipFilter = GpuMipFilter.None, }); + // Retail LScape::SetDetailTexturing category indices: + // 1 = building, 2 = environment/EnvCell. + // ChangeRegion and the only reachable SmartBox caller keep + // landscape (0) and object (3) disabled, so do not create or expose + // those categories here. + detailSampler = device.CreateSampler(GpuSamplerDescription.WorldRepeat); + if (terrainDesc is { Count: > 1 }) + { + buildingDetail = TryCreateDetailTexture( + device, + dats, + detailSampler, + terrainDesc[1], + "building"); + } + if (terrainDesc is { Count: > 2 }) + { + environmentDetail = TryCreateDetailTexture( + device, + dats, + detailSampler, + terrainDesc[2], + "environment"); + } + Console.WriteLine( $"TerrainAtlas: {layerCount} terrain layers at {maxW}x{maxH} ({mipLevels} mip levels)"); Console.WriteLine( @@ -399,16 +471,117 @@ public sealed class TerrainAtlas : IDisposable alpha.RoadLayers, alpha.CornerTCodes, alpha.SideTCodes, - alpha.RoadRCodes); + alpha.RoadRCodes, + buildingDetail, + environmentDetail); } catch { + DisposeDetailTextureResource(device, environmentDetail); + DisposeDetailTextureResource(device, buildingDetail); alphaTexture?.Dispose(); terrainTexture?.Dispose(); throw; } } + private static DetailTextureResource? TryCreateDetailTexture( + IGpuDevice device, + IDatReaderWriter dats, + IGpuSampler sampler, + DatReaderWriter.Types.TMTerrainDesc terrain, + string categoryName) + { + uint surfaceTextureId = (uint)terrain.TerrainTex.DetailTextureId; + if (surfaceTextureId == 0) + return null; + + SurfaceTexture? surfaceTexture = dats.Get(surfaceTextureId); + if (surfaceTexture is null || surfaceTexture.Textures.Count == 0) + { + Console.WriteLine( + $"WARN: retail {categoryName} detail SurfaceTexture " + + $"0x{surfaceTextureId:X8} missing"); + return null; + } + + uint renderSurfaceId = (uint)surfaceTexture.Textures[0]; + RenderSurface? renderSurface = dats.Get(renderSurfaceId); + if (renderSurface is null) + { + Console.WriteLine( + $"WARN: retail {categoryName} detail RenderSurface " + + $"0x{renderSurfaceId:X8} missing"); + return null; + } + + Palette? palette = renderSurface.DefaultPaletteId != 0 + ? dats.Get(renderSurface.DefaultPaletteId) + : null; + DecodedTexture decoded = SurfaceDecoder.DecodeRenderSurface( + renderSurface, + palette); + if (ReferenceEquals(decoded, DecodedTexture.Magenta)) + { + Console.WriteLine( + $"WARN: retail {categoryName} detail RenderSurface " + + $"0x{renderSurfaceId:X8} failed to decode"); + return null; + } + + int mipLevels = Wb.RhiWorldTextureArray.MipLevelsFor( + decoded.Width, + decoded.Height); + IGpuTexture? texture = null; + GpuTextureSlot slot = GpuTextureSlot.Unassigned; + try + { + texture = device.CreateTexture(new GpuTextureDescription( + $"retail-detail-{categoryName}", + GpuTextureKind.Texture2DArray, + GpuTextureFormat.Rgba8Unorm, + decoded.Width, + decoded.Height, + LayerCount: 1, + MipLevelCount: mipLevels)); + texture.Upload(0, 0, decoded.Rgba8); + texture.GenerateMipChain(); + slot = device.RegisterTexture(texture, sampler); + var binding = new RetailDetailTextureBinding( + slot, + terrain.TerrainTex.DetailTexTiling, + surfaceTextureId, + renderSurfaceId, + decoded.Width, + decoded.Height); + Console.WriteLine( + $"Retail detail {categoryName}: SurfaceTexture " + + $"0x{surfaceTextureId:X8} -> RenderSurface " + + $"0x{renderSurfaceId:X8}, {decoded.Width}x{decoded.Height}, " + + $"tiling={binding.Tiling}"); + return new DetailTextureResource(texture, binding); + } + catch + { + if (slot.IsAssigned) + device.ReleaseTextureSlot(slot); + texture?.Dispose(); + throw; + } + } + + private static void DisposeDetailTextureResource( + IGpuDevice device, + DetailTextureResource? resource) + { + if (resource is null) + return; + + if (resource.Binding.IsAvailable) + device.ReleaseTextureSlot(resource.Binding.TextureSlot); + resource.Texture.Dispose(); + } + private static DecodedTexture WhitePixel() => new([0xFF, 0xFF, 0xFF, 0xFF], 1, 1); @@ -495,13 +668,20 @@ public sealed class TerrainAtlas : IDisposable Console.WriteLine($"TerrainAtlas: anisotropic updated to {level}x"); } + private bool _disposed; + public void Dispose() { - // Slice V4t's teardown rule: the device dies with its callers, so the - // table entries are not released here — deferring through a - // possibly-disposed retirement queue would turn a clean shutdown into a - // throw. The images themselves route through the device's retirement - // queue, which is what IGpuTexture.Dispose does. + if (_disposed) + return; + _disposed = true; + + // Slice V4t's teardown rule remains load-bearing: GameWindowLifetime + // disposes the device before this atlas, so normal teardown must not + // call back into its texture table. Build/registration failure paths + // above still release detail slots while the device is known alive. + _rhi.EnvironmentDetailTexture?.Dispose(); + _rhi.BuildingDetailTexture?.Dispose(); _rhi.Alpha.Dispose(); _rhi.Terrain.Dispose(); } diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadowReceivers.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadowReceivers.cs new file mode 100644 index 00000000..8ec70656 --- /dev/null +++ b/src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadowReceivers.cs @@ -0,0 +1,62 @@ +using AcDream.App.Rendering.Gpu; + +namespace AcDream.App.Rendering; + +public sealed partial class TerrainModernRenderer +{ + internal sealed class DirectionalShadowReceiverPipelineState( + IDirectionalShadowReceiverSource source, + IGpuPipeline pipeline) : IDisposable + { + internal IDirectionalShadowReceiverSource Source { get; } = source; + + internal IGpuPipeline Pipeline { get; } = pipeline; + + public void Dispose() => Pipeline.Dispose(); + } + + /// + /// Constructs the complete opt-in receiver state without publishing it. + /// The controller couples this candidate with the pack runtime and world + /// receiver set at one stable-boundary commit. + /// + internal DirectionalShadowReceiverPipelineState? PrepareDirectionalShadowReceiver( + IDirectionalShadowReceiverSource? source, + int sampleCount) + { + if (source is null) + return null; + IGpuDevice device = _device + ?? throw new InvalidOperationException("Directional receivers require the modern RHI device."); + if (_scope is null || sampleCount != _scope.SampleCount) + throw new InvalidOperationException("Receiver and world-pass sample counts must match."); + + return new DirectionalShadowReceiverPipelineState( + source, + device.CreatePipeline( + new GpuPipelineDescription + { + Name = "terrain-atmospheric", + Shaders = source.PipelineShaders.TerrainReceiver, + VertexLayout = TerrainVertexLayout, + Topology = GpuPrimitiveTopology.TriangleList, + Blend = GpuBlendMode.None, + Depth = new GpuDepthState(true, true, GpuCompareOp.Less), + Cull = GpuCullMode.Back, + FrontFace = GpuFrontFace.CounterClockwise, + AlphaToCoverage = false, + ColorWrite = true, + UsesRenderPackShaderAbi = true, + SampleCount = sampleCount, + })); + } + + /// Assignment-only publication; returned state retires after the coupled swap. + internal DirectionalShadowReceiverPipelineState? SwapDirectionalShadowReceiver( + DirectionalShadowReceiverPipelineState? candidate) + { + DirectionalShadowReceiverPipelineState? prior = _directionalShadowReceiver; + _directionalShadowReceiver = candidate; + return prior; + } +} diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadows.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadows.cs new file mode 100644 index 00000000..d8c972ec --- /dev/null +++ b/src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadows.cs @@ -0,0 +1,146 @@ +using System.Runtime.CompilerServices; +using AcDream.App.Rendering.Wb; +using AcDream.App.Rendering.Gpu; + +namespace AcDream.App.Rendering; + +internal readonly record struct DirectionalShadowTerrainRange( + uint FirstIndex, + int IndexCount); + +internal readonly record struct DirectionalShadowTerrainGeometry( + IGpuBuffer VertexBuffer, + IGpuBuffer IndexBuffer); + +/// +/// The complete resident terrain arena expressed once as indirect commands. +/// It deliberately has no camera, PView, portal, or cascade input. +/// +internal sealed class DirectionalShadowTerrainPreparedDraws +{ + private DrawElementsIndirectCommand[] _commands = []; + private int _count; + private bool _building; + + public long SourceFrameSequence { get; private set; } + + public ulong BuildSequence { get; private set; } + + public ReadOnlySpan Commands => + _commands.AsSpan(0, _count); + + public long RetainedScratchBytes => + checked((long)_commands.Length + * Unsafe.SizeOf()); + + public bool TryBegin(long frameSequence, int estimatedCommands) + { + if (_building) + throw new InvalidOperationException( + "A terrain shadow draw build is already active."); + if (frameSequence <= 0) + throw new ArgumentOutOfRangeException(nameof(frameSequence)); + ArgumentOutOfRangeException.ThrowIfNegative(estimatedCommands); + if (SourceFrameSequence == frameSequence) + return false; + + EnsureCapacity(estimatedCommands); + _count = 0; + _building = true; + return true; + } + + public void Add(in DirectionalShadowTerrainRange range) + { + if (!_building) + throw new InvalidOperationException( + "Begin a terrain shadow draw build before adding ranges."); + if (range.IndexCount <= 0) + throw new ArgumentOutOfRangeException(nameof(range)); + EnsureCapacity(checked(_count + 1)); + _commands[_count++] = new DrawElementsIndirectCommand + { + Count = checked((uint)range.IndexCount), + InstanceCount = 1, + FirstIndex = range.FirstIndex, + BaseVertex = 0, + BaseInstance = 0, + }; + } + + public void Complete(long frameSequence) + { + if (!_building) + throw new InvalidOperationException( + "No terrain shadow draw build is active."); + if (frameSequence <= 0) + throw new ArgumentOutOfRangeException(nameof(frameSequence)); + SourceFrameSequence = frameSequence; + BuildSequence = checked(BuildSequence + 1); + _building = false; + } + + public void Abort() + { + _count = 0; + _building = false; + } + + private void EnsureCapacity(int required) + { + if (_commands.Length >= required) + return; + int capacity = _commands.Length == 0 ? 16 : _commands.Length; + while (capacity < required) + capacity = checked(capacity * 2); + Array.Resize(ref _commands, capacity); + } +} + +public sealed partial class TerrainModernRenderer +{ + private readonly DirectionalShadowTerrainPreparedDraws + _directionalShadowTerrainDraws = new(); + private long _directionalShadowFrameSequence; + + internal DirectionalShadowTerrainGeometry GetDirectionalShadowGeometry() => new( + _vertexStore ?? throw new InvalidOperationException("Terrain has no vertex store."), + _indexStore ?? throw new InvalidOperationException("Terrain has no index store.")); + + /// + /// Builds one all-resident indirect list for the current frame. Repeated + /// calls by individual cascades return the same retained product. + /// + internal DirectionalShadowTerrainPreparedDraws + PrepareDirectionalShadowDraws() + { + if (!_directionalShadowTerrainDraws.TryBegin( + _directionalShadowFrameSequence, + _alloc.LoadedCount)) + { + return _directionalShadowTerrainDraws; + } + + try + { + for (int slot = 0; slot < _slots.Length; slot++) + { + SlotData? data = _slots[slot]; + if (data is null) + continue; + var range = new DirectionalShadowTerrainRange( + data.FirstIndex, + data.IndexCount); + _directionalShadowTerrainDraws.Add(in range); + } + _directionalShadowTerrainDraws.Complete( + _directionalShadowFrameSequence); + return _directionalShadowTerrainDraws; + } + catch + { + _directionalShadowTerrainDraws.Abort(); + throw; + } + } +} diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.Rhi.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.Rhi.cs index 9840788f..b688cb4f 100644 --- a/src/AcDream.App/Rendering/TerrainModernRenderer.Rhi.cs +++ b/src/AcDream.App/Rendering/TerrainModernRenderer.Rhi.cs @@ -51,6 +51,7 @@ public sealed unsafe partial class TerrainModernRenderer private readonly ICurrentGpuFrameSource? _frames; private readonly IWorldPassScope? _scope; private IGpuPipeline? _pipeline; + private DirectionalShadowReceiverPipelineState? _directionalShadowReceiver; private IGpuBuffer? _vertexStore; private IGpuBuffer? _indexStore; private IGpuBuffer? _tilingBuffer; @@ -232,13 +233,36 @@ public sealed unsafe partial class TerrainModernRenderer ParamB = 0f, }; - encoder.BindPipeline(_pipeline!); + IGpuPipeline pipeline = _pipeline!; + DirectionalShadowFrameBinding shadowBinding = default; + DirectionalShadowReceiverPipelineState? receiver = + _directionalShadowReceiver; + IDirectionalShadowReceiverSource? receiverSource = receiver?.Source; + bool bindingValid = receiverSource is not null + && receiverSource.TryGetCurrentFrameBinding(frame, out shadowBinding); + if (DirectionalShadowReceiverPolicy.ShouldSelectReceiverPipeline( + encoder.Pass.Name, + receiverSource is not null, + bindingValid)) + { + pipeline = receiver!.Pipeline; + } + + encoder.BindPipeline(pipeline); encoder.SetPushConstants(in pushConstants); encoder.BindVertexBuffer(0, RequireVertexStore(), 0); encoder.BindIndexBuffer(RequireIndexStore(), 0, GpuIndexType.UInt32); BindTilingTable(encoder); WorldFrameSectionBinding.BindSceneLighting(encoder, scope.Sections, frame); WorldFrameSectionBinding.BindTerrainClip(encoder, scope.Sections, frame); + if (shadowBinding.Enabled && shadowBinding.Buffer is not null) + { + encoder.BindUniformBuffer( + GpuBindingModel.UniformDirectionalShadow, + shadowBinding.Buffer, + shadowBinding.OffsetBytes, + shadowBinding.SizeBytes); + } GpuRingAllocation commands = frame.AllocateRing( drawCount * sizeof(DrawElementsIndirectCommand), @@ -315,6 +339,8 @@ public sealed unsafe partial class TerrainModernRenderer private void DisposeRhi() { + _directionalShadowReceiver?.Dispose(); + _directionalShadowReceiver = null; _pipeline?.Dispose(); _pipeline = null; _tilingBuffer?.Dispose(); diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.cs index a9a91cfe..1790655f 100644 --- a/src/AcDream.App/Rendering/TerrainModernRenderer.cs +++ b/src/AcDream.App/Rendering/TerrainModernRenderer.cs @@ -90,6 +90,10 @@ public sealed partial class TerrainModernRenderer : IDisposable public void BeginFrame(int frameSlot) { ArgumentOutOfRangeException.ThrowIfNegative(frameSlot); + if (_directionalShadowFrameSequence == long.MaxValue) + throw new InvalidOperationException( + "Directional-shadow terrain frame identity was exhausted."); + _directionalShadowFrameSequence++; _retirementLedger.RetryPendingPublications(); _dynamicFrameSlot = frameSlot; _dynamicFrameStarted = true; diff --git a/src/AcDream.App/Rendering/VolumetricShaftQuality.cs b/src/AcDream.App/Rendering/VolumetricShaftQuality.cs new file mode 100644 index 00000000..717d59bd --- /dev/null +++ b/src/AcDream.App/Rendering/VolumetricShaftQuality.cs @@ -0,0 +1,116 @@ +using System.Numerics; +using AcDream.Core.World; + +namespace AcDream.App.Rendering; + +internal readonly record struct VolumetricShaftQuality( + DirectionalShadowPreset Preset, + bool EnabledByDefault, + float ResolutionScale, + int RayMarchSteps, + double IncrementalGpuP50BudgetMilliseconds, + double IncrementalGpuP99BudgetMilliseconds) +{ + internal static VolumetricShaftQuality For(DirectionalShadowPreset preset) => + preset switch + { + DirectionalShadowPreset.Low => new( + preset, + EnabledByDefault: false, + ResolutionScale: 0.25f, + RayMarchSteps: 24, + IncrementalGpuP50BudgetMilliseconds: 0.15, + IncrementalGpuP99BudgetMilliseconds: 0.30), + DirectionalShadowPreset.Medium => new( + preset, + EnabledByDefault: true, + ResolutionScale: 0.25f, + RayMarchSteps: 40, + IncrementalGpuP50BudgetMilliseconds: 0.25, + IncrementalGpuP99BudgetMilliseconds: 0.40), + DirectionalShadowPreset.High => new( + preset, + EnabledByDefault: true, + ResolutionScale: 0.50f, + RayMarchSteps: 56, + IncrementalGpuP50BudgetMilliseconds: 0.40, + IncrementalGpuP99BudgetMilliseconds: 0.65), + _ => throw new ArgumentOutOfRangeException(nameof(preset), preset, null), + }; +} + +internal readonly record struct VolumetricShaftFrameParameters( + bool Enabled, + float ResolutionScale, + int RayMarchSteps, + float Density, + float Strength, + Vector3 LinearSunColor) +{ + internal static VolumetricShaftFrameParameters Disabled => + new(false, 0f, 0, 0f, 0f, Vector3.Zero); +} + +/// +/// Maps authored AC sun/weather inputs and the already-gated directional map +/// to pack-local volumetric parameters. It owns no clock or weather state and +/// cannot enable shafts without the current frame's valid shadow map. +/// +internal static class VolumetricShaftPolicy +{ + internal static VolumetricShaftFrameParameters Evaluate( + in VolumetricShaftQuality quality, + bool userEnabled, + in DirectionalShadowEnvironmentState shadow, + WeatherKind weather, + Vector3 authoredSunColor, + float authoredSunBrightness) + { + if (!userEnabled + || !shadow.ShouldRender + || shadow.SourceKind is not Packs.AuthoredCelestialShadowSourceKind.Sun + || !float.IsFinite(authoredSunBrightness) + || authoredSunBrightness <= 0f) + return VolumetricShaftFrameParameters.Disabled; + + float weatherMultiplier = weather switch + { + WeatherKind.Clear => 1f, + WeatherKind.Overcast => 0.18f, + WeatherKind.Rain => 0.10f, + WeatherKind.Snow => 0.16f, + WeatherKind.Storm => 0.06f, + _ => 0f, + }; + if (weatherMultiplier <= 0f) + return VolumetricShaftFrameParameters.Disabled; + + // Peak at a raking but valid sun. The shadow gate already fades the + // first degrees above the horizon; this term then rolls shafts away + // toward noon without inventing a time-of-day schedule. + float elevation = Math.Clamp(shadow.LightElevationSin, 0f, 1f); + float noonRollOff = 1f - SmoothStep(0.18f, 0.82f, elevation); + float strength = Math.Clamp( + shadow.Strength * weatherMultiplier * noonRollOff, + 0f, + 1f); + if (strength <= 1e-4f) + return VolumetricShaftFrameParameters.Disabled; + + Vector3 color = Vector3.Max(Vector3.Zero, authoredSunColor) + * Math.Clamp(authoredSunBrightness, 0f, 8f); + return new VolumetricShaftFrameParameters( + Enabled: true, + quality.ResolutionScale, + quality.RayMarchSteps, + Density: 0.035f * strength, + Strength: strength, + LinearSunColor: color); + } + + private static float SmoothStep(float minimum, float maximum, float value) + { + float t = Math.Clamp((value - minimum) / (maximum - minimum), 0f, 1f); + return t * t * (3f - (2f * t)); + } +} diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs index bafea1a5..cb43449b 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs @@ -16,8 +16,8 @@ namespace AcDream.App.Rendering.Wb; /// Two structural differences from V4c. It records into the pass /// VulkanWorldScenePhase opened rather than opening /// "envcell-shells" of its own, because the frame's one backbuffer pass -/// resolves. And there is no binding-9 texture table: V4t moved the slot onto -/// the device, and on Vulkan that table is set 2, which the encoder binds. +/// resolves. And the global texture table is set 2 rather than a storage +/// buffer: storage binding 9 is now #226's per-instance detail category. /// public sealed unsafe partial class EnvCellRenderer { @@ -27,10 +27,14 @@ public sealed unsafe partial class EnvCellRenderer private IGpuPipeline? _opaquePipeline; private IGpuPipeline? _alphaPipeline; private IGpuPipeline? _additivePipeline; + private IGpuPipeline? _detailPipeline; + private IGpuPipeline? _transparentDetailPipeline; + private readonly TerrainAtlas.RetailDetailTextureBinding _environmentDetail; + private readonly Func _buildingDetailEnabled; /// /// The RHI arm's constructor. It also completes Initialize's job: the - /// three pipelines ARE this renderer's program, so there is no second step + /// five pipelines ARE this renderer's program, so there is no second step /// and no Shader to hand in. /// internal EnvCellRenderer( @@ -38,13 +42,17 @@ public sealed unsafe partial class EnvCellRenderer ICurrentGpuFrameSource frames, IWorldPassScope scope, ObjectMeshManager meshManager, - WbFrustum frustum) + WbFrustum frustum, + TerrainAtlas.RetailDetailTextureBinding environmentDetail = default, + Func? buildingDetailEnabled = null) { _device = device ?? throw new ArgumentNullException(nameof(device)); _frames = frames ?? throw new ArgumentNullException(nameof(frames)); _scope = scope ?? throw new ArgumentNullException(nameof(scope)); _meshManager = meshManager ?? throw new ArgumentNullException(nameof(meshManager)); _frustum = frustum ?? throw new ArgumentNullException(nameof(frustum)); + _environmentDetail = environmentDetail; + _buildingDetailEnabled = buildingDetailEnabled ?? DisableDetailTextures; _opaquePipeline = CreateShellPipeline( device, "envcell-opaque", GpuBlendMode.None, depthWrite: true, scope.SampleCount); @@ -52,9 +60,29 @@ public sealed unsafe partial class EnvCellRenderer device, "envcell-alpha", GpuBlendMode.StraightAlpha, depthWrite: false, scope.SampleCount); _additivePipeline = CreateShellPipeline( device, "envcell-additive", GpuBlendMode.Additive, depthWrite: false, scope.SampleCount); + _detailPipeline = CreateShellPipeline( + device, + "envcell-retail-detail", + GpuBlendMode.RetailDetail, + depthWrite: true, + scope.SampleCount, + shaderName: "mesh_detail", + depthCompare: RetailDetailTextureContract.DetailDepthCompare( + transparent: false)); + _transparentDetailPipeline = CreateShellPipeline( + device, + "envcell-retail-detail-alpha", + GpuBlendMode.RetailDetail, + depthWrite: false, + scope.SampleCount, + shaderName: "mesh_detail", + depthCompare: RetailDetailTextureContract.DetailDepthCompare( + transparent: true)); _initialized = true; } + private static bool DisableDetailTextures() => false; + /// /// One pipeline per blend state the shell pass uses. Everything else is /// shared: mesh_modern, the 32-byte world-mesh vertex, triangle lists, @@ -70,15 +98,17 @@ public sealed unsafe partial class EnvCellRenderer string name, GpuBlendMode blend, bool depthWrite, - int sampleCount) => + int sampleCount, + string shaderName = "mesh_modern", + GpuCompareOp depthCompare = GpuCompareOp.Less) => device.CreatePipeline(new GpuPipelineDescription { Name = name, - Shaders = new GpuShaderSet("mesh_modern"), + Shaders = new GpuShaderSet(shaderName), VertexLayout = GpuVertexLayout.WorldMesh, Topology = GpuPrimitiveTopology.TriangleList, Blend = blend, - Depth = new GpuDepthState(Test: true, Write: depthWrite, GpuCompareOp.Less), + Depth = new GpuDepthState(Test: true, Write: depthWrite, depthCompare), Cull = GpuCullMode.Back, FrontFace = GpuFrontFace.Clockwise, AlphaToCoverage = false, @@ -196,6 +226,7 @@ public sealed unsafe partial class EnvCellRenderer BindRingSection( encoder, frame, GpuBindingModel.StorageInstanceLightSets, _lightSetData.AsSpan(0, uniqueInstanceCount * lightStride)); + BindEnvironmentDetailCategory(encoder, frame); // The frame-global sections, bound after this renderer's own binds // because those binds are what select the descriptor scope. @@ -210,6 +241,9 @@ public sealed unsafe partial class EnvCellRenderer MemoryMarshal.AsBytes(_commands.AsSpan(0, totalDraws)).CopyTo(commands.Data); IGpuBuffer commandBuffer = commands.Buffer; uint commandBase = commands.OffsetBytes; + bool detailEnabled = RetailDetailTextureContract.ShouldRender( + _buildingDetailEnabled(), + _environmentDetail); for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++) { @@ -222,13 +256,16 @@ public sealed unsafe partial class EnvCellRenderer if (cullMode == CullMode.Landblock) cullMode = CullMode.None; bool isAdditive = groupIndex >= 4; + IGpuPipeline rangeBasePipeline = isAdditive + ? _additivePipeline! + : _alphaPipeline!; if (renderPass == WbRenderPass.Transparent) { // Blend state is the pipeline's; switching variants mid-pass has // to re-establish the mesh, which is vertex-array state. BindPipelineWithMesh( encoder, - isAdditive ? _additivePipeline! : _alphaPipeline!, + rangeBasePipeline, mesh); } @@ -241,12 +278,85 @@ public sealed unsafe partial class EnvCellRenderer : (int)renderPass; pushConstants.DrawIdOffset = drawRange.FirstCommand; encoder.SetPushConstants(in pushConstants); + + // Retail DrawMesh's two-pass fallback redraws each transparent + // RenderMeshSubset immediately, before the next delayed-alpha + // subset. Preserve that base/detail adjacency so another shell or + // particle cannot be composited between the two contributions. + if (renderPass == WbRenderPass.Transparent && detailEnabled) + { + int rangeEnd = drawRange.FirstCommand + drawRange.CommandCount; + for (int command = drawRange.FirstCommand; command < rangeEnd; command++) + { + BindPipelineWithMesh(encoder, rangeBasePipeline, mesh); + SetCullMode(encoder, cullMode); + pushConstants.RenderPass = isAdditive + ? (int)renderPass | 0x100 + : (int)renderPass; + pushConstants.DrawIdOffset = command; + pushConstants.TextureIndexA = 0; + pushConstants.ParamA = 0f; + pushConstants.ParamB = 0f; + encoder.SetPushConstants(in pushConstants); + encoder.MultiDrawIndexedIndirect( + commandBuffer, + commandBase + (uint)(command * sizeof(DrawElementsIndirectCommand)), + 1, + (uint)sizeof(DrawElementsIndirectCommand)); + + BindPipelineWithMesh(encoder, _transparentDetailPipeline!, mesh); + SetCullMode(encoder, cullMode); + pushConstants.DrawIdOffset = command; + pushConstants.TextureIndexA = _environmentDetail.TextureSlot.Index; + pushConstants.ParamA = _environmentDetail.Tiling; + pushConstants.ParamB = 0f; + encoder.SetPushConstants(in pushConstants); + encoder.MultiDrawIndexedIndirect( + commandBuffer, + commandBase + (uint)(command * sizeof(DrawElementsIndirectCommand)), + 1, + (uint)sizeof(DrawElementsIndirectCommand)); + } + continue; + } + encoder.MultiDrawIndexedIndirect( commandBuffer, commandBase + (uint)(drawRange.FirstCommand * sizeof(DrawElementsIndirectCommand)), (uint)drawRange.CommandCount, (uint)sizeof(DrawElementsIndirectCommand)); } + + // Retail DrawEnvCell category (2). Replay the already-filtered opaque + // shell commands, including ClipMap built-mesh subsets, and apply the + // 10-50 m positive-view-depth fade. The existing + // "Building Detail Textures" option gates both this and buildings, + // matching LScape::ChangeRegion. + if (renderPass == WbRenderPass.Opaque + && detailEnabled) + { + BindPipelineWithMesh(encoder, _detailPipeline!, mesh); + pushConstants.RenderPass = 0; + pushConstants.TextureIndexA = _environmentDetail.TextureSlot.Index; + pushConstants.ParamA = _environmentDetail.Tiling; + pushConstants.ParamB = 0f; + + for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++) + { + MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex]; + var cullMode = (CullMode)(drawRange.GroupIndex % 4); + if (cullMode == CullMode.Landblock) + cullMode = CullMode.None; + SetCullMode(encoder, cullMode); + pushConstants.DrawIdOffset = drawRange.FirstCommand; + encoder.SetPushConstants(in pushConstants); + encoder.MultiDrawIndexedIndirect( + commandBuffer, + commandBase + (uint)(drawRange.FirstCommand * sizeof(DrawElementsIndirectCommand)), + (uint)drawRange.CommandCount, + (uint)sizeof(DrawElementsIndirectCommand)); + } + } } private void BindPipelineWithMesh( @@ -315,6 +425,25 @@ public sealed unsafe partial class EnvCellRenderer (uint)byteCount); } + /// + /// Binds one category word for mesh_detail.vert's statically used + /// binding 9. EnvCell draws select their renderer-wide category via + /// uParamB=0, so the value is semantically unused, but Vulkan still + /// requires the declared descriptor to be valid. + /// + internal static void BindEnvironmentDetailCategory( + IGpuPassEncoder encoder, + IGpuFrame frame) + { + Span category = stackalloc uint[1]; + category[0] = 1u; + BindRingSection( + encoder, + frame, + GpuBindingModel.StorageInstanceDetailCategory, + category); + } + private void DisposeRhiResources() { _opaquePipeline?.Dispose(); @@ -323,5 +452,9 @@ public sealed unsafe partial class EnvCellRenderer _alphaPipeline = null; _additivePipeline?.Dispose(); _additivePipeline = null; + _detailPipeline?.Dispose(); + _detailPipeline = null; + _transparentDetailPipeline?.Dispose(); + _transparentDetailPipeline = null; } } diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index 982206c9..426768f4 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -1290,6 +1290,11 @@ public sealed partial class EnvCellRenderer : // mesh manager at upload rather than interned here. TextureTableIndex = item.batch.TextureSlot.Index, TextureIndex = (uint)item.batch.TextureIndex, + // #226: this renderer submits built EnvCell meshes. + // Retail DrawMesh forwards curr_detail_surface to + // RenderMeshSubset for every material subset, including + // ClipMap, transparent, additive and inverse alpha. + Flags = 1u, }; _commands[cmdIndex] = new DrawElementsIndirectCommand diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadowReceivers.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadowReceivers.cs new file mode 100644 index 00000000..d86570fa --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadowReceivers.cs @@ -0,0 +1,130 @@ +using AcDream.App.Rendering.Gpu; + +namespace AcDream.App.Rendering.Wb; + +public sealed partial class WbDrawDispatcher +{ + internal sealed class DirectionalShadowReceiverPipelineState : IDisposable + { + private readonly MeshPipelineSet _backbuffer; + private readonly MeshPipelineSet _offscreen; + + internal DirectionalShadowReceiverPipelineState( + IDirectionalShadowReceiverSource source, + MeshPipelineSet backbuffer, + MeshPipelineSet offscreen) + { + Source = source; + _backbuffer = backbuffer; + _offscreen = offscreen; + } + + internal IDirectionalShadowReceiverSource Source { get; } + + internal MeshPipelineSet ForSampleCount(int sampleCount) => + sampleCount > 1 ? _backbuffer : _offscreen; + + public void Dispose() + { + DisposeMeshPipelineSet(_backbuffer); + if (!ReferenceEquals(_offscreen, _backbuffer)) + DisposeMeshPipelineSet(_offscreen); + } + } + + private DirectionalShadowReceiverPipelineState? _directionalShadowReceiver; + + /// + /// Builds both sample-count variants without publishing either. A different + /// pack always gets pipelines compiled from that candidate's shader blobs; + /// no prior pack pipeline is reused by shader name or nullable caching. + /// + internal DirectionalShadowReceiverPipelineState? PrepareDirectionalShadowReceiver( + IDirectionalShadowReceiverSource? source, + int sampleCount) + { + if (source is null) + return null; + IGpuDevice device = _device + ?? throw new InvalidOperationException("Directional receivers require the modern RHI device."); + if (_scope is null || sampleCount != _scope.SampleCount) + throw new InvalidOperationException("Receiver and world-pass sample counts must match."); + + MeshPipelineSet? backbuffer = null; + MeshPipelineSet? offscreen = null; + try + { + backbuffer = CreateMeshPipelineSet( + device, + sampleCount, + baseShaders: source.PipelineShaders.WorldReceiver, + namePrefix: "wb-mesh-atmospheric", + usesRenderPackShaderAbi: true); + offscreen = sampleCount == 1 + ? backbuffer + : CreateMeshPipelineSet( + device, + 1, + baseShaders: source.PipelineShaders.WorldReceiver, + namePrefix: "wb-mesh-atmospheric", + usesRenderPackShaderAbi: true); + return new DirectionalShadowReceiverPipelineState(source, backbuffer, offscreen); + } + catch + { + DisposeMeshPipelineSet(backbuffer); + if (!ReferenceEquals(offscreen, backbuffer)) + DisposeMeshPipelineSet(offscreen); + throw; + } + } + + /// Assignment-only publication; returned state retires after the coupled swap. + internal DirectionalShadowReceiverPipelineState? SwapDirectionalShadowReceiver( + DirectionalShadowReceiverPipelineState? candidate) + { + DirectionalShadowReceiverPipelineState? prior = _directionalShadowReceiver; + _directionalShadowReceiver = candidate; + return prior; + } + + private MeshPipelineSet PipelinesFor( + IGpuPassEncoder encoder, + IGpuFrame frame, + out DirectionalShadowFrameBinding shadowBinding) + { + DirectionalShadowReceiverPipelineState? receiver = _directionalShadowReceiver; + IDirectionalShadowReceiverSource? source = receiver?.Source; + shadowBinding = DirectionalShadowFrameBinding.Disabled; + bool bindingValid = source is not null + && source.TryGetCurrentFrameBinding(frame, out shadowBinding); + if (DirectionalShadowReceiverPolicy.ShouldSelectReceiverPipeline( + encoder.Pass.Name, + source is not null, + bindingValid)) + { + return receiver!.ForSampleCount(encoder.Pass.SampleCount); + } + return PipelinesFor(encoder); + } + + private static void BindDirectionalShadowReceiver( + IGpuPassEncoder encoder, + in DirectionalShadowFrameBinding binding) + { + if (!binding.Enabled || binding.Buffer is null) + return; + encoder.BindUniformBuffer( + GpuBindingModel.UniformDirectionalShadow, + binding.Buffer, + binding.OffsetBytes, + binding.SizeBytes); + } + + private void DisposeDirectionalShadowReceiverPipelines() + { + DirectionalShadowReceiverPipelineState? state = + SwapDirectionalShadowReceiver(null); + state?.Dispose(); + } +} diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs new file mode 100644 index 00000000..db939f12 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs @@ -0,0 +1,1186 @@ +using System.Numerics; +using System.Runtime.CompilerServices; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Scene; +using AcDream.Core.Meshing; +using AcDream.Core.World; +using DatReaderWriter.Enums; + +namespace AcDream.App.Rendering.Wb; + +internal enum DirectionalShadowCasterMaterial : byte +{ + Opaque, + AlphaCutout, +} + +internal readonly record struct DirectionalShadowPreparedBatch( + GpuTextureSlot TextureSlot, + uint TextureLayer, + CullMode CullMode, + DirectionalShadowCasterMaterial Material); + +internal readonly record struct DirectionalShadowPreparedRun( + int StartCommand, + int CommandCount, + CullMode CullMode, + DirectionalShadowCasterMaterial Material); + +internal readonly record struct DirectionalShadowPreparationStats( + int SourceCasters, + int SourceMeshRefs, + int SourceParts, + int SourceBatches, + int PreparedInstances, + int PreparedOpaqueCommands, + int PreparedAlphaCutoutCommands, + int RejectedTransparentBatches, + int RejectedFadedParts, + int MissingMeshes, + int UnresolvedAlphaCutoutTextures); + +internal readonly record struct DirectionalShadowMeshGeometry( + IGpuBuffer VertexBuffer, + IGpuBuffer IndexBuffer); + +internal readonly record struct DirectionalShadowTransformSource( + bool Refreshable, + int CasterIndex, + int MeshIndex, + bool IsSetupPart, + Matrix4x4 SetupPartTransform) +{ + public static DirectionalShadowTransformSource Dynamic( + int casterIndex, + int meshIndex, + bool isSetupPart, + in Matrix4x4 setupPartTransform) => + new( + true, + casterIndex, + meshIndex, + isSetupPart, + setupPartTransform); +} + +/// +/// One reusable CPU product prepared before the first directional cascade. +/// Every cascade replays these exact command, metadata, and transform spans; +/// it never reclassifies materials or asks animation for another pose. +/// +internal sealed class DirectionalShadowPreparedDraws +{ + private DirectionalShadowSourceDraw[] _source = []; + private Matrix4x4[] _transforms = []; + private DirectionalShadowTransformSource[] _transformSources = []; + private int[] _dynamicTransformSlots = []; + private int[] _allDynamicTransformSlots = []; + private int[] _firstDynamicTransformByCaster = []; + private int[] _nextDynamicTransform = []; + private int[] _denseChangedPoseByCaster = []; + private RenderProjectionId[] _mappedCasterIds = []; + private RenderProjectionClass[] _mappedCasterClasses = []; + private bool[] _mappedCasterIdentityPresent = []; + private DrawElementsIndirectCommand[] _commands = []; + private DirectionalShadowPreparedBatch[] _batches = []; + private DirectionalShadowPreparedRun[] _runs = []; + private int _sourceCount; + private int _commandCount; + private int _runCount; + private int _dynamicTransformSlotCount; + private int _allDynamicTransformSlotCount; + private int _mappedCasterCount; + private bool _building; + private bool _retryClassificationNextFrame; + + public RenderSceneGeneration SourceGeneration { get; private set; } + + public ulong SourceCasterBuildSequence { get; private set; } + + public long SourceRenderDataAvailabilityVersion { get; private set; } + + public ulong SourceTranslucencyFadeRevision { get; private set; } + + public ulong BuildSequence { get; private set; } + + public int LastDynamicTransformRefreshCount { get; private set; } + + public bool LastDynamicTransformRefreshWasDense { get; private set; } + + public int OpaqueCommandCount { get; private set; } + + public int AlphaCutoutCommandCount => _commandCount - OpaqueCommandCount; + + public int OpaqueRunCount { get; private set; } + + public ReadOnlySpan Transforms => + _transforms.AsSpan(0, _sourceCount); + + /// + /// Sorted transform indices whose root and/or animated part values are + /// refreshed from already-published slim pose snapshots this frame. + /// Static indices are omitted so a retained GPU transform product can leave + /// their exact bits untouched until topology changes. + /// + public ReadOnlySpan DynamicTransformSlots => + _dynamicTransformSlots.AsSpan(0, _dynamicTransformSlotCount); + + /// + /// Every refreshable transform in the retained topology. The per-flight + /// GPU product uses this exact list only when its bounded replay journal + /// cannot cover all changes since a flight slot was last submitted. + /// + public ReadOnlySpan AllDynamicTransformSlots => + _allDynamicTransformSlots.AsSpan(0, _allDynamicTransformSlotCount); + + public ReadOnlySpan Commands => + _commands.AsSpan(0, _commandCount); + + public ReadOnlySpan OpaqueCommands => + _commands.AsSpan(0, OpaqueCommandCount); + + public ReadOnlySpan AlphaCutoutCommands => + _commands.AsSpan(OpaqueCommandCount, AlphaCutoutCommandCount); + + public ReadOnlySpan Batches => + _batches.AsSpan(0, _commandCount); + + public ReadOnlySpan OpaqueBatches => + _batches.AsSpan(0, OpaqueCommandCount); + + public ReadOnlySpan AlphaCutoutBatches => + _batches.AsSpan(OpaqueCommandCount, AlphaCutoutCommandCount); + + public ReadOnlySpan Runs => + _runs.AsSpan(0, _runCount); + + public ReadOnlySpan OpaqueRuns => + _runs.AsSpan(0, OpaqueRunCount); + + public ReadOnlySpan AlphaCutoutRuns => + _runs.AsSpan(OpaqueRunCount, _runCount - OpaqueRunCount); + + public DirectionalShadowPreparationStats Stats { get; private set; } + + public long RetainedScratchBytes => checked( + (long)_source.Length * Unsafe.SizeOf() + + (long)_transforms.Length * Unsafe.SizeOf() + + (long)_transformSources.Length + * Unsafe.SizeOf() + + (long)_dynamicTransformSlots.Length * sizeof(int) + + (long)_allDynamicTransformSlots.Length * sizeof(int) + + (long)_firstDynamicTransformByCaster.Length * sizeof(int) + + (long)_nextDynamicTransform.Length * sizeof(int) + + (long)_denseChangedPoseByCaster.Length * sizeof(int) + + (long)_mappedCasterIds.Length * Unsafe.SizeOf() + + (long)_mappedCasterClasses.Length + * Unsafe.SizeOf() + + _mappedCasterIdentityPresent.Length + + (long)_commands.Length * Unsafe.SizeOf() + + (long)_batches.Length * Unsafe.SizeOf() + + (long)_runs.Length * Unsafe.SizeOf()); + + /// + /// Returns false when this exact resident-caster build was already + /// prepared. A controller may therefore ask once per cascade without + /// accidentally performing per-cascade CPU classification. + /// + public bool RequiresTopologyBuild( + RenderSceneGeneration generation, + ulong casterBuildSequence, + long renderDataAvailabilityVersion = 0, + ulong translucencyFadeRevision = 0) => + SourceGeneration != generation + || SourceCasterBuildSequence != casterBuildSequence + || SourceRenderDataAvailabilityVersion + != renderDataAvailabilityVersion + || SourceTranslucencyFadeRevision != translucencyFadeRevision + || _retryClassificationNextFrame; + + public bool TryBegin( + RenderSceneGeneration generation, + ulong casterBuildSequence, + int estimatedInstances, + long renderDataAvailabilityVersion = 0, + ulong translucencyFadeRevision = 0) + { + if (_building) + throw new InvalidOperationException( + "A directional-shadow draw build is already active."); + if (casterBuildSequence == 0) + throw new ArgumentOutOfRangeException(nameof(casterBuildSequence)); + ArgumentOutOfRangeException.ThrowIfNegative(estimatedInstances); + if (!RequiresTopologyBuild( + generation, + casterBuildSequence, + renderDataAvailabilityVersion, + translucencyFadeRevision)) + { + return false; + } + + EnsureCapacity(ref _source, estimatedInstances); + _sourceCount = 0; + _commandCount = 0; + _runCount = 0; + _dynamicTransformSlotCount = 0; + _allDynamicTransformSlotCount = 0; + if (_mappedCasterCount != 0) + { + Array.Clear( + _mappedCasterIdentityPresent, + 0, + _mappedCasterCount); + } + _mappedCasterCount = 0; + OpaqueCommandCount = 0; + OpaqueRunCount = 0; + Stats = default; + LastDynamicTransformRefreshCount = 0; + LastDynamicTransformRefreshWasDense = false; + _building = true; + return true; + } + + public void MapCasterIdentity( + int casterIndex, + RenderProjectionId id, + RenderProjectionClass projectionClass) + { + if (!_building) + { + throw new InvalidOperationException( + "Begin a directional-shadow draw build before mapping casters."); + } + ArgumentOutOfRangeException.ThrowIfNegative(casterIndex); + int required = checked(casterIndex + 1); + EnsureCapacity(ref _mappedCasterIds, required); + EnsureCapacity(ref _mappedCasterClasses, required); + EnsureCapacity(ref _mappedCasterIdentityPresent, required); + if (_mappedCasterIdentityPresent[casterIndex] + && (_mappedCasterIds[casterIndex] != id + || _mappedCasterClasses[casterIndex] != projectionClass)) + { + throw new InvalidOperationException( + $"Directional-shadow caster slot {casterIndex} was mapped twice " + + "with different projection identities."); + } + _mappedCasterIds[casterIndex] = id; + _mappedCasterClasses[casterIndex] = projectionClass; + _mappedCasterIdentityPresent[casterIndex] = true; + _mappedCasterCount = Math.Max(_mappedCasterCount, required); + } + + public void Add( + uint firstIndex, + int baseVertex, + int indexCount, + GpuTextureSlot textureSlot, + uint textureLayer, + CullMode cullMode, + DirectionalShadowCasterMaterial material, + in Matrix4x4 transform) + { + DirectionalShadowTransformSource source = default; + Add( + firstIndex, + baseVertex, + indexCount, + textureSlot, + textureLayer, + cullMode, + material, + in transform, + in source); + } + + public void Add( + uint firstIndex, + int baseVertex, + int indexCount, + GpuTextureSlot textureSlot, + uint textureLayer, + CullMode cullMode, + DirectionalShadowCasterMaterial material, + in Matrix4x4 transform, + in DirectionalShadowTransformSource transformSource) + { + if (!_building) + throw new InvalidOperationException( + "Begin a directional-shadow draw build before adding batches."); + if (indexCount <= 0) + throw new ArgumentOutOfRangeException(nameof(indexCount)); + if (material is DirectionalShadowCasterMaterial.AlphaCutout + && !textureSlot.IsAssigned) + { + throw new ArgumentException( + "An alpha-cutout caster requires an assigned texture slot.", + nameof(textureSlot)); + } + + EnsureCapacity(ref _source, checked(_sourceCount + 1)); + _source[_sourceCount++] = new DirectionalShadowSourceDraw( + new DirectionalShadowDrawKey( + firstIndex, + baseVertex, + indexCount, + material is DirectionalShadowCasterMaterial.AlphaCutout + ? textureSlot + : GpuTextureSlot.Unassigned, + material is DirectionalShadowCasterMaterial.AlphaCutout + ? textureLayer + : 0u, + cullMode, + material), + transform, + transformSource); + } + + public void Complete( + RenderSceneGeneration generation, + ulong casterBuildSequence, + in DirectionalShadowPreparationStats stats, + long renderDataAvailabilityVersion = 0, + ulong translucencyFadeRevision = 0) + { + if (!_building) + throw new InvalidOperationException( + "No directional-shadow draw build is active."); + if (casterBuildSequence == 0) + throw new ArgumentOutOfRangeException(nameof(casterBuildSequence)); + + Array.Sort( + _source, + 0, + _sourceCount, + DirectionalShadowSourceDrawComparer.Instance); + EnsureCapacity(ref _transforms, _sourceCount); + EnsureCapacity(ref _transformSources, _sourceCount); + EnsureCapacity(ref _dynamicTransformSlots, _sourceCount); + EnsureCapacity(ref _allDynamicTransformSlots, _sourceCount); + EnsureCapacity(ref _nextDynamicTransform, _sourceCount); + EnsureCapacity(ref _commands, _sourceCount); + EnsureCapacity(ref _batches, _sourceCount); + EnsureCapacity(ref _runs, _sourceCount); + + int maxCasterIndex = -1; + for (int index = 0; index < _sourceCount; index++) + { + DirectionalShadowTransformSource transformSource = + _source[index].TransformSource; + if (transformSource.Refreshable) + maxCasterIndex = Math.Max(maxCasterIndex, transformSource.CasterIndex); + } + _mappedCasterCount = Math.Max(_mappedCasterCount, maxCasterIndex + 1); + EnsureCapacity(ref _firstDynamicTransformByCaster, _mappedCasterCount); + EnsureCapacity(ref _denseChangedPoseByCaster, _mappedCasterCount); + EnsureCapacity(ref _mappedCasterIds, _mappedCasterCount); + EnsureCapacity(ref _mappedCasterClasses, _mappedCasterCount); + EnsureCapacity(ref _mappedCasterIdentityPresent, _mappedCasterCount); + if (_mappedCasterCount != 0) + { + Array.Fill( + _firstDynamicTransformByCaster, + -1, + 0, + _mappedCasterCount); + } + + int sourceIndex = 0; + int transformIndex = 0; + int commandIndex = 0; + int opaqueCommands = 0; + while (sourceIndex < _sourceCount) + { + DirectionalShadowDrawKey key = _source[sourceIndex].Key; + int groupStart = sourceIndex; + do + { + _transforms[transformIndex++] = _source[sourceIndex].Transform; + _transformSources[transformIndex - 1] = + _source[sourceIndex].TransformSource; + if (_source[sourceIndex].TransformSource.Refreshable) + { + int dynamicTransformIndex = transformIndex - 1; + DirectionalShadowTransformSource transformSource = + _source[sourceIndex].TransformSource; + if (transformSource.CasterIndex < 0) + { + throw new InvalidOperationException( + "A refreshable directional-shadow transform has a negative caster index."); + } + _allDynamicTransformSlots[_allDynamicTransformSlotCount++] = + dynamicTransformIndex; + _nextDynamicTransform[dynamicTransformIndex] = + _firstDynamicTransformByCaster[transformSource.CasterIndex]; + _firstDynamicTransformByCaster[transformSource.CasterIndex] = + dynamicTransformIndex; + } + sourceIndex++; + } + while (sourceIndex < _sourceCount && _source[sourceIndex].Key == key); + + int instanceCount = sourceIndex - groupStart; + _commands[commandIndex] = new DrawElementsIndirectCommand + { + Count = checked((uint)key.IndexCount), + InstanceCount = checked((uint)instanceCount), + FirstIndex = key.FirstIndex, + BaseVertex = key.BaseVertex, + BaseInstance = checked((uint)(transformIndex - instanceCount)), + }; + _batches[commandIndex] = new DirectionalShadowPreparedBatch( + key.TextureSlot, + key.TextureLayer, + key.CullMode, + key.Material); + if (key.Material is DirectionalShadowCasterMaterial.Opaque) + opaqueCommands++; + commandIndex++; + } + + _commandCount = commandIndex; + OpaqueCommandCount = opaqueCommands; + int runCursor = 0; + int runStart = 0; + while (runStart < commandIndex) + { + DirectionalShadowPreparedBatch first = _batches[runStart]; + int runEnd = runStart + 1; + while (runEnd < commandIndex + && _batches[runEnd].CullMode == first.CullMode + && _batches[runEnd].Material == first.Material) + { + runEnd++; + } + _runs[runCursor++] = new DirectionalShadowPreparedRun( + runStart, + runEnd - runStart, + first.CullMode, + first.Material); + runStart = runEnd; + } + _runCount = runCursor; + while (OpaqueRunCount < runCursor + && _runs[OpaqueRunCount].Material is DirectionalShadowCasterMaterial.Opaque) + { + OpaqueRunCount++; + } + SourceGeneration = generation; + SourceCasterBuildSequence = casterBuildSequence; + SourceRenderDataAvailabilityVersion = renderDataAvailabilityVersion; + SourceTranslucencyFadeRevision = translucencyFadeRevision; + BuildSequence = checked(BuildSequence + 1); + LastDynamicTransformRefreshCount = 0; + LastDynamicTransformRefreshWasDense = false; + _dynamicTransformSlotCount = 0; + _retryClassificationNextFrame = + stats.UnresolvedAlphaCutoutTextures != 0; + Stats = stats with + { + PreparedInstances = _sourceCount, + PreparedOpaqueCommands = opaqueCommands, + PreparedAlphaCutoutCommands = commandIndex - opaqueCommands, + }; + _building = false; + } + + public void RefreshDynamicTransforms( + DirectionalShadowCasterFrame casters) + { + ArgumentNullException.ThrowIfNull(casters); + if (casters.Stats.TransformJournalFullRefresh) + { + RefreshAllDynamicTransforms(casters.Casters); + LastDynamicTransformRefreshWasDense = false; + return; + } + RefreshDynamicTransforms( + casters.ChangedCasterPoses, + casters.Stats.DensityBulkRefresh); + } + + internal void RefreshDynamicTransforms( + ReadOnlySpan currentCasters) + { + RefreshAllDynamicTransforms(currentCasters); + LastDynamicTransformRefreshWasDense = false; + } + + internal void RefreshDenseDynamicTransforms( + ReadOnlySpan currentCasters) + { + RefreshAllDynamicTransforms(currentCasters); + LastDynamicTransformRefreshWasDense = true; + } + + private void RefreshAllDynamicTransforms( + ReadOnlySpan currentCasters) + { + _dynamicTransformSlotCount = _allDynamicTransformSlotCount; + _allDynamicTransformSlots.AsSpan(0, _allDynamicTransformSlotCount) + .CopyTo(_dynamicTransformSlots); + int refreshed = 0; + for (int dynamicIndex = 0; + dynamicIndex < _dynamicTransformSlotCount; + dynamicIndex++) + { + int transformIndex = _dynamicTransformSlots[dynamicIndex]; + DirectionalShadowTransformSource source = + _transformSources[transformIndex]; + if ((uint)source.CasterIndex >= (uint)currentCasters.Length) + { + throw new InvalidOperationException( + "Directional-shadow transform source has a stale caster index."); + } + + RenderProjectionRecord projection = + currentCasters[source.CasterIndex].Projection; + IReadOnlyList meshes = projection.EntityPayload.MeshRefs; + if ((uint)source.MeshIndex >= (uint)meshes.Count) + { + throw new InvalidOperationException( + "Directional-shadow transform source has a stale mesh index."); + } + + MeshRef mesh = meshes[source.MeshIndex]; + _transforms[transformIndex] = source.IsSetupPart + ? WbDrawDispatcher.ComposePartWorldMatrix( + projection.Transform.LocalToWorld, + mesh.PartTransform, + source.SetupPartTransform) + : mesh.PartTransform * projection.Transform.LocalToWorld; + refreshed++; + } + + LastDynamicTransformRefreshCount = refreshed; + } + + internal void RefreshDynamicTransforms( + ReadOnlySpan currentCasters, + ReadOnlySpan changedCasterSlots) + { + _dynamicTransformSlotCount = 0; + for (int changedIndex = 0; + changedIndex < changedCasterSlots.Length; + changedIndex++) + { + int casterIndex = changedCasterSlots[changedIndex]; + if ((uint)casterIndex >= (uint)currentCasters.Length) + { + throw new InvalidOperationException( + "Directional-shadow changed-caster slot is stale."); + } + if ((uint)casterIndex >= (uint)_mappedCasterCount) + continue; + + for (int transformIndex = _firstDynamicTransformByCaster[casterIndex]; + transformIndex >= 0; + transformIndex = _nextDynamicTransform[transformIndex]) + { + RefreshTransform(currentCasters, transformIndex); + _dynamicTransformSlots[_dynamicTransformSlotCount++] = transformIndex; + } + } + _dynamicTransformSlots.AsSpan(0, _dynamicTransformSlotCount).Sort(); + LastDynamicTransformRefreshCount = _dynamicTransformSlotCount; + LastDynamicTransformRefreshWasDense = false; + } + + internal void RefreshDynamicTransforms( + ReadOnlySpan changedPoses, + bool denseRefresh = false) + { + if (denseRefresh) + { + RefreshDenseDynamicTransforms(changedPoses); + return; + } + + _dynamicTransformSlotCount = 0; + for (int changedIndex = 0; + changedIndex < changedPoses.Length; + changedIndex++) + { + ref readonly DirectionalShadowChangedPose changed = + ref changedPoses[changedIndex]; + int casterIndex = changed.CasterIndex; + if ((uint)casterIndex >= (uint)_mappedCasterCount + || !_mappedCasterIdentityPresent[casterIndex]) + { + throw new InvalidOperationException( + "Directional-shadow changed pose has a stale or unmapped caster index."); + } + + ref readonly DirectionalShadowTransformSnapshot pose = + ref changed.Snapshot; + if (pose.Id != _mappedCasterIds[casterIndex] + || pose.ProjectionClass != _mappedCasterClasses[casterIndex]) + { + throw new InvalidOperationException( + $"Directional-shadow changed pose {pose.Id} does not match " + + $"retained caster {_mappedCasterIds[casterIndex]} at " + + $"slot {casterIndex}."); + } + + for (int transformIndex = _firstDynamicTransformByCaster[casterIndex]; + transformIndex >= 0; + transformIndex = _nextDynamicTransform[transformIndex]) + { + RefreshTransform( + in pose, + casterIndex, + transformIndex); + _dynamicTransformSlots[_dynamicTransformSlotCount++] = + transformIndex; + } + } + _dynamicTransformSlots.AsSpan(0, _dynamicTransformSlotCount).Sort(); + LastDynamicTransformRefreshCount = _dynamicTransformSlotCount; + LastDynamicTransformRefreshWasDense = denseRefresh; + } + + /// + /// Dense animation refreshes already upload the complete retained dynamic + /// ranges. Mark the changed caster slots, then walk the topology's strictly + /// increasing transform slots once. This produces the same exact sorted + /// changed-slot product without sorting roughly two thousand integers on + /// every foliage-animation frame. + /// + private void RefreshDenseDynamicTransforms( + ReadOnlySpan changedPoses) + { + _dynamicTransformSlotCount = 0; + int marked = 0; + try + { + for (int changedIndex = 0; + changedIndex < changedPoses.Length; + changedIndex++) + { + ref readonly DirectionalShadowChangedPose changed = + ref changedPoses[changedIndex]; + int casterIndex = changed.CasterIndex; + ValidateChangedPose(in changed, casterIndex); + if (_denseChangedPoseByCaster[casterIndex] != 0) + { + throw new InvalidOperationException( + "A dense directional-shadow refresh contains a duplicate caster slot."); + } + + _denseChangedPoseByCaster[casterIndex] = changedIndex + 1; + marked++; + } + + for (int dynamicIndex = 0; + dynamicIndex < _allDynamicTransformSlotCount; + dynamicIndex++) + { + int transformIndex = _allDynamicTransformSlots[dynamicIndex]; + DirectionalShadowTransformSource source = + _transformSources[transformIndex]; + int poseIndex = _denseChangedPoseByCaster[source.CasterIndex] - 1; + if (poseIndex < 0) + continue; + + ref readonly DirectionalShadowChangedPose changed = + ref changedPoses[poseIndex]; + RefreshTransform( + in changed.Snapshot, + changed.CasterIndex, + transformIndex); + _dynamicTransformSlots[_dynamicTransformSlotCount++] = + transformIndex; + } + } + finally + { + if (marked != 0) + { + for (int changedIndex = 0; + changedIndex < changedPoses.Length; + changedIndex++) + { + int casterIndex = changedPoses[changedIndex].CasterIndex; + if ((uint)casterIndex < (uint)_denseChangedPoseByCaster.Length) + _denseChangedPoseByCaster[casterIndex] = 0; + } + } + } + + LastDynamicTransformRefreshCount = _dynamicTransformSlotCount; + LastDynamicTransformRefreshWasDense = true; + } + + private void ValidateChangedPose( + in DirectionalShadowChangedPose changed, + int casterIndex) + { + if ((uint)casterIndex >= (uint)_mappedCasterCount + || !_mappedCasterIdentityPresent[casterIndex]) + { + throw new InvalidOperationException( + "Directional-shadow changed pose has a stale or unmapped caster index."); + } + + ref readonly DirectionalShadowTransformSnapshot pose = + ref changed.Snapshot; + if (pose.Id != _mappedCasterIds[casterIndex] + || pose.ProjectionClass != _mappedCasterClasses[casterIndex]) + { + throw new InvalidOperationException( + $"Directional-shadow changed pose {pose.Id} does not match " + + $"retained caster {_mappedCasterIds[casterIndex]} at " + + $"slot {casterIndex}."); + } + } + + private void RefreshTransform( + in DirectionalShadowTransformSnapshot pose, + int casterIndex, + int transformIndex) + { + DirectionalShadowTransformSource source = + _transformSources[transformIndex]; + if (source.CasterIndex != casterIndex) + { + throw new InvalidOperationException( + "Directional-shadow transform source maps to a different caster."); + } + + IReadOnlyList currentMeshes = pose.EntityPayload.MeshRefs; + if ((uint)source.MeshIndex >= (uint)currentMeshes.Count) + { + throw new InvalidOperationException( + "Directional-shadow changed pose has a stale mesh index."); + } + + MeshRef currentMesh = currentMeshes[source.MeshIndex]; + _transforms[transformIndex] = source.IsSetupPart + ? WbDrawDispatcher.ComposePartWorldMatrix( + pose.Transform.LocalToWorld, + currentMesh.PartTransform, + source.SetupPartTransform) + : currentMesh.PartTransform * pose.Transform.LocalToWorld; + } + + private void RefreshTransform( + ReadOnlySpan currentCasters, + int transformIndex) + { + DirectionalShadowTransformSource source = + _transformSources[transformIndex]; + if ((uint)source.CasterIndex >= (uint)currentCasters.Length) + { + throw new InvalidOperationException( + "Directional-shadow transform source has a stale caster index."); + } + + RenderProjectionRecord projection = + currentCasters[source.CasterIndex].Projection; + IReadOnlyList meshes = projection.EntityPayload.MeshRefs; + if ((uint)source.MeshIndex >= (uint)meshes.Count) + { + throw new InvalidOperationException( + "Directional-shadow transform source has a stale mesh index."); + } + + MeshRef mesh = meshes[source.MeshIndex]; + _transforms[transformIndex] = source.IsSetupPart + ? WbDrawDispatcher.ComposePartWorldMatrix( + projection.Transform.LocalToWorld, + mesh.PartTransform, + source.SetupPartTransform) + : mesh.PartTransform * projection.Transform.LocalToWorld; + } + + public void Abort() + { + _sourceCount = 0; + _commandCount = 0; + _runCount = 0; + _dynamicTransformSlotCount = 0; + _allDynamicTransformSlotCount = 0; + if (_mappedCasterCount != 0) + { + Array.Clear( + _mappedCasterIdentityPresent, + 0, + _mappedCasterCount); + } + _mappedCasterCount = 0; + OpaqueCommandCount = 0; + OpaqueRunCount = 0; + Stats = default; + SourceGeneration = default; + SourceCasterBuildSequence = 0; + SourceRenderDataAvailabilityVersion = 0; + SourceTranslucencyFadeRevision = 0; + LastDynamicTransformRefreshCount = 0; + LastDynamicTransformRefreshWasDense = false; + _retryClassificationNextFrame = false; + _building = false; + } + + internal static bool TryClassifyMaterial( + TranslucencyKind translucency, + out DirectionalShadowCasterMaterial material) + { + switch (translucency) + { + case TranslucencyKind.Opaque: + material = DirectionalShadowCasterMaterial.Opaque; + return true; + case TranslucencyKind.ClipMap: + material = DirectionalShadowCasterMaterial.AlphaCutout; + return true; + default: + material = default; + return false; + } + } + + internal static bool FadeExcludesCaster(float translucency) => + !float.IsFinite(translucency) || translucency > 0f; + + private static void EnsureCapacity(ref T[] values, int required) + { + if (values.Length >= required) + return; + int capacity = values.Length == 0 ? 16 : values.Length; + while (capacity < required) + capacity = checked(capacity * 2); + Array.Resize(ref values, capacity); + } + + private readonly record struct DirectionalShadowDrawKey( + uint FirstIndex, + int BaseVertex, + int IndexCount, + GpuTextureSlot TextureSlot, + uint TextureLayer, + CullMode CullMode, + DirectionalShadowCasterMaterial Material); + + private readonly record struct DirectionalShadowSourceDraw( + DirectionalShadowDrawKey Key, + Matrix4x4 Transform, + DirectionalShadowTransformSource TransformSource); + + private sealed class DirectionalShadowSourceDrawComparer + : IComparer + { + public static DirectionalShadowSourceDrawComparer Instance { get; } = new(); + + public int Compare( + DirectionalShadowSourceDraw left, + DirectionalShadowSourceDraw right) + { + DirectionalShadowDrawKey x = left.Key; + DirectionalShadowDrawKey y = right.Key; + int order = x.Material.CompareTo(y.Material); + if (order != 0) return order; + order = x.CullMode.CompareTo(y.CullMode); + if (order != 0) return order; + order = x.FirstIndex.CompareTo(y.FirstIndex); + if (order != 0) return order; + order = x.BaseVertex.CompareTo(y.BaseVertex); + if (order != 0) return order; + order = x.IndexCount.CompareTo(y.IndexCount); + if (order != 0) return order; + order = x.TextureSlot.Index.CompareTo(y.TextureSlot.Index); + return order != 0 + ? order + : x.TextureLayer.CompareTo(y.TextureLayer); + } + } +} + +public sealed partial class WbDrawDispatcher +{ + private readonly DirectionalShadowPreparedDraws _directionalShadowDraws = new(); + + internal DirectionalShadowMeshGeometry GetDirectionalShadowGeometry() + { + GlobalMeshBuffer mesh = _meshAdapter.MeshManager?.GlobalBuffer + ?? throw new InvalidOperationException("The shared mesh arena is not published."); + return new DirectionalShadowMeshGeometry( + mesh.VertexStore ?? throw new InvalidOperationException( + "The shared mesh arena has no vertex store."), + mesh.IndexStore ?? throw new InvalidOperationException( + "The shared mesh arena has no index store.")); + } + + /// + /// Classifies the retained resident caster product exactly once. The + /// returned owner is renderer-retained and remains valid until the next + /// distinct caster build is prepared. + /// + internal DirectionalShadowPreparedDraws PrepareDirectionalShadowDraws( + DirectionalShadowCasterFrame casters) + { + ArgumentNullException.ThrowIfNull(casters); + ReadOnlySpan source = casters.Casters; + long renderDataAvailabilityVersion = + _meshAdapter.MeshManager?.RenderDataAvailabilityVersion ?? 0L; + ulong translucencyFadeRevision = _translucencyFades.Revision; + if (!_directionalShadowDraws.RequiresTopologyBuild( + casters.Generation, + casters.BuildSequence, + renderDataAvailabilityVersion, + translucencyFadeRevision)) + { + _directionalShadowDraws.RefreshDynamicTransforms(casters); + return _directionalShadowDraws; + } + + int estimatedInstances = 0; + for (int i = 0; i < source.Length; i++) + { + estimatedInstances = checked( + estimatedInstances + + source[i].Projection.EntityPayload.MeshRefs.Count); + } + if (!_directionalShadowDraws.TryBegin( + casters.Generation, + casters.BuildSequence, + estimatedInstances, + renderDataAvailabilityVersion, + translucencyFadeRevision)) + { + return _directionalShadowDraws; + } + + int meshRefs = 0; + int parts = 0; + int batches = 0; + int rejectedTransparent = 0; + int rejectedFaded = 0; + int missingMeshes = 0; + int unresolvedCutoutTextures = 0; + try + { + for (int casterIndex = 0; casterIndex < source.Length; casterIndex++) + { + RenderProjectionRecord projection = source[casterIndex].Projection; + _directionalShadowDraws.MapCasterIdentity( + casterIndex, + projection.Id, + projection.ProjectionClass); + IReadOnlyList projectionMeshes = + projection.EntityPayload.MeshRefs; + var frameCandidate = new RenderFrameEntityCandidate( + projection, + MeshPartOffset: 0, + MeshPartCount: projectionMeshes.Count, + Animated: source[casterIndex].UsesCurrentAnimatedTransforms); + RenderInstanceCandidate candidate = + RenderInstanceCandidate.FromFrame( + in frameCandidate, + projection.Residency.OwnerLandblockId); + PaletteCompositeIdentity paletteIdentity = + projection.EntityPayload.PaletteOverride is null + ? default + : TextureCache.GetPaletteIdentity( + projection.EntityPayload.PaletteOverride); + + for (int meshIndex = 0; + meshIndex < projectionMeshes.Count; + meshIndex++) + { + meshRefs++; + MeshRef meshRef = projectionMeshes[meshIndex]; + ObjectRenderData? renderData = + _meshAdapter.TryGetRenderData(meshRef.GfxObjId); + if (renderData is null) + { + missingMeshes++; + _meshAdapter.EnsureLoaded(meshRef.GfxObjId); + continue; + } + + if (renderData.IsSetup && renderData.SetupParts.Count > 0) + { + for (int setupPartIndex = 0; + setupPartIndex < renderData.SetupParts.Count; + setupPartIndex++) + { + parts++; + if (PartFadeExcludesCaster( + projection.Source.LocalEntityId, + setupPartIndex)) + { + rejectedFaded++; + continue; + } + + (ulong partGfxObjId, Matrix4x4 partTransform) = + renderData.SetupParts[setupPartIndex]; + ObjectRenderData? partData = + _meshAdapter.TryGetRenderData(partGfxObjId); + if (partData is null) + { + missingMeshes++; + _meshAdapter.EnsureLoaded(partGfxObjId); + continue; + } + + Matrix4x4 model = ComposePartWorldMatrix( + projection.Transform.LocalToWorld, + meshRef.PartTransform, + partTransform); + DirectionalShadowTransformSource transformSource = + source[casterIndex].UsesCurrentAnimatedTransforms + ? DirectionalShadowTransformSource.Dynamic( + casterIndex, + meshIndex, + true, + in partTransform) + : default; + AddDirectionalShadowBatches( + partData, + in candidate, + meshRef, + paletteIdentity, + in model, + in transformSource, + ref batches, + ref rejectedTransparent, + ref unresolvedCutoutTextures); + } + } + else + { + parts++; + if (PartFadeExcludesCaster( + projection.Source.LocalEntityId, + meshIndex)) + { + rejectedFaded++; + continue; + } + + Matrix4x4 model = meshRef.PartTransform + * projection.Transform.LocalToWorld; + Matrix4x4 noSetupPart = default; + DirectionalShadowTransformSource transformSource = + source[casterIndex].UsesCurrentAnimatedTransforms + ? DirectionalShadowTransformSource.Dynamic( + casterIndex, + meshIndex, + false, + in noSetupPart) + : default; + AddDirectionalShadowBatches( + renderData, + in candidate, + meshRef, + paletteIdentity, + in model, + in transformSource, + ref batches, + ref rejectedTransparent, + ref unresolvedCutoutTextures); + } + } + } + + var stats = new DirectionalShadowPreparationStats( + SourceCasters: source.Length, + SourceMeshRefs: meshRefs, + SourceParts: parts, + SourceBatches: batches, + PreparedInstances: 0, + PreparedOpaqueCommands: 0, + PreparedAlphaCutoutCommands: 0, + RejectedTransparentBatches: rejectedTransparent, + RejectedFadedParts: rejectedFaded, + MissingMeshes: missingMeshes, + UnresolvedAlphaCutoutTextures: unresolvedCutoutTextures); + _directionalShadowDraws.Complete( + casters.Generation, + casters.BuildSequence, + in stats, + renderDataAvailabilityVersion, + translucencyFadeRevision); + return _directionalShadowDraws; + } + catch + { + _directionalShadowDraws.Abort(); + throw; + } + } + + private bool PartFadeExcludesCaster(uint entityId, int partIndex) => + _translucencyFades.TryGetCurrentValue( + entityId, + checked((uint)partIndex), + out float translucency) + && DirectionalShadowPreparedDraws.FadeExcludesCaster(translucency); + + private void AddDirectionalShadowBatches( + ObjectRenderData renderData, + in RenderInstanceCandidate candidate, + MeshRef meshRef, + PaletteCompositeIdentity paletteIdentity, + in Matrix4x4 model, + in DirectionalShadowTransformSource transformSource, + ref int sourceBatches, + ref int rejectedTransparent, + ref int unresolvedCutoutTextures) + { + for (int batchIndex = 0; + batchIndex < renderData.Batches.Count; + batchIndex++) + { + ObjectRenderBatch batch = renderData.Batches[batchIndex]; + sourceBatches++; + if (!DirectionalShadowPreparedDraws.TryClassifyMaterial( + batch.Translucency, + out DirectionalShadowCasterMaterial material)) + { + rejectedTransparent++; + continue; + } + + GpuTextureSlot textureSlot = GpuTextureSlot.Unassigned; + uint textureLayer = 0; + if (material is DirectionalShadowCasterMaterial.AlphaCutout) + { + // ObjectRenderBatch.SurfaceId is a legacy property that the + // modern mesh uploader does not populate. Key.SurfaceId is + // the same authoritative id ResolveTexture consumes. + if (batch.Key.SurfaceId is 0 or uint.MaxValue) + { + unresolvedCutoutTextures++; + continue; + } + ResolvedTexture texture = ResolveTexture( + in candidate, + meshRef, + batch, + paletteIdentity, + out bool compositePending); + if (compositePending || !texture.Slot.IsAssigned) + { + unresolvedCutoutTextures++; + continue; + } + textureSlot = texture.Slot; + textureLayer = texture.Layer; + } + + _directionalShadowDraws.Add( + batch.FirstIndex, + checked((int)batch.BaseVertex), + batch.IndexCount, + textureSlot, + textureLayer, + batch.CullMode, + material, + in model, + in transformSource); + } + } +} diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs index 6b4a4868..78a6b0e6 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs @@ -636,6 +636,7 @@ public sealed unsafe partial class WbDrawDispatcher slot, lights, indoor, + entity.IsBuildingShell, opacity, selectionLighting); } @@ -661,6 +662,7 @@ public sealed unsafe partial class WbDrawDispatcher slot, lights, indoor, + entity.IsBuildingShell, opacity: 1f, selectionLighting); } @@ -684,19 +686,51 @@ public sealed unsafe partial class WbDrawDispatcher uint slot, InstanceLightSet lights, bool indoor, + bool buildingDetail, float opacity, Vector2 selectionLighting) { InstanceGroup group = GetOrCreatePackedGroup(classified.Key); + AppendPackedInstance( + group, + model, + classified.LocalSortCenter, + _nextPackedInstanceSubmissionOrder++, + slot, + lights, + indoor, + buildingDetail, + opacity, + selectionLighting); + } + + /// + /// Appends one packed-route instance and every per-instance attribute in + /// lockstep. Keeping the writer in one testable seam prevents a newly + /// introduced storage binding from covering the legacy classifier while + /// leaving the production packed classifier with a shorter parallel list. + /// + internal static void AppendPackedInstance( + InstanceGroup group, + Matrix4x4 model, + Vector3 localSortCenter, + int submissionOrder, + uint slot, + InstanceLightSet lights, + bool indoor, + bool buildingDetail, + float opacity, + Vector2 selectionLighting) + { + ArgumentNullException.ThrowIfNull(group); group.Matrices.Add(model); - group.LocalSortCenters.Add( - classified.LocalSortCenter); - group.SubmissionOrders.Add( - _nextPackedInstanceSubmissionOrder++); + group.LocalSortCenters.Add(localSortCenter); + group.SubmissionOrders.Add(submissionOrder); group.Slots.Add(slot); group.LightSets.Add(lights); group.IndoorFlags.Add(indoor ? 1u : 0u); + group.DetailCategories.Add(buildingDetail ? 1u : 0u); group.Opacities.Add(opacity); group.SelectionLighting.Add(selectionLighting); } diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs index ef2e3fa7..25d3dc17 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs @@ -18,9 +18,9 @@ namespace AcDream.App.Rendering.Wb; /// AMD's GL stack did not. Every GL statement in the sibling file is untouched; /// everything here runs only when there is no GL context. /// -/// Three differences from V4c, each because the tree moved under it. There -/// is no binding-9 texture table — V4t put the slot on the device and Vulkan -/// binds set 2. The pass is BORROWED from rather +/// Three differences from V4c, each because the tree moved under it. The +/// texture table moved to set 2; #226 now uses storage binding 9 for the detail +/// category. The pass is BORROWED from rather /// than opened, because the frame's one backbuffer pass resolves. And the /// pipelines carry the device's sample count, because Vulkan requires a /// pipeline's rasterizationSamples to match the pass and @@ -33,7 +33,7 @@ public sealed unsafe partial class WbDrawDispatcher private readonly IWorldPassScope? _scope; /// - /// The five mesh pipelines at ONE sample count. + /// The seven mesh pipelines at ONE sample count. /// /// Campaign V slice V6l: there are two of these. Vulkan requires a /// pipeline's rasterizationSamples to equal the pass it draws in, and @@ -46,19 +46,25 @@ public sealed unsafe partial class WbDrawDispatcher /// the live pass actually is. Both are built at startup against the persisted /// cache, so no frame ever compiles one. /// - private sealed record MeshPipelineSet( + internal sealed record MeshPipelineSet( int SampleCount, IGpuPipeline Opaque, IGpuPipeline OpaqueAlphaToCoverage, IGpuPipeline AlphaBlend, IGpuPipeline AlphaAdditive, - IGpuPipeline AlphaInverse); + IGpuPipeline AlphaInverse, + IGpuPipeline RetailDetail, + IGpuPipeline RetailDetailTransparent); private MeshPipelineSet? _backbufferPipelines; private MeshPipelineSet? _offscreenPipelines; private const string OpaqueTimerScope = "wb-entities-opaque"; private const string TransparentTimerScope = "wb-entities-transparent"; + private const string DetailTimerScope = "wb-buildings-detail"; + + private readonly TerrainAtlas.RetailDetailTextureBinding _buildingDetail; + private readonly Func _buildingDetailEnabled; /// /// A ring slice reduced to the three values a later bind needs. The prepared @@ -70,6 +76,11 @@ public sealed unsafe partial class WbDrawDispatcher uint OffsetBytes, uint SizeBytes); + private readonly WorldTransformFrameArena _worldTransformFrames = new(); + private long _ordinaryTransformDemandFrameSerial = -1; + private uint _ordinaryTransformDemandThisFrame; + private uint _ordinaryTransformDemandHighWater; + private RhiSection _alphaInstances; private RhiSection _alphaBatches; private RhiSection _alphaClipSlots; @@ -78,12 +89,78 @@ public sealed unsafe partial class WbDrawDispatcher private RhiSection _alphaIndoor; private RhiSection _alphaOpacity; private RhiSection _alphaSelectionLighting; + private RhiSection _alphaDetailCategory; private RhiSection _alphaCommands; + private int _preparedAlphaInstanceCount; + private uint _alphaTransformBaseInstance; + + /// + /// Starts the one authoritative transform address space for an enhanced + /// world frame. The compatibility overload writes the shadow prefix into a + /// frame ring; the retained overload activates the current flight slot's + /// already-published prefix. Ordinary N.5 submissions append later and use + /// absolute BaseInstance values into the same bound buffer/range. + /// + internal WorldTransformFrameSlice BeginDirectionalShadowTransformFrame( + IGpuFrame frame, + ReadOnlySpan transforms) + { + ArgumentNullException.ThrowIfNull(frame); + return _worldTransformFrames.Begin( + frame, + transforms, + ResolveDirectionalShadowTransformBindingSize(transforms.Length)); + } + + /// + /// Resolves the one authoritative pose-buffer range before any shadow draw + /// records it. The retained owner and ordinary world appenders therefore + /// use the same demand-sized address space for the complete frame. + /// + internal uint ResolveDirectionalShadowTransformBindingSize( + int requiredPrefixInstances, + int ordinaryInstanceUpperBound = 0) + { + ArgumentOutOfRangeException.ThrowIfNegative(requiredPrefixInstances); + ArgumentOutOfRangeException.ThrowIfNegative(ordinaryInstanceUpperBound); + uint maximum = _device?.Capabilities.MaxStorageBufferRangeBytes + ?? WorldTransformCapacityPolicy.VulkanGuaranteedMaxStorageBufferRangeBytes; + uint currentFrameDemand = checked( + (uint)requiredPrefixInstances + (uint)ordinaryInstanceUpperBound); + uint requiredCombinedInstances = checked( + (uint)requiredPrefixInstances + _ordinaryTransformDemandHighWater); + return WorldTransformCapacityPolicy.ResolveBindingSizeBytes( + Math.Max(currentFrameDemand, requiredCombinedInstances), + maximum); + } + + internal WorldTransformFrameSlice BeginDirectionalShadowTransformFrame( + IGpuFrame frame, + in WorldTransformFrameSlice retainedShadowPrefix) + { + ArgumentNullException.ThrowIfNull(frame); + return _worldTransformFrames.BeginRetained( + frame, + in retainedShadowPrefix); + } + + internal void CancelDirectionalShadowTransformFrame(IGpuFrame frame) + { + ArgumentNullException.ThrowIfNull(frame); + _worldTransformFrames.Cancel(frame); + } + + internal bool HasDirectionalShadowTransformFrame(long frameSerial) => + _worldTransformFrames.IsActiveFor(frameSerial); + + internal uint DirectionalShadowTransformFrameUsedInstances => + _worldTransformFrames.UsedInstances; /// /// The RHI arm's constructor. No GL context, no Shader, no - /// BindlessSupport: the five pipelines compile mesh_modern from - /// the committed SPIR-V, and batch data already carries the device's own + /// BindlessSupport: five base pipelines compile mesh_modern and + /// the detail overlay compiles mesh_detail from committed SPIR-V; + /// batch data already carries the device's own /// GpuTextureSlot (V4t) rather than a bindless handle. /// internal WbDrawDispatcher( @@ -97,7 +174,9 @@ public sealed unsafe partial class WbDrawDispatcher AcDream.Core.Rendering.TranslucencyFadeManager translucencyFades, IRetailSelectionRenderSink? selectionSink = null, RetailAlphaQueue? alphaQueue = null, - long? alphaScratchBudgetBytes = null) + long? alphaScratchBudgetBytes = null, + TerrainAtlas.RetailDetailTextureBinding buildingDetail = default, + Func? buildingDetailEnabled = null) { _device = device ?? throw new ArgumentNullException(nameof(device)); _frames = frames ?? throw new ArgumentNullException(nameof(frames)); @@ -114,6 +193,8 @@ public sealed unsafe partial class WbDrawDispatcher _selectionLighting = selectionSink as IRetailSelectionLightingSource; _alphaQueue = alphaQueue; _alphaSource = new AlphaDrawSource(this); + _buildingDetail = buildingDetail; + _buildingDetailEnabled = buildingDetailEnabled ?? DisableDetailTextures; long scratchBudget = alphaScratchBudgetBytes ?? AlphaScratchBudgetProfile.Create( ResidencyBudgetOptions.Default.AlphaScratchBytes) @@ -137,21 +218,82 @@ public sealed unsafe partial class WbDrawDispatcher } } - private static MeshPipelineSet CreateMeshPipelineSet(IGpuDevice device, int samples) + private static bool DisableDetailTextures() => false; + + private static MeshPipelineSet CreateMeshPipelineSet( + IGpuDevice device, + int samples, + string baseShaderName = "mesh_modern", + GpuShaderSet? baseShaders = null, + string namePrefix = "wb-mesh", + bool usesRenderPackShaderAbi = false) { string suffix = samples > 1 ? string.Empty : "-1x"; - return new MeshPipelineSet( - samples, - CreateMeshPipeline( - device, $"wb-mesh-opaque{suffix}", GpuBlendMode.None, true, false, samples), - CreateMeshPipeline( - device, $"wb-mesh-opaque-a2c{suffix}", GpuBlendMode.None, true, true, samples), - CreateMeshPipeline( - device, $"wb-mesh-alpha{suffix}", GpuBlendMode.StraightAlpha, false, false, samples), - CreateMeshPipeline( - device, $"wb-mesh-additive{suffix}", GpuBlendMode.Additive, false, false, samples), - CreateMeshPipeline( - device, $"wb-mesh-inverse{suffix}", GpuBlendMode.InverseAlpha, false, false, samples)); + var created = new List(7); + try + { + return new MeshPipelineSet( + samples, + Track(CreateMeshPipeline( + device, $"{namePrefix}-opaque{suffix}", GpuBlendMode.None, true, false, samples, + shaders: baseShaders, + shaderName: baseShaderName, + usesRenderPackShaderAbi: usesRenderPackShaderAbi)), + Track(CreateMeshPipeline( + device, $"{namePrefix}-opaque-a2c{suffix}", GpuBlendMode.None, true, true, samples, + shaders: baseShaders, + shaderName: baseShaderName, + usesRenderPackShaderAbi: usesRenderPackShaderAbi)), + Track(CreateMeshPipeline( + device, $"{namePrefix}-alpha{suffix}", GpuBlendMode.StraightAlpha, false, false, samples, + shaders: baseShaders, + shaderName: baseShaderName, + usesRenderPackShaderAbi: usesRenderPackShaderAbi)), + Track(CreateMeshPipeline( + device, $"{namePrefix}-additive{suffix}", GpuBlendMode.Additive, false, false, samples, + shaders: baseShaders, + shaderName: baseShaderName, + usesRenderPackShaderAbi: usesRenderPackShaderAbi)), + Track(CreateMeshPipeline( + device, $"{namePrefix}-inverse{suffix}", GpuBlendMode.InverseAlpha, false, false, samples, + shaders: baseShaders, + shaderName: baseShaderName, + usesRenderPackShaderAbi: usesRenderPackShaderAbi)), + Track(CreateMeshPipeline( + device, + $"wb-mesh-retail-detail{suffix}", + GpuBlendMode.RetailDetail, + true, + false, + samples, + shaderName: "mesh_detail", + depthCompare: RetailDetailTextureContract.DetailDepthCompare( + transparent: false), + usesRenderPackShaderAbi: usesRenderPackShaderAbi)), + Track(CreateMeshPipeline( + device, + $"wb-mesh-retail-detail-alpha{suffix}", + GpuBlendMode.RetailDetail, + false, + false, + samples, + shaderName: "mesh_detail", + depthCompare: RetailDetailTextureContract.DetailDepthCompare( + transparent: true), + usesRenderPackShaderAbi: usesRenderPackShaderAbi))); + } + catch + { + for (int i = created.Count - 1; i >= 0; i--) + created[i].Dispose(); + throw; + } + + IGpuPipeline Track(IGpuPipeline pipeline) + { + created.Add(pipeline); + return pipeline; + } } /// @@ -183,19 +325,24 @@ public sealed unsafe partial class WbDrawDispatcher GpuBlendMode blend, bool depthWrite, bool alphaToCoverage, - int sampleCount) => + int sampleCount, + string shaderName = "mesh_modern", + GpuShaderSet? shaders = null, + GpuCompareOp depthCompare = GpuCompareOp.Less, + bool usesRenderPackShaderAbi = false) => device.CreatePipeline(new GpuPipelineDescription { Name = name, - Shaders = new GpuShaderSet("mesh_modern"), + Shaders = shaders ?? new GpuShaderSet(shaderName), VertexLayout = GpuVertexLayout.WorldMesh, Topology = GpuPrimitiveTopology.TriangleList, Blend = blend, - Depth = new GpuDepthState(Test: true, Write: depthWrite, GpuCompareOp.Less), + Depth = new GpuDepthState(Test: true, Write: depthWrite, depthCompare), Cull = GpuCullMode.Back, FrontFace = GpuFrontFace.Clockwise, AlphaToCoverage = alphaToCoverage, ColorWrite = true, + UsesRenderPackShaderAbi = usesRenderPackShaderAbi, SampleCount = sampleCount, }); @@ -230,19 +377,33 @@ public sealed unsafe partial class WbDrawDispatcher ParamB = 0f, }; + RhiSection instanceTransforms = WriteWorldTransformSection( + frame, + _instanceData.AsSpan(0, immediateInstances * 16), + out uint transformBaseInstance); + // Pack receiver/detail shaders subtract this shared-arena prefix for + // every parallel per-instance array while retaining the absolute pose + // lookup. The acdream default path always receives zero here. + pushConstants.TextureIndexB = transformBaseInstance; + // Bind the opaque variant first so the ring binds land on a live program; // the transparent bracket rebinds its own variant, and push constants // survive that switch per the encoder contract. - MeshPipelineSet pipelines = PipelinesFor(encoder); + MeshPipelineSet pipelines = PipelinesFor( + encoder, + frame, + out DirectionalShadowFrameBinding shadowBinding); BindPipelineWithMesh( encoder, AlphaToCoverage ? pipelines.OpaqueAlphaToCoverage : pipelines.Opaque, mesh); encoder.SetPushConstants(in pushConstants); + BindDirectionalShadowReceiver(encoder, in shadowBinding); - BindRingSection( - encoder, frame, GpuBindingModel.StorageInstances, - _instanceData.AsSpan(0, immediateInstances * 16)); + BindSection( + encoder, + GpuBindingModel.StorageInstances, + instanceTransforms); BindRingSection( encoder, frame, GpuBindingModel.StorageBatches, _batchData.AsSpan(0, totalDraws)); @@ -262,19 +423,25 @@ public sealed unsafe partial class WbDrawDispatcher BindRingSection( encoder, frame, GpuBindingModel.StorageInstanceSelectionLighting, _selectionLightingData.AsSpan(0, immediateInstances)); + BindRingSection( + encoder, frame, GpuBindingModel.StorageInstanceDetailCategory, + _detailCategoryData.AsSpan(0, immediateInstances)); AcDream.App.Rendering.WorldFrameSectionBinding.BindClipRegions( encoder, scope.Sections, frame); AcDream.App.Rendering.WorldFrameSectionBinding.BindSceneLighting( encoder, scope.Sections, frame); - GpuRingAllocation commands = frame.AllocateRing( - totalDraws * DrawCommandStride, - GpuRingUsage.Indirect); - MemoryMarshal.AsBytes(_indirectCommands.AsSpan(0, totalDraws)) - .CopyTo(commands.Data); + GpuRingAllocation commands = WriteIndirectCommands( + frame, + _indirectCommands.AsSpan(0, totalDraws), + transformBaseInstance); IGpuBuffer commandBuffer = commands.Buffer; uint commandBase = commands.OffsetBytes; + ReadOnlySpan usedCommands = + _indirectCommands.AsSpan(0, totalDraws); + ReadOnlySpan usedDetailCategories = + _detailCategoryData.AsSpan(0, immediateInstances); // ── Phase 7: opaque pass ───────────────────────────────────────────── if (_opaqueDrawCount > 0) @@ -295,10 +462,64 @@ public sealed unsafe partial class WbDrawDispatcher } } + // Retail DrawBuilding detail category (1). Only consecutive command + // runs containing a building are replayed; mesh_detail filters ordinary + // instances inside a mixed command. This includes ClipMap built-mesh + // subsets: the named retail + // DrawMesh path forwards curr_detail_surface to RenderMeshSubset for + // every material kind. The setting is read at draw time so the existing + // Options checkbox changes the live scene immediately. + if (_opaqueDrawCount > 0 + && RetailDetailTextureContract.ShouldRender( + _buildingDetailEnabled(), + _buildingDetail)) + { + int searchStart = 0; + if (TryGetNextDetailCommandRun( + usedCommands, + usedDetailCategories, + searchStart, + _opaqueDrawCount, + out DetailCommandRun run)) + { + BindPipelineWithMesh(encoder, pipelines.RetailDetail, mesh); + pushConstants.RenderPass = 0; + pushConstants.DrawIdOffset = 0; + pushConstants.TextureIndexA = _buildingDetail.TextureSlot.Index; + pushConstants.ParamA = _buildingDetail.Tiling; + pushConstants.ParamB = 1f; + encoder.SetPushConstants(in pushConstants); + using (BeginRhiTimer(encoder, diag, DetailTimerScope)) + { + do + { + DrawIndirectRangeRhi( + encoder, + ref pushConstants, + commandBuffer, + commandBase, + run.FirstCommand, + run.CommandCount); + searchStart = run.FirstCommand + run.CommandCount; + } + while (TryGetNextDetailCommandRun( + usedCommands, + usedDetailCategories, + searchStart, + _opaqueDrawCount, + out run)); + } + } + + // Later base passes expect neutral spare push fields. + pushConstants.TextureIndexA = 0; + pushConstants.ParamA = 0f; + pushConstants.ParamB = 0f; + } + // ── Phase 8: transparent pass ──────────────────────────────────────── if (_transparentDrawCount > 0) { - BindPipelineWithMesh(encoder, pipelines.AlphaBlend, mesh); // Issue #52 again: the transparent section starts at _opaqueDrawCount. // Without the offset each transparent draw reads the OPAQUE section // and the lifestone crystal's texture flickers. @@ -307,9 +528,14 @@ public sealed unsafe partial class WbDrawDispatcher encoder.SetPushConstants(in pushConstants); using (BeginRhiTimer(encoder, diag, TransparentTimerScope)) { - DrawIndirectRangeRhi( - encoder, ref pushConstants, commandBuffer, commandBase, - _opaqueDrawCount, _transparentDrawCount); + DrawImmediateTransparentRhi( + encoder, + mesh, + pipelines, + ref pushConstants, + commandBuffer, + commandBase, + usedDetailCategories); } } @@ -323,8 +549,13 @@ public sealed unsafe partial class WbDrawDispatcher /// private void PrepareRhiAlphaSections(int count) { + _preparedAlphaInstanceCount = count; IGpuFrame frame = RequireRhiFrame(); - _alphaInstances = WriteRingSection(frame, _instanceData.AsSpan(0, count * 16)); + _alphaInstances = WriteWorldTransformSection( + frame, + _instanceData.AsSpan(0, count * 16), + out uint transformBaseInstance); + _alphaTransformBaseInstance = transformBaseInstance; _alphaBatches = WriteRingSection(frame, _batchData.AsSpan(0, count)); _alphaClipSlots = WriteRingSection(frame, _clipSlotData.AsSpan(0, count)); int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); @@ -340,10 +571,17 @@ public sealed unsafe partial class WbDrawDispatcher _alphaSelectionLighting = WriteRingSection( frame, _selectionLightingData.AsSpan(0, count)); - _alphaCommands = WriteRingSection( + _alphaDetailCategory = WriteRingSection( + frame, + _detailCategoryData.AsSpan(0, count)); + GpuRingAllocation commands = WriteIndirectCommands( frame, _indirectCommands.AsSpan(0, count), - GpuRingUsage.Indirect); + transformBaseInstance); + _alphaCommands = new RhiSection( + commands.Buffer, + commands.OffsetBytes, + checked((uint)(count * DrawCommandStride))); } private void DrawPreparedAlphaBatchRhi( @@ -366,14 +604,18 @@ public sealed unsafe partial class WbDrawDispatcher RenderPass = 1, LightDebug = RenderingDiagnostics.LightDebugMode, TextureIndexA = 0, - TextureIndexB = 0, + TextureIndexB = _alphaTransformBaseInstance, ParamA = 0f, ParamB = 0f, }; - MeshPipelineSet pipelines = PipelinesFor(encoder); + MeshPipelineSet pipelines = PipelinesFor( + encoder, + frame, + out DirectionalShadowFrameBinding shadowBinding); BindPipelineWithMesh(encoder, pipelines.AlphaBlend, mesh); encoder.SetPushConstants(in pushConstants); + BindDirectionalShadowReceiver(encoder, in shadowBinding); BindSection(encoder, GpuBindingModel.StorageInstances, _alphaInstances); BindSection(encoder, GpuBindingModel.StorageBatches, _alphaBatches); BindSection(encoder, GpuBindingModel.StorageClipSlots, _alphaClipSlots); @@ -385,18 +627,45 @@ public sealed unsafe partial class WbDrawDispatcher encoder, GpuBindingModel.StorageInstanceSelectionLighting, _alphaSelectionLighting); + BindSection( + encoder, + GpuBindingModel.StorageInstanceDetailCategory, + _alphaDetailCategory); AcDream.App.Rendering.WorldFrameSectionBinding.BindClipRegions( encoder, scope.Sections, frame); AcDream.App.Rendering.WorldFrameSectionBinding.BindSceneLighting( encoder, scope.Sections, frame); + bool detailEnabled = RetailDetailTextureContract.ShouldRender( + _buildingDetailEnabled(), + _buildingDetail); + if (firstPreparedDraw < 0 + || drawCount < 0 + || firstPreparedDraw > _preparedAlphaInstanceCount - drawCount) + { + throw new ArgumentOutOfRangeException( + nameof(firstPreparedDraw), + "The prepared-alpha draw range exceeds its uploaded instance/category payload."); + } + ReadOnlySpan usedDetailCategories = + _detailCategoryData.AsSpan(0, _preparedAlphaInstanceCount); int runStart = firstPreparedDraw; int preparedEnd = firstPreparedDraw + drawCount; while (runStart < preparedEnd) { TranslucencyKind blend = _deferredAlphaKinds[runStart]; + bool hasDetail = detailEnabled + && CommandContainsDetailCategory( + _indirectCommands[runStart], + usedDetailCategories); int runEnd = runStart + 1; - while (runEnd < preparedEnd && _deferredAlphaKinds[runEnd] == blend) + while (runEnd < preparedEnd + && !hasDetail + && _deferredAlphaKinds[runEnd] == blend + && (!detailEnabled + || !CommandContainsDetailCategory( + _indirectCommands[runEnd], + usedDetailCategories))) runEnd++; // ApplyRetailBlend's three cases are three pipelines, including the @@ -410,10 +679,112 @@ public sealed unsafe partial class WbDrawDispatcher _alphaCommands.OffsetBytes, runStart, runEnd - runStart); + + if (hasDetail) + { + DrawBuildingDetailRangeRhi( + encoder, + mesh, + pipelines.RetailDetailTransparent, + ref pushConstants, + _alphaCommands.Buffer!, + _alphaCommands.OffsetBytes, + runStart, + 1); + } runStart = runEnd; } } + private void DrawImmediateTransparentRhi( + IGpuPassEncoder encoder, + GlobalMeshBuffer mesh, + MeshPipelineSet pipelines, + ref GpuPushConstants pushConstants, + IGpuBuffer commandBuffer, + uint commandBase, + ReadOnlySpan usedDetailCategories) + { + bool detailEnabled = RetailDetailTextureContract.ShouldRender( + _buildingDetailEnabled(), + _buildingDetail); + int command = _opaqueDrawCount; + int end = command + _transparentDrawCount; + while (command < end) + { + bool hasDetail = detailEnabled + && CommandContainsDetailCategory( + _indirectCommands[command], + usedDetailCategories); + int runEnd = command + 1; + while (runEnd < end + && !hasDetail + && (!detailEnabled + || !CommandContainsDetailCategory( + _indirectCommands[runEnd], + usedDetailCategories))) + { + runEnd++; + } + + BindPipelineWithMesh(encoder, pipelines.AlphaBlend, mesh); + ClearDetailPushConstants(ref pushConstants); + DrawIndirectRangeRhi( + encoder, + ref pushConstants, + commandBuffer, + commandBase, + command, + runEnd - command); + + if (hasDetail) + { + DrawBuildingDetailRangeRhi( + encoder, + mesh, + pipelines.RetailDetailTransparent, + ref pushConstants, + commandBuffer, + commandBase, + command, + 1); + } + command = runEnd; + } + } + + private void DrawBuildingDetailRangeRhi( + IGpuPassEncoder encoder, + GlobalMeshBuffer mesh, + IGpuPipeline detailPipeline, + ref GpuPushConstants pushConstants, + IGpuBuffer commandBuffer, + uint commandBase, + int firstCommand, + int commandCount) + { + BindPipelineWithMesh(encoder, detailPipeline, mesh); + pushConstants.TextureIndexA = _buildingDetail.TextureSlot.Index; + pushConstants.ParamA = _buildingDetail.Tiling; + pushConstants.ParamB = 1f; + DrawIndirectRangeRhi( + encoder, + ref pushConstants, + commandBuffer, + commandBase, + firstCommand, + commandCount); + ClearDetailPushConstants(ref pushConstants); + } + + private static void ClearDetailPushConstants( + ref GpuPushConstants pushConstants) + { + pushConstants.TextureIndexA = 0; + pushConstants.ParamA = 0f; + pushConstants.ParamB = 0f; + } + private static IGpuPipeline PipelineForBlend(MeshPipelineSet pipelines, TranslucencyKind blend) => blend switch { @@ -551,6 +922,95 @@ public sealed unsafe partial class WbDrawDispatcher return new RhiSection(allocation.Buffer, allocation.OffsetBytes, (uint)byteCount); } + private RhiSection WriteWorldTransformSection( + IGpuFrame frame, + ReadOnlySpan matrixFloats, + out uint firstInstance) + { + ResetWorldTransformFrameIfStale(frame.Serial); + if ((matrixFloats.Length & 15) != 0) + { + throw new ArgumentException( + "World transforms must contain complete 16-float matrices.", + nameof(matrixFloats)); + } + ObserveOrdinaryTransformDemand( + frame.Serial, + checked((uint)(matrixFloats.Length / 16))); + if (!_worldTransformFrames.IsActive) + { + firstInstance = 0; + return WriteRingSection(frame, matrixFloats); + } + + WorldTransformFrameSlice appended = _worldTransformFrames.Append( + frame, + MemoryMarshal.Cast(matrixFloats)); + firstInstance = appended.FirstInstance; + return new RhiSection( + appended.Buffer, + appended.BaseOffsetBytes, + appended.BindingSizeBytes); + } + + private static GpuRingAllocation WriteIndirectCommands( + IGpuFrame frame, + Span commands, + uint baseInstance) + { + int byteCount = checked(commands.Length * DrawCommandStride); + GpuRingAllocation allocation = frame.AllocateRing( + byteCount, + GpuRingUsage.Indirect); + if (baseInstance == 0) + { + MemoryMarshal.AsBytes(commands).CopyTo(allocation.Data); + return allocation; + } + + int adjusted = 0; + try + { + for (int i = 0; i < commands.Length; i++) + { + commands[i].BaseInstance = checked( + commands[i].BaseInstance + baseInstance); + adjusted++; + } + MemoryMarshal.AsBytes(commands).CopyTo(allocation.Data); + } + finally + { + for (int i = 0; i < adjusted; i++) + commands[i].BaseInstance -= baseInstance; + } + return allocation; + } + + private void ResetWorldTransformFrameIfStale(long frameSerial) + { + _worldTransformFrames.ResetIfStale(frameSerial); + } + + private void ObserveOrdinaryTransformDemand(long frameSerial, uint instances) + { + if (_ordinaryTransformDemandFrameSerial != frameSerial) + { + _ordinaryTransformDemandFrameSerial = frameSerial; + _ordinaryTransformDemandThisFrame = 0; + } + _ordinaryTransformDemandThisFrame = checked( + _ordinaryTransformDemandThisFrame + instances); + _ordinaryTransformDemandHighWater = Math.Max( + _ordinaryTransformDemandHighWater, + _ordinaryTransformDemandThisFrame); + } + + private void ResetWorldTransformFrame() + { + _worldTransformFrames.Reset(); + } + private IGpuFrame RequireRhiFrame() { // The same precondition ActivateNextDynamicBufferSet enforces on GL: a @@ -593,6 +1053,11 @@ public sealed unsafe partial class WbDrawDispatcher totalMs += transparentMs; any = true; } + if (_device.Timers.TryResolve(DetailTimerScope, out double detailMs)) + { + totalMs += detailMs; + any = true; + } if (!any) return; @@ -611,6 +1076,7 @@ public sealed unsafe partial class WbDrawDispatcher // there is one set and disposing it twice would be a double free. if (!ReferenceEquals(offscreen, backbuffer)) DisposeMeshPipelineSet(offscreen); + DisposeDirectionalShadowReceiverPipelines(); } private static void DisposeMeshPipelineSet(MeshPipelineSet? pipelines) @@ -622,6 +1088,8 @@ public sealed unsafe partial class WbDrawDispatcher pipelines.AlphaBlend.Dispose(); pipelines.AlphaAdditive.Dispose(); pipelines.AlphaInverse.Dispose(); + pipelines.RetailDetail.Dispose(); + pipelines.RetailDetailTransparent.Dispose(); } private sealed class NullRhiTimerScope : IDisposable diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index a540e0e3..6c36b5ea 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -505,6 +505,11 @@ public sealed partial class WbDrawDispatcher : IDisposable // Mechanically a clone of _clipSlotData. private uint[] _indoorData = new uint[256]; + // #226: per-instance retail detail category (binding=9), parallel to the + // transform buffer. 1 = building shell; 0 = ordinary object. Landscape + // and generic objects are intentionally never enabled by the retail caller. + private uint[] _detailCategoryData = new uint[256]; + // #188: per-instance opacity multiplier (binding=7), one float per // instance, parallel to the instance data. 1.0 = unmodified (the dat's own // material/texture alpha, untouched); < 1.0 multiplies the shader's @@ -539,6 +544,7 @@ public sealed partial class WbDrawDispatcher : IDisposable // (ParentCellId is an EnvCell). Appended to InstanceGroup.IndoorFlags in // AppendCurrentLightSet; uploaded as binding=6 instanceIndoor[]. private bool _currentEntityIndoor; + private bool _currentEntityBuildingDetail; private Vector2 _currentEntitySelectionLighting = new(0f, 1f); // Phase U.3: the SHARED per-cell clip-region SSBO (binding=2) id, owned by @@ -593,13 +599,13 @@ public sealed partial class WbDrawDispatcher : IDisposable // every existing CPU writer's offsets are unchanged (see // GpuBindingModel.GpuBatchDataStrideBytes). TextureIndex used to be a // 64-bit ulong TextureHandle (an ARB_bindless_texture handle, uvec2 in - // GLSL); it is now a slot into the binding=9 handle table + // GLSL); it is now a slot into the device texture table // (mesh_modern.vert's BatchData.textureIndex / ACDREAM_TEXTURE_HANDLE), // which is why the struct only needs 4-byte (not 8-byte) packing now. [StructLayout(LayoutKind.Sequential, Pack = 4)] private struct BatchData { - public uint TextureIndex; // slot into the binding=9 handle table + public uint TextureIndex; // slot into the device texture table public uint Reserved; // pad — keeps TextureLayer/Flags at offsets 8/12 public uint TextureLayer; public uint Flags; @@ -611,6 +617,7 @@ public sealed partial class WbDrawDispatcher : IDisposable uint ClipSlot, InstanceLightSet Lights, uint Indoor, + uint DetailCategory, float Opacity, Vector2 SelectionLighting); @@ -679,6 +686,7 @@ public sealed partial class WbDrawDispatcher : IDisposable + (long)_clipSlotData.Length * sizeof(uint) + (long)_lightSetData.Length * sizeof(int) + (long)_indoorData.Length * sizeof(uint) + + (long)_detailCategoryData.Length * sizeof(uint) + (long)_alphaData.Length * sizeof(float) + (long)_selectionLightingData.Length * Unsafe.SizeOf() + (long)_batchData.Length * Unsafe.SizeOf() @@ -795,6 +803,7 @@ public sealed partial class WbDrawDispatcher : IDisposable public void BeginFrame(int frameSlot) { _ = frameSlot; + ResetWorldTransformFrame(); if (_groupFrame == long.MaxValue) throw new InvalidOperationException("Instance-group frame identity was exhausted."); @@ -1619,6 +1628,7 @@ public sealed partial class WbDrawDispatcher : IDisposable // is constant across the entity's parts/tuples), by the entity's // bounding sphere — camera-INDEPENDENT (minimize_object_lighting). ComputeEntityLightSet(entity); + _currentEntityBuildingDetail = entity.IsBuildingShell; _currentEntitySelectionLighting = _selectionLighting?.TryGetLighting( entity.ServerGuid, @@ -2107,6 +2117,9 @@ public sealed partial class WbDrawDispatcher : IDisposable if (_indoorData.Length < immediateInstances) _indoorData = new uint[immediateInstances + 256]; + if (_detailCategoryData.Length < immediateInstances) + _detailCategoryData = new uint[immediateInstances + 256]; + // #188: per-instance opacity buffer, one float per instance, parallel to // _clipSlotData / _instanceData. Grown on demand like the others. if (_alphaData.Length < immediateInstances) @@ -2336,6 +2349,7 @@ public sealed partial class WbDrawDispatcher : IDisposable _lightSetData, cursor * LightManager.MaxLightsPerObject); _indoorData[cursor] = group.IndoorFlags[i]; + _detailCategoryData[cursor] = group.DetailCategories[i]; _alphaData[cursor] = group.Opacities[i]; _selectionLightingData[cursor] = group.SelectionLighting[i]; cursor++; @@ -2623,6 +2637,7 @@ public sealed partial class WbDrawDispatcher : IDisposable hash.Add(lights[lightIndex]); } hash.Add(group.IndoorFlags[index]); + hash.Add(group.DetailCategories[index]); hash.Add(group.Opacities[index]); hash.Add(group.SelectionLighting[index]); } @@ -2679,6 +2694,7 @@ public sealed partial class WbDrawDispatcher : IDisposable group.Slots[i], group.LightSets[i], group.IndoorFlags[i], + group.DetailCategories[i], group.Opacities[i], group.SelectionLighting[i])); queue.Submit( @@ -2721,6 +2737,7 @@ public sealed partial class WbDrawDispatcher : IDisposable WriteMatrix(_instanceData, i * 16, entry.Model); _clipSlotData[i] = entry.ClipSlot; _indoorData[i] = entry.Indoor; + _detailCategoryData[i] = entry.DetailCategory; _alphaData[i] = entry.Opacity; _selectionLightingData[i] = entry.SelectionLighting; int lightOffset = i * LightManager.MaxLightsPerObject; @@ -2732,7 +2749,11 @@ public sealed partial class WbDrawDispatcher : IDisposable // Campaign V slice V2: table slot, not the raw handle. TextureIndex = key.TextureSlot.Index, TextureLayer = key.TextureLayer, - Flags = 0, + // DrawMesh invokes RenderMeshSubset with detail enabled for + // every built-mesh material subset while curr_detail_surface + // is installed. The per-instance category still rejects + // ordinary objects in mesh_detail. + Flags = 1, }; _indirectCommands[i] = new DrawElementsIndirectCommand { @@ -2781,6 +2802,8 @@ public sealed partial class WbDrawDispatcher : IDisposable _clipSlotData = new uint[count + 256]; if (_indoorData.Length < count) _indoorData = new uint[count + 256]; + if (_detailCategoryData.Length < count) + _detailCategoryData = new uint[count + 256]; if (_alphaData.Length < count) _alphaData = new float[count + 256]; if (_selectionLightingData.Length < count) @@ -2820,6 +2843,7 @@ public sealed partial class WbDrawDispatcher : IDisposable int bytesPerUnit = checked( 16 * sizeof(float) + sizeof(uint) + + sizeof(uint) + LightManager.MaxLightsPerObject * sizeof(int) + sizeof(uint) + sizeof(float) @@ -2844,6 +2868,7 @@ public sealed partial class WbDrawDispatcher : IDisposable _lightSetData = new int[ checked(targetCapacity * LightManager.MaxLightsPerObject)]; _indoorData = new uint[targetCapacity]; + _detailCategoryData = new uint[targetCapacity]; _alphaData = new float[targetCapacity]; _selectionLightingData = new Vector2[targetCapacity]; _batchData = new BatchData[targetCapacity]; @@ -3271,6 +3296,7 @@ public sealed partial class WbDrawDispatcher : IDisposable { grp.LightSets.Add(_currentEntityLightSet); grp.IndoorFlags.Add(_currentEntityIndoor ? 1u : 0u); // #142, parallel to the light block + grp.DetailCategories.Add(_currentEntityBuildingDetail ? 1u : 0u); // #226 } private bool ClassifyBatches( @@ -3527,8 +3553,8 @@ public sealed partial class WbDrawDispatcher : IDisposable /// /// Public view of the per-group inputs to — used in tests. - /// Campaign V slice V2: TextureIndex is a slot into the binding=9 - /// handle table (was a raw 64-bit bindless TextureHandle). + /// Campaign V slice V2: TextureIndex is a slot into the device's + /// texture table (was a raw 64-bit bindless TextureHandle). /// public readonly record struct IndirectGroupInput( int IndexCount, @@ -3602,7 +3628,10 @@ public sealed partial class WbDrawDispatcher : IDisposable TextureIndex = g.TextureIndex, Reserved = 0, TextureLayer = g.TextureLayer, - Flags = 0, + // #226: this is the built-mesh path. Retail DrawMesh passes + // curr_detail_surface through RenderMeshSubset for opaque, + // ClipMap, alpha, additive and inverse-alpha subsets alike. + Flags = 1u, }; if (IsOpaque(g.Translucency)) @@ -3631,6 +3660,85 @@ public sealed partial class WbDrawDispatcher : IDisposable /// public static bool IsOpaquePublic(TranslucencyKind t) => IsOpaque(t); + internal readonly record struct DetailCommandRun( + int FirstCommand, + int CommandCount); + + /// + /// Finds the next consecutive run containing at least one building instance + /// per command. Commands with no building instances are never submitted to + /// the detail pipeline; a mixed command remains eligible and relies on the + /// shader's per-instance category filter. + /// + internal static bool TryGetNextDetailCommandRun( + ReadOnlySpan commands, + ReadOnlySpan detailCategories, + int searchStart, + int exclusiveEnd, + out DetailCommandRun run) + { + if (searchStart < 0 + || exclusiveEnd < searchStart + || exclusiveEnd > commands.Length) + { + throw new ArgumentOutOfRangeException( + nameof(searchStart), + "The requested detail-command search range is invalid."); + } + + int first = searchStart; + while (first < exclusiveEnd + && !CommandContainsDetailCategory( + commands[first], + detailCategories)) + { + first++; + } + if (first == exclusiveEnd) + { + run = default; + return false; + } + + int end = first + 1; + while (end < exclusiveEnd + && CommandContainsDetailCategory( + commands[end], + detailCategories)) + { + end++; + } + run = new DetailCommandRun(first, end - first); + return true; + } + + /// + /// Returns whether an indirect command contains at least one building + /// instance. Retail's detail fallback redraws the exact built-mesh subset + /// immediately after its transparent base subset; this predicate lets the + /// Vulkan arm preserve that adjacency without replaying ordinary objects. + /// + internal static bool CommandContainsDetailCategory( + DrawElementsIndirectCommand command, + ReadOnlySpan detailCategories) + { + ulong first = command.BaseInstance; + ulong end = first + command.InstanceCount; + if (end > (ulong)detailCategories.Length) + { + throw new ArgumentOutOfRangeException( + nameof(command), + "The indirect instance range exceeds the detail-category buffer."); + } + + for (ulong index = first; index < end; index++) + { + if (detailCategories[(int)index] != 0u) + return true; + } + return false; + } + private static bool IsOpaque(TranslucencyKind t) => t == TranslucencyKind.Opaque || t == TranslucencyKind.ClipMap; @@ -3724,6 +3832,10 @@ public sealed partial class WbDrawDispatcher : IDisposable // cursor as Matrices, so binding=6 instanceIndoor[] tracks binding=0. public readonly List IndoorFlags = new(); + // #226: 1 for a building-shell instance, 0 otherwise. Parallel to + // Matrices and uploaded at storage binding 9 for the detail replay. + public readonly List DetailCategories = new(); + // #188: per-instance opacity multiplier, parallel to Matrices. // Opacities[i] is 1.0=unmodified, or <1.0 while a TransparentPartHook // fade is in flight for the instance whose matrix is Matrices[i]. At @@ -3753,6 +3865,7 @@ public sealed partial class WbDrawDispatcher : IDisposable Slots.Clear(); LightSets.Clear(); IndoorFlags.Clear(); + DetailCategories.Clear(); Opacities.Clear(); SelectionLighting.Clear(); } @@ -3766,6 +3879,7 @@ public sealed partial class WbDrawDispatcher : IDisposable Slots.TrimExcess(); LightSets.TrimExcess(); IndoorFlags.TrimExcess(); + DetailCategories.TrimExcess(); Opacities.TrimExcess(); SelectionLighting.TrimExcess(); } diff --git a/src/AcDream.App/Rendering/Wb/WorldTransformFrameArena.cs b/src/AcDream.App/Rendering/Wb/WorldTransformFrameArena.cs new file mode 100644 index 00000000..eae4fec3 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/WorldTransformFrameArena.cs @@ -0,0 +1,264 @@ +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Stable non-ref view of the one pack-on world-transform allocation for a +/// render frame. Shadow and ordinary world commands address different matrix +/// ranges through BaseInstance, but bind this exact buffer, offset, and +/// range. The matrices remain the already-published N.5 values; this type owns +/// no animation or scene projection. +/// +internal readonly record struct WorldTransformFrameSlice( + long FrameSerial, + IGpuBuffer Buffer, + uint BaseOffsetBytes, + uint BindingSizeBytes, + uint FirstInstance, + uint InstanceCount) +{ + internal bool IsValidFor(IGpuFrame frame) => + FrameSerial == frame.Serial + && Buffer is not null + && BindingSizeBytes >= checked( + (FirstInstance + InstanceCount) * WorldTransformCapacityPolicy.MatrixBytes) + && BindingSizeBytes % WorldTransformCapacityPolicy.MatrixBytes == 0u + && BaseOffsetBytes % WorldTransformCapacityPolicy.MatrixBytes == 0u + && checked((long)BaseOffsetBytes + BindingSizeBytes) <= Buffer.SizeBytes; +} + +/// +/// Demand-sized policy for the one enhanced-frame pose address space. The +/// 68,395-matrix connected dense row is the bootstrap observation, not a hard +/// ceiling: allocations grow in 64 KiB pages and stop only at the adapter's +/// probed maxStorageBufferRange. Vulkan guarantees that limit is at least +/// 128 MiB (2,097,152 matrices). +/// +internal static class WorldTransformCapacityPolicy +{ + internal const uint MatrixBytes = 64u; + internal const uint ConnectedDenseBootstrapInstances = 68_395u; + internal const uint AllocationQuantumBytes = 64u * 1024u; + internal const uint InitialBindingSizeBytes = + ((ConnectedDenseBootstrapInstances * MatrixBytes + + AllocationQuantumBytes - 1u) / AllocationQuantumBytes) + * AllocationQuantumBytes; + internal const uint VulkanGuaranteedMaxStorageBufferRangeBytes = + 128u * 1024u * 1024u; + + internal static uint ResolveBindingSizeBytes( + uint requiredInstances, + uint maxStorageBufferRangeBytes) + { + uint maximum = maxStorageBufferRangeBytes + - (maxStorageBufferRangeBytes % MatrixBytes); + ulong requiredBytes = (ulong)requiredInstances * MatrixBytes; + if (maximum < MatrixBytes || requiredBytes > maximum) + { + throw new NotSupportedException( + $"The enhanced frame needs {requiredInstances:N0} world matrices " + + $"({requiredBytes:N0} bytes), but this adapter exposes only " + + $"{maximum:N0} bytes through one storage-buffer binding. " + + "The pack will fail safe rather than split the authoritative pose buffer."); + } + + ulong targetBytes = Math.Max( + requiredBytes, + (ulong)ConnectedDenseBootstrapInstances * MatrixBytes); + ulong growthBytes = checked( + ((targetBytes + AllocationQuantumBytes - 1u) + / AllocationQuantumBytes) + * AllocationQuantumBytes); + + // An implementation is allowed to expose a non-page-aligned maximum. + // Use all of it when it is below the preferred growth page, provided + // the current demand still fits. + return (uint)Math.Min(growthBytes, maximum); + } + + internal static void ValidateBindingSizeBytes( + uint bindingSizeBytes, + uint requiredInstances, + uint maxStorageBufferRangeBytes) + { + ulong requiredBytes = (ulong)requiredInstances * MatrixBytes; + if (bindingSizeBytes < requiredBytes + || bindingSizeBytes % MatrixBytes != 0u + || bindingSizeBytes > maxStorageBufferRangeBytes) + { + throw new ArgumentOutOfRangeException( + nameof(bindingSizeBytes), + bindingSizeBytes, + $"A shared world-transform binding must be matrix-aligned, contain " + + $"all {requiredInstances:N0} matrices, and not exceed the adapter's " + + $"{maxStorageBufferRangeBytes:N0}-byte storage range."); + } + } +} + +/// +/// Frame-local address allocator over the pack-on N.5 transform block. The +/// backing buffer may be a frame-ring allocation or the active pack's retained +/// flight-slot arena. It publishes the prepared shadow prefix once and then +/// only appends already-built ordinary world matrices. It deliberately has no +/// scene, animation, or transform-derivation dependency. +/// +internal sealed class WorldTransformFrameArena +{ + private WorldTransformFrameSlice _allocation; + private uint _usedBytes; + + internal bool IsActive => _allocation.Buffer is not null; + + internal uint UsedInstances => _usedBytes / 64u; + + internal WorldTransformFrameSlice Begin( + IGpuFrame frame, + ReadOnlySpan transforms) => Begin( + frame, + transforms, + WorldTransformCapacityPolicy.ResolveBindingSizeBytes( + checked((uint)transforms.Length), + WorldTransformCapacityPolicy.VulkanGuaranteedMaxStorageBufferRangeBytes)); + + internal WorldTransformFrameSlice Begin( + IGpuFrame frame, + ReadOnlySpan transforms, + uint bindingSizeBytes) + { + ArgumentNullException.ThrowIfNull(frame); + ResetIfStale(frame.Serial); + if (IsActive) + { + throw new InvalidOperationException( + "The directional-shadow transform frame was already published."); + } + + uint byteCount = checked((uint)(transforms.Length * WorldTransformCapacityPolicy.MatrixBytes)); + if (bindingSizeBytes < byteCount + || bindingSizeBytes % WorldTransformCapacityPolicy.MatrixBytes != 0u) + throw new ArgumentOutOfRangeException(nameof(bindingSizeBytes)); + + GpuRingAllocation allocation = frame.AllocateRing( + checked((int)bindingSizeBytes), + GpuRingUsage.Storage); + if (!transforms.IsEmpty) + MemoryMarshal.AsBytes(transforms).CopyTo(allocation.Data); + + _allocation = new WorldTransformFrameSlice( + frame.Serial, + allocation.Buffer, + allocation.OffsetBytes, + bindingSizeBytes, + FirstInstance: 0, + checked((uint)transforms.Length)); + _usedBytes = byteCount; + return _allocation; + } + + /// + /// Activates a pack-owned flight-slot arena whose shadow prefix was already + /// published. Ordinary N.5 matrices append after that prefix and bind this + /// exact buffer/range, preserving the one authoritative pose SSBO without + /// copying stable shadow matrices through the frame ring. + /// + internal WorldTransformFrameSlice BeginRetained( + IGpuFrame frame, + in WorldTransformFrameSlice shadowPrefix) + { + ArgumentNullException.ThrowIfNull(frame); + ResetIfStale(frame.Serial); + if (IsActive) + { + throw new InvalidOperationException( + "The directional-shadow transform frame was already published."); + } + if (!shadowPrefix.IsValidFor(frame) + || shadowPrefix.FirstInstance != 0 + || shadowPrefix.Buffer.Residency != GpuMemoryResidency.HostWritable + || !shadowPrefix.Buffer.HostWritesAreCoherent + || !shadowPrefix.Buffer.Usage.HasFlag(GpuBufferUsage.Storage)) + { + throw new ArgumentException( + "The retained shadow prefix must be this frame's host-writable " + + "matrix-aligned storage arena at base instance zero.", + nameof(shadowPrefix)); + } + + uint byteCount = checked( + shadowPrefix.InstanceCount * WorldTransformCapacityPolicy.MatrixBytes); + if (byteCount > shadowPrefix.BindingSizeBytes) + { + throw new ArgumentException( + "The retained shadow prefix exceeds its storage binding.", + nameof(shadowPrefix)); + } + + _allocation = shadowPrefix; + _usedBytes = byteCount; + return _allocation; + } + + internal WorldTransformFrameSlice Append( + IGpuFrame frame, + ReadOnlySpan transforms) + { + ArgumentNullException.ThrowIfNull(frame); + ResetIfStale(frame.Serial); + if (!IsActive) + { + throw new InvalidOperationException( + "The shared world-transform frame has not been published."); + } + + uint byteCount = checked( + (uint)(transforms.Length * WorldTransformCapacityPolicy.MatrixBytes)); + uint start = _usedBytes; + uint end = checked(start + byteCount); + if (end > _allocation.BindingSizeBytes) + { + throw new InvalidOperationException( + $"The enhanced frame needs {end / WorldTransformCapacityPolicy.MatrixBytes:N0} world matrices; " + + $"this frame's shared transform binding contains " + + $"{_allocation.BindingSizeBytes / WorldTransformCapacityPolicy.MatrixBytes:N0}. " + + "The pack will fail safe rather than bind a second pose buffer."); + } + + if (!transforms.IsEmpty) + { + _allocation.Buffer.Upload( + checked((long)_allocation.BaseOffsetBytes + start), + MemoryMarshal.AsBytes(transforms)); + } + _usedBytes = end; + return _allocation with + { + FirstInstance = start / WorldTransformCapacityPolicy.MatrixBytes, + InstanceCount = checked((uint)transforms.Length), + }; + } + + internal bool IsActiveFor(long frameSerial) => + IsActive && _allocation.FrameSerial == frameSerial; + + internal void Cancel(IGpuFrame frame) + { + ArgumentNullException.ThrowIfNull(frame); + if (IsActiveFor(frame.Serial)) + Reset(); + } + + internal void ResetIfStale(long frameSerial) + { + if (IsActive && _allocation.FrameSerial != frameSerial) + Reset(); + } + + internal void Reset() + { + _allocation = default; + _usedBytes = 0; + } +} diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs index b5b6554e..9ab24c9e 100644 --- a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs +++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs @@ -2,6 +2,7 @@ using System.Numerics; using AcDream.App.Audio; using AcDream.App.Input; using AcDream.App.Rendering.Selection; +using AcDream.App.Rendering.Packs; using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Wb; using AcDream.App.Settings; @@ -60,6 +61,21 @@ internal readonly record struct WorldRenderFrame( HashSet AnimatedEntityIds) { public LoadedCell? ClipRoot => Roots.ViewerRoot ?? Buildings.OutdoorNode; + + /// + /// Pack-on-only borrowed publication fact. Retail does not read it. The + /// world scene attaches it after the ordinary frame has resolved the exact + /// render centre, so shadow fitting cannot infer reach from a configured + /// radius or touch streaming ownership. + /// + public ResidentStreamingWindowFact ResidentStreamingWindow { get; init; } + + /// + /// Pack-on-only authored celestial direction. The authoritative retail + /// lighting path ignores this fact; it exists solely for the opt-in + /// directional-shadow prepass. + /// + public AuthoredCelestialShadowSource CelestialShadowSource { get; init; } } internal interface IWorldRenderFrameBuilder diff --git a/src/AcDream.App/Rendering/WorldSceneRenderer.cs b/src/AcDream.App/Rendering/WorldSceneRenderer.cs index bb8b93d7..9d7ec0d1 100644 --- a/src/AcDream.App/Rendering/WorldSceneRenderer.cs +++ b/src/AcDream.App/Rendering/WorldSceneRenderer.cs @@ -1,5 +1,6 @@ using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Selection; +using AcDream.App.Rendering.Packs; using AcDream.App.Rendering.Vfx; using AcDream.App.Streaming; using AcDream.Core.Rendering; @@ -15,6 +16,28 @@ internal interface IWorldScenePViewRenderer void AbortFrame(); } +internal readonly record struct PreparedWorldSceneFrame( + bool ShouldRender, + RenderFrameFoundation Foundation, + WorldRenderFrame World, + int ActiveDayGroup); + +/// +/// Pack-on-only two-stage seam. Preparation resolves the canonical camera and +/// borrowed world roots once; the shadow prepass may consume those exact facts +/// before the normal world pass executes them without a second PView/build. +/// +internal interface IPreparedWorldSceneFramePhase : IWorldSceneFramePhase +{ + PreparedWorldSceneFrame PrepareEnhanced(RenderFrameInput input); + + WorldRenderFrameOutcome RenderPreparedEnhanced( + RenderFrameInput input, + in PreparedWorldSceneFrame prepared); + + void CancelPreparedEnhanced(in PreparedWorldSceneFrame prepared); +} + internal sealed class WorldScenePViewRenderer : IWorldScenePViewRenderer { private readonly RetailPViewRenderer _renderer; @@ -46,7 +69,7 @@ internal sealed class WorldScenePViewRenderer : IWorldScenePViewRenderer /// decision: a resolved viewer root uses DrawInside; only an unresolved root /// uses the flat safety path. /// -internal sealed class WorldSceneRenderer : IWorldSceneFramePhase +internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase { private readonly IRenderFrameFoundationSource _foundation; private readonly IRenderLoginStateSource _login; @@ -62,7 +85,10 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase private readonly IWorldRenderRangeSource _renderRange; private readonly IWorldSceneDiagnostics _diagnostics; private readonly IWorldGenerationAvailability _availability; + private readonly IAtmosphericWorldFrameSink? _atmosphere; private readonly RetailPViewFrameInput _pviewFrameInput = new(); + private WorldRenderFrame _preparedEnhancedWorld; + private bool _hasPreparedEnhancedWorld; public WorldSceneRenderer( IRenderFrameFoundationSource foundation, @@ -78,7 +104,8 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase IWorldScenePassExecutor passes, IWorldRenderRangeSource renderRange, IWorldSceneDiagnostics diagnostics, - IWorldGenerationAvailability? availability = null) + IWorldGenerationAvailability? availability = null, + IAtmosphericWorldFrameSink? atmosphere = null) { _foundation = foundation ?? throw new ArgumentNullException(nameof(foundation)); _login = login ?? throw new ArgumentNullException(nameof(login)); @@ -95,6 +122,7 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase _renderRange = renderRange ?? throw new ArgumentNullException(nameof(renderRange)); _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); _availability = availability ?? AlwaysAvailableWorldGeneration.Instance; + _atmosphere = atmosphere; } public WorldRenderFrameOutcome Render(RenderFrameInput input) @@ -105,7 +133,15 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase bool pviewFrameStarted = false; try { - _selection?.BeginFrame(); + // Enhanced rendering builds the canonical camera before this world + // transaction so the shadow prepass can consume it. Carry that + // exact frustum across BeginFrame: clearing it here would make every + // subsequently drawn part fail RetailSelectionScene's visibility + // gate, leaving radar selection alive but disabling world picking. + FrustumPlanes? preparedSelectionFrustum = _hasPreparedEnhancedWorld + ? _preparedEnhancedWorld.Camera.Frustum + : null; + _selection?.BeginFrame(preparedSelectionFrustum); if (!_availability.IsWorldAvailable) { _selection?.CompleteFrame(); @@ -125,10 +161,23 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase // the same abort path instead of making every later frame fail. worldFrameStarted = true; _alpha.BeginFrame(); - WorldRenderFrame world = _frames.Build( + WorldRenderFrame world; + if (_hasPreparedEnhancedWorld) + { + world = _preparedEnhancedWorld; + _hasPreparedEnhancedWorld = false; + } + else + { + world = _frames.Build( + in foundation, + _login.IsWaitingForLogin, + _sky.ActiveDayGroup); + } + _atmosphere?.Publish( in foundation, - _login.IsWaitingForLogin, - _sky.ActiveDayGroup); + in world, + _sky.ActiveDayGroupIndex); _passes.BeginFrame(); WorldCameraFrame camera = world.Camera; @@ -311,6 +360,78 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase } } + public PreparedWorldSceneFrame PrepareEnhanced(RenderFrameInput input) + { + _ = input; + if (_hasPreparedEnhancedWorld) + { + throw new InvalidOperationException( + "The prior enhanced world preparation was not consumed."); + } + + RenderFrameFoundation foundation = _foundation.Foundation; + if (!_availability.IsWorldAvailable || foundation.PortalViewportVisible) + return new PreparedWorldSceneFrame(false, foundation, default, -1); + + WorldRenderFrame world = _frames.Build( + in foundation, + _login.IsWaitingForLogin, + _sky.ActiveDayGroup); + WorldRootFrame roots = world.Roots; + world = world with + { + ResidentStreamingWindow = + _entities.CaptureResidentStreamingWindow( + roots.RenderCenterLandblockX, + roots.RenderCenterLandblockY), + CelestialShadowSource = + AuthoredCelestialShadowSourceResolver.Resolve( + _sky.ActiveDayGroup, + _sky.DayFraction, + foundation.Sky), + }; + _preparedEnhancedWorld = world; + _hasPreparedEnhancedWorld = true; + return new PreparedWorldSceneFrame( + true, + foundation, + world, + _sky.ActiveDayGroupIndex); + } + + public WorldRenderFrameOutcome RenderPreparedEnhanced( + RenderFrameInput input, + in PreparedWorldSceneFrame prepared) + { + if (prepared.ShouldRender != _hasPreparedEnhancedWorld) + { + throw new InvalidOperationException( + "The prepared enhanced-world token does not match the pending frame."); + } + + try + { + return Render(input); + } + finally + { + // A gate may change between prepare and execute. Never let that + // exceptional edge leak borrowed builder scratch into another frame. + _hasPreparedEnhancedWorld = false; + _preparedEnhancedWorld = default; + } + } + + public void CancelPreparedEnhanced(in PreparedWorldSceneFrame prepared) + { + if (!prepared.ShouldRender) + return; + if (!_hasPreparedEnhancedWorld) + return; + _hasPreparedEnhancedWorld = false; + _preparedEnhancedWorld = default; + } + private void CompleteWorldFrame() { _alpha.EndFrame(); diff --git a/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs b/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs index e8615988..4e2c188b 100644 --- a/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs +++ b/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs @@ -10,6 +10,8 @@ internal interface IWorldSceneSkyStateSource { DayGroupData? ActiveDayGroup { get; } + int ActiveDayGroupIndex => 0; + float DayFraction { get; } } @@ -21,6 +23,10 @@ internal interface IWorldSceneEntitySource { get; } IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds { get; } + + ResidentStreamingWindowFact CaptureResidentStreamingWindow( + int centerX, + int centerY) => ResidentStreamingWindowFact.Unavailable(revision: 0); } internal sealed class RuntimeWorldSceneEntitySource : IWorldSceneEntitySource @@ -39,6 +45,11 @@ internal sealed class RuntimeWorldSceneEntitySource : IWorldSceneEntitySource public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds => _world.LandblockBounds; + + public ResidentStreamingWindowFact CaptureResidentStreamingWindow( + int centerX, + int centerY) => + _world.CaptureResidentStreamingWindow(centerX, centerY); } internal interface IWorldScenePViewDiagnosticSource diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 1d69d3e9..635d513e 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -63,9 +63,24 @@ public sealed record RuntimeOptions( bool UiProbeDump, string? UiProbeScript, string? AutomationArtifactDirectory, + /// Diagnostic-only request for an exact automation framebuffer. + /// The graphical host uses the persisted display resolution as the initial + /// size of a borderless window so the OS cannot clamp a decorated window to + /// the desktop work area. False for every ordinary launch. + bool ExactAutomationFramebuffer, int? ForcedDayGroupIndex, float? PinnedWorldDayFraction, float? SkyAnimationPhaseSeconds, + /// Diagnostic initial distance for the offline orbit camera, in + /// metres. Null for every ordinary launch; used by deterministic renderer + /// acceptance captures that need receivers inside a finite shadow reach. + float? InitialOrbitDistanceMeters, + /// Diagnostic-only initial orbit heading in degrees. Null keeps + /// the normal camera default. + float? InitialOrbitYawDegrees, + /// Diagnostic-only initial orbit elevation in degrees. Null keeps + /// the normal camera default. + float? InitialOrbitPitchDegrees, float FogStartMultiplier, float FogEndMultiplier, ResidencyBudgetOptions ResidencyBudgets, @@ -159,6 +174,8 @@ public sealed record RuntimeOptions( UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT")), AutomationArtifactDirectory: NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR")), + ExactAutomationFramebuffer: + IsExactlyOne(env("ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER")), ForcedDayGroupIndex: TryParseNonNegativeInt(env("ACDREAM_DAY_GROUP")), // Campaign V slice V7 instrument determinism: pins the Dereth day @@ -182,6 +199,14 @@ public sealed record RuntimeOptions( // per axis, so any finite number is a valid phase. SkyAnimationPhaseSeconds: TryParseFloat(env("ACDREAM_SKY_PHASE_SECONDS")), + InitialOrbitDistanceMeters: + TryParsePositiveFiniteFloat( + env("ACDREAM_ORBIT_DISTANCE_METERS")), + InitialOrbitYawDegrees: + TryParseFiniteFloat(env("ACDREAM_ORBIT_YAW_DEGREES")), + InitialOrbitPitchDegrees: + TryParseOrbitPitchDegrees( + env("ACDREAM_ORBIT_PITCH_DEGREES")), FogStartMultiplier: TryParseFloat(env("ACDREAM_FOG_START_MULT")) ?? 0.7f, FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f, ResidencyBudgets: ResidencyBudgetOptions.Parse(env), @@ -348,4 +373,22 @@ public sealed record RuntimeOptions( => float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out float value) ? value : null; + + private static float? TryParsePositiveFiniteFloat(string? s) + => TryParseFloat(s) is { } value + && float.IsFinite(value) + && value > 0f + ? value + : null; + + private static float? TryParseFiniteFloat(string? s) + => TryParseFloat(s) is { } value && float.IsFinite(value) + ? value + : null; + + private static float? TryParseOrbitPitchDegrees(string? s) + => TryParseFiniteFloat(s) is { } value + && value is >= -89f and <= 89f + ? value + : null; } diff --git a/src/AcDream.App/Settings/RuntimeSettingsController.cs b/src/AcDream.App/Settings/RuntimeSettingsController.cs index d22dbb46..7838812d 100644 --- a/src/AcDream.App/Settings/RuntimeSettingsController.cs +++ b/src/AcDream.App/Settings/RuntimeSettingsController.cs @@ -76,14 +76,14 @@ internal sealed record RuntimeSettingsSnapshot( internal interface IRuntimeSettingsStartupTarget { - void ApplyDisplay(DisplaySettings display); + RuntimeDisplayApplyResult ApplyDisplay(DisplaySettings display); void ApplyAudio(AudioSettings audio); } internal interface IRuntimeSettingsTargets { - void ApplyDisplayWindowState(DisplaySettings display); + RuntimeDisplayApplyResult ApplyDisplayWindowState(DisplaySettings display); /// /// Campaign OP slice OP6 (2026-08-11): pushes the CURRENT audio @@ -195,6 +195,13 @@ internal sealed class RuntimeSettingsController : public QualitySettings ResolvedQuality { get; private set; } + /// + /// Successful committed display changes. Render-pack selection listens to + /// this edge and defers the actual candidate swap to its next stable frame + /// boundary; failed persistence never changes the live selection. + /// + public event Action? DisplayChanged; + // OP9: the optional developer-tools draft-preview view model // (SettingsVM) was retired — it had zero production construction // sites (only tests ever called CreateViewModel). HasDraftPreview is @@ -216,7 +223,16 @@ internal sealed class RuntimeSettingsController : if (!_startupDisplayApplied) { - target.ApplyDisplay(Startup.Display); + RuntimeDisplayApplyResult result = target.ApplyDisplay(Startup.Display); + DisplaySettings applied = ReconcileDisplayResult(Startup.Display, result); + if (!ReferenceEquals(applied, Startup.Display)) + { + _storage.SaveDisplay(applied); + Display = applied; + _log( + $"settings: startup fullscreen request reconciled to " + + $"native state {applied.Fullscreen}"); + } _startupDisplayApplied = true; } if (!_startupAudioApplied) @@ -420,16 +436,43 @@ internal sealed class RuntimeSettingsController : { _storage.SaveDisplay(display); _log($"settings: display saved to {_storage.Location}"); - _runtimeTargets?.ApplyDisplayWindowState(display); - Display = display; - ReapplyQualityPreset(display.Quality); + RuntimeDisplayApplyResult result = _runtimeTargets is null + ? new RuntimeDisplayApplyResult(display.Fullscreen) + : _runtimeTargets.ApplyDisplayWindowState(display); + DisplaySettings applied = ReconcileDisplayResult(display, result); + if (!ReferenceEquals(applied, display)) + { + _storage.SaveDisplay(applied); + _log( + $"settings: fullscreen request reconciled to native state " + + $"{applied.Fullscreen}"); + } + Display = applied; + ReapplyQualityPreset(applied.Quality); } catch (Exception ex) { _log($"settings: display save failed: {ex.Message}"); + return; + } + + try + { + DisplayChanged?.Invoke(Display); + } + catch (Exception ex) + { + _log($"settings: display observer failed: {ex.Message}"); } } + private static DisplaySettings ReconcileDisplayResult( + DisplaySettings requested, + RuntimeDisplayApplyResult result) => + requested.Fullscreen == result.Fullscreen + ? requested + : requested with { Fullscreen = result.Fullscreen }; + /// Widened from private to public at Campaign OP slice OP6, /// same reason as . Also now pushes the saved /// snapshot into the live engine via diff --git a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs index 9f59ca23..53e80268 100644 --- a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs +++ b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs @@ -16,9 +16,16 @@ namespace AcDream.App.Settings; internal interface IRuntimeDisplayWindowTarget { - void Apply(DisplaySettings display); + RuntimeDisplayApplyResult Apply(DisplaySettings display); } +/// +/// Native display state observed after a requested settings apply. The +/// controller owns persistence and uses this result to keep the checkbox and +/// settings file aligned with the GLFW post-condition (#392). +/// +internal readonly record struct RuntimeDisplayApplyResult(bool Fullscreen); + internal interface IRuntimeQualityApplicationTarget { void SetAlphaToCoverage(bool enabled); @@ -133,7 +140,7 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg /// gate crash). Every failure path logs and leaves the window in a /// usable state instead of throwing. /// - public void Apply(DisplaySettings display) + public RuntimeDisplayApplyResult Apply(DisplaySettings display) { ArgumentNullException.ThrowIfNull(display); bool haveResolution = @@ -147,7 +154,7 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg // "any refused/failed switch logs a line". Console.WriteLine( $"display: fullscreen refused — unparseable resolution '{display.Resolution}'"); - return; + return CurrentResult(); } // Mechanism/blast M2: idempotence BEFORE any native work — every // Display-backed Config row applies per change (sliders per drag @@ -155,17 +162,17 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg // display-mode change per mouse sample. if (_modeSwitcher.CurrentFullscreenMode is (int curW, int curH) && curW == width && curH == height) - return; + return CurrentResult(); if (!_isOfferedMode.Invoke($"{width}x{height}")) { Console.WriteLine( $"display: fullscreen {width}x{height} refused — not an offered mode"); - return; + return CurrentResult(); } if (!_modeSwitcher.TryEnterFullscreen(width, height, out string? error)) Console.WriteLine( - $"display: fullscreen {width}x{height} failed ({error}) — window state unchanged (#392 tracks the persisted-flag divergence)"); - return; + $"display: fullscreen {width}x{height} failed ({error}) — window state unchanged"); + return CurrentResult(); } // Windowed target: leave fullscreen first if needed (the native exit @@ -180,7 +187,7 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg if (!_modeSwitcher.TryLeaveFullscreen(width, height, out string? error)) Console.WriteLine( $"display: leaving fullscreen failed ({error})"); - return; + return CurrentResult(); } if (haveResolution && (_window.Size.X != width || _window.Size.Y != height)) @@ -196,8 +203,12 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg $"(window was {_window.Size.X}x{_window.Size.Y})"); _window.Size = new Vector2D(width, height); } + return CurrentResult(); } + private RuntimeDisplayApplyResult CurrentResult() => + new(_modeSwitcher.IsFullscreen); + internal static bool TryParseResolution( string spec, out int width, @@ -240,13 +251,14 @@ internal sealed class RuntimeSettingsStartupTargets : IRuntimeSettingsStartupTar _audio = audio; } - public void ApplyDisplay(DisplaySettings display) + public RuntimeDisplayApplyResult ApplyDisplay(DisplaySettings display) { ArgumentNullException.ThrowIfNull(display); _pacing.RefreshActiveMonitor(); _pacing.ApplyPreference(display.VSync); - _displayWindow.Apply(display); + RuntimeDisplayApplyResult result = _displayWindow.Apply(display); ApplyFieldOfView(_cameras, display.FieldOfView); + return result; } public void ApplyAudio(AudioSettings audio) => ApplyAudio(_audio, audio); @@ -470,9 +482,9 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets _cameras = cameras; } - public void ApplyDisplayWindowState(DisplaySettings display) + public RuntimeDisplayApplyResult ApplyDisplayWindowState(DisplaySettings display) { - _displayWindow.Apply(display); + RuntimeDisplayApplyResult result = _displayWindow.Apply(display); // #389 blast MUST-FIX 2 (see the ctor's cameras doc): the Field of // View applies live on Save, from the update-phase save handler — // deliberately NOT from the render-phase preview seam @@ -480,6 +492,7 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets // is the review's WATCH-3 cull-vs-raster landmine. if (_cameras is not null) RuntimeSettingsStartupTargets.ApplyFieldOfView(_cameras, display.FieldOfView); + return result; } /// Campaign OP slice OP6: reuses the SAME static helper the diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index ce8cb764..5ad82a65 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -163,11 +163,13 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery private int _mutationDepth; private long _visibilityCommitCount; private ulong _flatViewGeneration; + private ulong _residentWindowRevision; private bool _flatMembershipDirty; public IReadOnlyList Entities => _flatEntities; public ulong FlatViewGeneration => _flatViewGeneration; public IReadOnlyCollection LoadedLandblockIds => _loaded.Keys; + internal ulong ResidentWindowRevision => _residentWindowRevision; public event Action? LiveProjectionVisibilityChanged; public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId); @@ -428,6 +430,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery throw new InvalidOperationException( $"Landblock 0x{landblockId:X8} already owns a render traversal slot."); } + _residentWindowRevision = checked(_residentWindowRevision + 1); InvalidateLandblockRenderViews(entries: true, bounds: true); } @@ -449,10 +452,81 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery _renderTraversalLandblockSlots[slot] = 0; _freeRenderTraversalLandblockSlots.Push(slot); + _residentWindowRevision = checked(_residentWindowRevision + 1); InvalidateLandblockRenderViews(entries: true, bounds: true); return true; } + /// + /// Captures the largest complete, actually-published square around the + /// current render centre. This is a presentation-only read: it neither + /// consults the desired nor requests missing + /// landblocks. Hysteresis-retained cells outside a gap cannot inflate the + /// result, which is what makes it safe during recenter and portal turnover. + /// + internal ResidentStreamingWindowFact CaptureResidentStreamingWindow( + int centerX, + int centerY) + { + if ((uint)centerX > byte.MaxValue || (uint)centerY > byte.MaxValue) + return ResidentStreamingWindowFact.Unavailable(_residentWindowRevision); + + uint center = StreamingRegion.EncodeLandblockId(centerX, centerY); + if (!_loaded.ContainsKey(center)) + return ResidentStreamingWindowFact.Unavailable(_residentWindowRevision); + + int maximumCandidate = 0; + foreach (uint landblockId in _loaded.Keys) + { + int x = (int)((landblockId >> 24) & 0xFFu); + int y = (int)((landblockId >> 16) & 0xFFu); + maximumCandidate = Math.Max( + maximumCandidate, + Math.Max(Math.Abs(x - centerX), Math.Abs(y - centerY))); + } + + int completeRadius = 0; + for (int radius = 1; radius <= maximumCandidate; radius++) + { + if (!ContainsCompleteRing(radius)) + break; + completeRadius = radius; + } + + return new ResidentStreamingWindowFact( + _residentWindowRevision, + centerX, + centerY, + completeRadius, + _loaded.Count, + HasPublishedCenter: true); + + bool ContainsCompleteRing(int radius) + { + int minX = Math.Max(0, centerX - radius); + int maxX = Math.Min(byte.MaxValue, centerX + radius); + int minY = Math.Max(0, centerY - radius); + int maxY = Math.Min(byte.MaxValue, centerY + radius); + for (int x = minX; x <= maxX; x++) + { + if (!_loaded.ContainsKey(StreamingRegion.EncodeLandblockId(x, minY)) + || !_loaded.ContainsKey(StreamingRegion.EncodeLandblockId(x, maxY))) + { + return false; + } + } + for (int y = minY + 1; y < maxY; y++) + { + if (!_loaded.ContainsKey(StreamingRegion.EncodeLandblockId(minX, y)) + || !_loaded.ContainsKey(StreamingRegion.EncodeLandblockId(maxX, y))) + { + return false; + } + } + return true; + } + } + /// /// Total live entities currently parked in the pending bucket waiting /// for their landblock to arrive. Useful diagnostic for verifying the @@ -1482,6 +1556,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery _loadedLiveByLandblock.Clear(); _liveProjectionByKey.Clear(); + if (_loaded.Count != 0) + _residentWindowRevision = checked(_residentWindowRevision + 1); _loaded.Clear(); _renderTraversalLandblockSlots.Clear(); _freeRenderTraversalLandblockSlots.Clear(); diff --git a/src/AcDream.App/Streaming/ResidentStreamingWindowFact.cs b/src/AcDream.App/Streaming/ResidentStreamingWindowFact.cs new file mode 100644 index 00000000..540a874e --- /dev/null +++ b/src/AcDream.App/Streaming/ResidentStreamingWindowFact.cs @@ -0,0 +1,31 @@ +namespace AcDream.App.Streaming; + +/// +/// Read-only presentation fact describing the complete portion of the live +/// two-tier landblock window. A landblock is 192 metres wide. Radius zero means +/// only the centre is published and therefore offers no safe camera-relative +/// cascade reach beyond that landblock. +/// +internal readonly record struct ResidentStreamingWindowFact( + ulong Revision, + int CenterX, + int CenterY, + int CompleteRadiusLandblocks, + int PublishedLandblockCount, + bool HasPublishedCenter) +{ + internal const float LandblockSizeMeters = 192f; + + internal float MaximumReachMeters => HasPublishedCenter + ? CompleteRadiusLandblocks * LandblockSizeMeters + : 0f; + + internal static ResidentStreamingWindowFact Unavailable(ulong revision) => + new( + revision, + CenterX: 0, + CenterY: 0, + CompleteRadiusLandblocks: 0, + PublishedLandblockCount: 0, + HasPublishedCenter: false); +} diff --git a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs index 07afa390..10d2f26f 100644 --- a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs +++ b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Numerics; using AcDream.App.Rendering; using AcDream.App.UI; +using AcDream.Plugin.Abstractions.Rendering; using AcDream.UI.Abstractions.Panels.Settings; namespace AcDream.App.UI.Layout; @@ -118,8 +120,9 @@ namespace AcDream.App.UI.Layout; /// multiplier, a different unit system with its own live legacy-panel /// consumer; no gamma-correction render pass exists for either); Automatic /// Degrades/Graphics Performance/Degrade Distance/the four texture-detail- -/// family menus/Building Detail Textures/Multi-Pass Alpha (the renderer is -/// Vulkan + one aggregate QualityPreset, no per-feature knobs); +/// family menus/Multi-Pass Alpha (the renderer is Vulkan + one aggregate +/// QualityPreset, no per-feature knobs). Building Detail Textures is +/// LIVE: #226 consumes it directly in the retail building/EnvCell detail pass; /// Camera Stiffness/Adjustment Speed/Align To Slope/Mouse Look /// Sensitivity/Invert Mouselook Y Axis/Use Mouse Turning (TS-74 — no /// persistent mouse-turning camera mode exists for ANY of these six to @@ -135,8 +138,8 @@ namespace AcDream.App.UI.Layout; /// /// /// Caption dimming (AD-78, user-directed, 2026-08-11, gate 2). Every -/// STORE-ONLY row above (the 21 rows named in the paragraph before this one -/// -- AP-198's ten, AP-199's three, and TS-74's six Camera/Input rows plus +/// STORE-ONLY row above (the 20 rows named in the paragraph before this one +/// -- AP-198's nine, AP-199's three, and TS-74's six Camera/Input rows plus /// AP-200's two Chat-font rows) renders its caption in /// instead of the normal /// white/DAT-authored color. The row stays fully interactive -- it still @@ -343,7 +346,47 @@ public static class ConfigOptionsPageController Func LoadCameraTurning, Action SaveCameraTurning, Func LoadChat, - Action SaveChat); + Action SaveChat) + { + /// + /// Optional modern extension appended after retail's exact 39 authored + /// rows. Null preserves the byte-verified retail Config-page shape. + /// + public RenderPackBindings? RenderPacks { get; init; } + } + + public sealed record RenderPackBindings( + Func> LoadChoices) + { + public Func? LoadRevision { get; init; } + public Func? LoadFailureNotice { get; init; } + } + + public sealed record RenderPackChoice( + string Id, + string DisplayName, + string? Version, + bool Selectable, + string? UnavailableReason, + IReadOnlyList Presets) + { + public string FeatureSummary { get; init; } = string.Empty; + public IReadOnlyList Settings { get; init; } = []; + } + + public sealed record RenderPackPresetChoice( + string Id, + string DisplayName, + bool Selectable, + string? UnavailableReason) + { + public IReadOnlyList SettingOverrides { get; init; } = []; + public long MaxResidentGpuBytes { get; init; } + public double MaxIncrementalGpuMillisecondsP50 { get; init; } + public double MaxIncrementalGpuMillisecondsP99 { get; init; } + public double MaxIncrementalCpuMillisecondsP50 { get; init; } + public double MaxIncrementalCpuMillisecondsP99 { get; init; } + } /// /// Builds the six authored sections (Sound/Camera/Graphics/Rendering @@ -455,9 +498,518 @@ public static class ConfigOptionsPageController // built 38 — five interior separators, no trailing one). BuildSeparatorRow(listBox); + if (bindings.RenderPacks is not null) + BindRenderPackSection( + listBox, + page, + bindings, + bindings.RenderPacks, + resolveSprite, + datFont, + debugFont); + return true; } + private static void BindRenderPackSection( + UiTemplateListBox listBox, + OptionPage page, + Bindings bindings, + RenderPackBindings renderPacks, + Func? resolveSprite, + UiDatFont? datFont, + BitmapFont? debugFont) + { + List choices = LoadPackChoices(renderPacks); + long observedRevision = renderPacks.LoadRevision?.Invoke() ?? 0; + var menuChoices = new ExplicitMenuChoiceSource( + choices.Select(static value => + new ExplicitMenuChoice( + value.Id, + value.DisplayName, + value.Selectable, + PackTooltip(value))).ToArray()); + + BuildExplicitHeaderRow(listBox, "Graphics Enhancements"); + RenderPackTailOwner? tail = null; + StringOptionRow? packOption = null; + UiMenu? packMenu = BuildExplicitStringMenuRow( + listBox, + "Shader pack", + menuChoices.Choices, + page, + read: () => NormalizePackId(bindings.LoadDisplay().RenderPack, choices), + apply: selectedId => + { + RenderPackChoice? selected = choices.FirstOrDefault(value => + value.Selectable && string.Equals( + value.Id, + selectedId, + StringComparison.OrdinalIgnoreCase)); + RenderPackPresetChoice? preset = selected?.Presets.FirstOrDefault( + static value => value.Selectable); + if (selected is null || preset is null) + return; + bindings.SaveDisplay(bindings.LoadDisplay() with + { + RenderPack = new RenderPackSelectionSettings( + selected.Id, + selected.Version, + preset.Id), + }); + tail?.RebuildPackTail(selected); + }, + defaultValue: RenderPackSelectionSettings.RetailPackId, + resolveSprite, + datFont, + debugFont, + menuChoices, + option => packOption = option); + + string initialPackId = NormalizePackId(bindings.LoadDisplay().RenderPack, choices); + RenderPackChoice initialPack = choices.First(value => + string.Equals(value.Id, initialPackId, StringComparison.OrdinalIgnoreCase)); + tail = new RenderPackTailOwner( + listBox, + page, + bindings, + retainedItemCount: listBox.ItemCount, + retainedOptionCount: page.Rows.Count, + resolveSprite, + datFont, + debugFont); + tail.RebuildPackTail(initialPack); + if (packMenu is not null) + { + packMenu.BeforeOpen = () => + { + if (renderPacks.LoadRevision is not { } loadRevision) + return; + long revision = loadRevision(); + if (revision == observedRevision) + return; + observedRevision = revision; + choices = LoadPackChoices(renderPacks); + menuChoices.Choices = choices.Select(static value => + new ExplicitMenuChoice( + value.Id, + value.DisplayName, + value.Selectable, + PackTooltip(value))).ToArray(); + packMenu.Items = menuChoices.Choices + .Select(static choice => new UiMenu.MenuItem( + choice.Label, + choice.Id)) + .ToArray(); + string selectedId = NormalizePackId( + bindings.LoadDisplay().RenderPack, + choices); + packMenu.Selected = selectedId; + packMenu.TooltipText = menuChoices.Choices.FirstOrDefault(choice => + string.Equals( + choice.Id, + selectedId, + StringComparison.OrdinalIgnoreCase)) + .Tooltip; + RenderPackChoice selected = choices.First(value => string.Equals( + value.Id, + selectedId, + StringComparison.OrdinalIgnoreCase)); + tail.RebuildPackTail(selected); + // Catalog withdrawal/update is an external state change, not + // an unsaved user edit. Rebase Reset to the now-valid live + // selection so it cannot resurrect a withdrawn pack id. + packOption?.SaveCurrentValue(); + }; + packMenu.TooltipTextProvider = () => CombineTooltip( + renderPacks.LoadFailureNotice?.Invoke(), + PackTooltip(choices.FirstOrDefault(value => string.Equals( + value.Id, + packMenu.Selected as string, + StringComparison.OrdinalIgnoreCase)))); + } + } + + private static List LoadPackChoices( + RenderPackBindings renderPacks) + { + IReadOnlyList discovered = renderPacks.LoadChoices(); + var choices = new List(discovered.Count + 1) + { + new( + RenderPackSelectionSettings.RetailPackId, + "acdream default (retail-faithful)", + Version: null, + Selectable: true, + UnavailableReason: null, + [new RenderPackPresetChoice( + RenderPackSelectionSettings.RetailPresetId, + "Off", + Selectable: true, + UnavailableReason: null)]), + }; + choices.AddRange(discovered.Where(static choice => + !string.Equals( + choice.Id, + RenderPackSelectionSettings.RetailPackId, + StringComparison.OrdinalIgnoreCase))); + return choices; + } + + private static string NormalizePackId( + RenderPackSelectionSettings selection, + IReadOnlyList choices) => + choices.Any(value => string.Equals( + value.Id, + selection.PackId, + StringComparison.OrdinalIgnoreCase) && value.Selectable) + ? selection.PackId + : RenderPackSelectionSettings.RetailPackId; + + private static string NormalizePresetId( + string presetId, + RenderPackChoice pack) => + pack.Presets.Any(value => value.Selectable && string.Equals( + value.Id, + presetId, + StringComparison.OrdinalIgnoreCase)) + ? presetId + : pack.Presets.FirstOrDefault(static value => value.Selectable)?.Id + ?? RenderPackSelectionSettings.RetailPresetId; + + private static string PackTooltip(RenderPackChoice? pack) + { + if (pack is null) + return "acdream's default retail-faithful renderer remains authoritative unless an enhancement pack is explicitly selected."; + string summary = string.IsNullOrWhiteSpace(pack.FeatureSummary) + ? "acdream's default retail-faithful renderer remains authoritative unless an enhancement pack is explicitly selected." + : pack.FeatureSummary.Trim(); + return CombineTooltip(pack.UnavailableReason, summary) ?? summary; + } + + private static string PresetTooltip(RenderPackPresetChoice preset) + { + double residentMiB = preset.MaxResidentGpuBytes / (1024d * 1024d); + string estimate = string.Format( + CultureInfo.InvariantCulture, + "Estimated ceiling — GPU p50/p99 ≤ {0:0.###}/{1:0.###} ms; " + + "render CPU p50/p99 ≤ {2:0.###}/{3:0.###} ms; pack VRAM ≤ {4:0.##} MiB.", + preset.MaxIncrementalGpuMillisecondsP50, + preset.MaxIncrementalGpuMillisecondsP99, + preset.MaxIncrementalCpuMillisecondsP50, + preset.MaxIncrementalCpuMillisecondsP99, + residentMiB); + return CombineTooltip(preset.UnavailableReason, estimate) ?? estimate; + } + + private static string? CombineTooltip(string? first, string? second) + { + bool hasFirst = !string.IsNullOrWhiteSpace(first); + bool hasSecond = !string.IsNullOrWhiteSpace(second); + if (!hasFirst) + return hasSecond ? second!.Trim() : null; + if (!hasSecond) + return first!.Trim(); + return first!.Trim() + Environment.NewLine + second!.Trim(); + } + + private readonly record struct ExplicitMenuChoice( + string Id, + string Label, + bool Enabled, + string? Tooltip); + + private sealed class ExplicitMenuChoiceSource( + IReadOnlyList choices) + { + internal IReadOnlyList Choices { get; set; } = choices; + } + + /// + /// Owns only the modern extension suffix after the persistent pack picker. + /// Pack changes replace preset + settings; preset changes replace settings + /// only. Generation guards make externally-retained stale widgets inert, + /// while the ListBox and OptionPage tail APIs remove their visual and verb + /// ownership synchronously. + /// + private sealed class RenderPackTailOwner( + UiTemplateListBox listBox, + OptionPage page, + Bindings bindings, + int retainedItemCount, + int retainedOptionCount, + Func? resolveSprite, + UiDatFont? datFont, + BitmapFont? debugFont) + { + private readonly int _retainedItemCount = retainedItemCount; + private readonly int _retainedOptionCount = retainedOptionCount; + private int _packGeneration; + private int _settingsGeneration; + private int _settingsItemCount; + private int _settingsOptionCount; + + internal void RebuildPackTail(RenderPackChoice pack) + { + int generation = ++_packGeneration; + ++_settingsGeneration; + listBox.RemoveTail(_retainedItemCount); + page.RemoveTail(_retainedOptionCount); + + DisplaySettings display = bindings.LoadDisplay(); + string presetId = NormalizePresetId(display.RenderPack.PresetId, pack); + RenderPackPresetChoice preset = pack.Presets.First(value => + string.Equals(value.Id, presetId, StringComparison.OrdinalIgnoreCase)); + BuildExplicitStringMenuRow( + listBox, + "Quality preset", + pack.Presets.Select(static value => + new ExplicitMenuChoice( + value.Id, + value.DisplayName, + value.Selectable, + PresetTooltip(value))).ToArray(), + page, + read: () => NormalizePresetId( + bindings.LoadDisplay().RenderPack.PresetId, + pack), + apply: selectedPresetId => + { + if (generation != _packGeneration) + return; + RenderPackPresetChoice? selected = pack.Presets.FirstOrDefault(value => + value.Selectable && string.Equals( + value.Id, + selectedPresetId, + StringComparison.OrdinalIgnoreCase)); + DisplaySettings current = bindings.LoadDisplay(); + if (selected is null || !SamePack(current.RenderPack, pack)) + return; + bindings.SaveDisplay(current with + { + RenderPack = current.RenderPack with + { + PresetId = selected.Id, + SettingOverrides = SanitizeOverrides( + pack, + current.RenderPack.SettingOverrides), + }, + }); + RebuildSettingsTail(pack, selected); + }, + defaultValue: preset.Id, + resolveSprite, + datFont, + debugFont); + + _settingsItemCount = listBox.ItemCount; + _settingsOptionCount = page.Rows.Count; + RebuildSettingsTail(pack, preset); + } + + private void RebuildSettingsTail( + RenderPackChoice pack, + RenderPackPresetChoice preset) + { + int generation = ++_settingsGeneration; + listBox.RemoveTail(_settingsItemCount); + page.RemoveTail(_settingsOptionCount); + + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (RenderSettingDeclaration setting in pack.Settings) + { + if (!ids.Add(setting.Id) || !CanBuildSetting(setting)) + continue; + BuildSetting(pack, preset, setting, generation); + } + BuildSeparatorRow(listBox); + } + + private void BuildSetting( + RenderPackChoice pack, + RenderPackPresetChoice preset, + RenderSettingDeclaration setting, + int generation) + { + bool IsCurrent() => generation == _settingsGeneration + && SamePack(bindings.LoadDisplay().RenderPack, pack) + && string.Equals( + bindings.LoadDisplay().RenderPack.PresetId, + preset.Id, + StringComparison.OrdinalIgnoreCase); + string Read() => ResolveSettingValue( + pack, + preset, + setting, + bindings.LoadDisplay().RenderPack.SettingOverrides); + string defaultValue = ResolveSettingValue( + pack, + preset, + setting, + RenderPackSettingOverrides.Empty); + void Apply(string value) + { + if (!IsCurrent() + || !RenderPackSettingValueCodec.TryEncode(setting, value, out _)) + return; + DisplaySettings current = bindings.LoadDisplay(); + RenderPackSettingOverrides clean = SanitizeOverrides( + pack, + current.RenderPack.SettingOverrides); + bindings.SaveDisplay(current with + { + RenderPack = current.RenderPack with + { + SettingOverrides = clean.Set(setting.Id, value), + }, + }); + } + + switch (setting.Kind) + { + case RenderSettingKind.Boolean: + BuildExplicitToggleRow( + listBox, + setting.DisplayName, + bool.Parse(defaultValue), + page, + read: () => bool.Parse(Read()), + apply: value => Apply(value ? "true" : "false"), + IsCurrent); + break; + + case RenderSettingKind.Float: + case RenderSettingKind.Integer: + double min = setting.Minimum!.Value; + double max = setting.Maximum!.Value; + double step = setting.Step + ?? (setting.Kind == RenderSettingKind.Integer ? 1d : 0d); + BuildExplicitNumericSliderRow( + listBox, + setting.DisplayName, + min, + max, + step, + setting.Kind == RenderSettingKind.Integer, + double.Parse(defaultValue, CultureInfo.InvariantCulture), + page, + read: () => double.Parse(Read(), CultureInfo.InvariantCulture), + apply: value => Apply(FormatNumeric(value, setting.Kind)), + IsCurrent); + break; + + case RenderSettingKind.Choice: + BuildExplicitStringMenuRow( + listBox, + setting.DisplayName, + setting.Choices.Select(static value => + new ExplicitMenuChoice(value, value, true, null)).ToArray(), + page, + read: Read, + apply: value => + { + if (IsCurrent()) Apply(value); + }, + defaultValue, + resolveSprite, + datFont, + debugFont); + break; + } + } + + private static bool CanBuildSetting(RenderSettingDeclaration setting) + { + if (string.IsNullOrWhiteSpace(setting.Id) + || string.IsNullOrWhiteSpace(setting.DisplayName) + || !RenderPackSettingValueCodec.TryEncode( + setting, + setting.DefaultValue, + out _)) + return false; + if (setting.Kind is RenderSettingKind.Float or RenderSettingKind.Integer) + { + return setting.Minimum is { } min + && setting.Maximum is { } max + && double.IsFinite(min) + && double.IsFinite(max) + && min >= -float.MaxValue + && max <= float.MaxValue + && max > min + && (setting.Step is null + || double.IsFinite(setting.Step.Value) && setting.Step.Value > 0); + } + return setting.Kind is RenderSettingKind.Boolean + || setting.Kind == RenderSettingKind.Choice && setting.Choices.Count > 0; + } + + private static string ResolveSettingValue( + RenderPackChoice pack, + RenderPackPresetChoice preset, + RenderSettingDeclaration setting, + IReadOnlyDictionary userOverrides) + { + if (TryGet(userOverrides, setting.Id, out string? user) + && RenderPackSettingValueCodec.TryEncode(setting, user, out _)) + return user; + RenderQualitySettingOverride? presetValue = preset.SettingOverrides + .FirstOrDefault(value => string.Equals( + value.SettingId, + setting.Id, + StringComparison.OrdinalIgnoreCase)); + if (presetValue is not null + && RenderPackSettingValueCodec.TryEncode(setting, presetValue.Value, out _)) + return presetValue.Value; + return setting.DefaultValue; + } + + private static RenderPackSettingOverrides SanitizeOverrides( + RenderPackChoice pack, + IReadOnlyDictionary overrides) + { + var valid = new List>(); + foreach ((string id, string value) in overrides) + { + RenderSettingDeclaration? setting = pack.Settings.FirstOrDefault(candidate => + string.Equals(candidate.Id, id, StringComparison.OrdinalIgnoreCase)); + if (setting is not null + && RenderPackSettingValueCodec.TryEncode(setting, value, out _)) + valid.Add(new KeyValuePair(setting.Id, value)); + } + return new RenderPackSettingOverrides(valid); + } + + private static bool TryGet( + IReadOnlyDictionary values, + string id, + out string value) + { + if (values.TryGetValue(id, out value!)) + return true; + foreach ((string key, string candidate) in values) + { + if (string.Equals(key, id, StringComparison.OrdinalIgnoreCase)) + { + value = candidate; + return true; + } + } + value = string.Empty; + return false; + } + + private static bool SamePack( + RenderPackSelectionSettings selection, + RenderPackChoice pack) => + string.Equals(selection.PackId, pack.Id, StringComparison.OrdinalIgnoreCase) + && string.Equals(selection.PackVersion, pack.Version, StringComparison.Ordinal); + + private static string FormatNumeric(double value, RenderSettingKind kind) => + kind == RenderSettingKind.Integer + ? checked((long)Math.Round(value)).ToString(CultureInfo.InvariantCulture) + : value.ToString("R", CultureInfo.InvariantCulture); + } + // ── Section 1: Sound Options ──────────────────────────────────────── private static void BindSoundSection( @@ -729,9 +1281,9 @@ public static class ConfigOptionsPageController { BuildHeaderRow(listBox, "ID_Graphics_TextureSection", resolveString); - // The whole section is store-only: the world renderer is - // Vulkan + one aggregate QualityPreset, not per-feature knobs - // (register row, OP6). + // Every row except Building Detail Textures is still store-only: + // #226 consumes that existing preference in the retail building and + // EnvCell detail replay. The other rows remain aggregate-preset gaps. BuildMenuRow( listBox, "ID_Graphics_LandscapeTextureDetail", TextureDetailChoices, page, resolveString, read: () => bindings.LoadDisplay().LandscapeTextureDetail, @@ -772,7 +1324,7 @@ public static class ConfigOptionsPageController listBox, "ID_Graphics_BuildingDetailTextures", defaultValue: true, page, resolveString, read: () => bindings.LoadDisplay().BuildingDetailTextures, apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { BuildingDetailTextures = value }), - storeOnly: true); // AP-198 + storeOnly: false); // LIVE — #226 retail building/EnvCell detail pass BuildToggleRow( listBox, "ID_Graphics_MultiPassAlpha", defaultValue: false, page, resolveString, @@ -916,6 +1468,23 @@ public static class ConfigOptionsPageController header.LinesProvider = () => new[] { new UiText.Line(label, header.DefaultColor) }; } + private static void BuildExplicitHeaderRow( + UiTemplateListBox listBox, + string label) + { + if (listBox.AddItemFromTemplateList(HeaderTemplateIndex) is not UiText header) + { + Console.WriteLine( + "[render-pack] Config header template did not build as UiText."); + return; + } + + header.LinesProvider = () => new[] + { + new UiText.Line(label, header.DefaultColor), + }; + } + private static void BuildSeparatorRow(UiTemplateListBox listBox) { if (listBox.AddItemFromTemplateList(SeparatorTemplateIndex) is null) @@ -1314,6 +1883,231 @@ public static class ConfigOptionsPageController }; } + private static UiMenu? BuildExplicitStringMenuRow( + UiTemplateListBox listBox, + string labelText, + IReadOnlyList choices, + OptionPage page, + Func read, + Action apply, + string defaultValue, + Func? resolveSprite, + UiDatFont? datFont, + BitmapFont? debugFont, + ExplicitMenuChoiceSource? dynamicChoices = null, + Action? captureOption = null) + { + UiElement? row = listBox.AddItemFromTemplateList(MenuTemplateIndex); + if (row is null) + { + Console.WriteLine( + $"[render-pack] Config menu template did not build for '{labelText}'."); + return null; + } + + if (UiElement.FindDescendant(row, MenuLabelElementId) is UiText label) + { + label.LinesProvider = () => new[] + { + new UiText.Line(labelText, label.DefaultColor), + }; + } + + if (UiElement.FindDescendant(row, MenuElementId) is not UiMenu menu) + { + Console.WriteLine( + $"[render-pack] No UiMenu leaf found for '{labelText}'."); + return null; + } + + var choiceSource = dynamicChoices ?? new ExplicitMenuChoiceSource(choices); + ApplyMenuChrome(menu, resolveSprite, datFont, debugFont); + menu.Items = choiceSource.Choices + .Select(static choice => new UiMenu.MenuItem(choice.Label, choice.Id)) + .ToArray(); + menu.EnabledProvider = payload => choiceSource.Choices.Any(choice => + choice.Enabled && Equals(choice.Id, payload)); + + string initial = read(); + menu.Selected = initial; + menu.ButtonLabelProvider = () => + { + string current = menu.Selected as string ?? initial; + return choiceSource.Choices.FirstOrDefault(choice => string.Equals( + choice.Id, + current, + StringComparison.OrdinalIgnoreCase)) + .Label ?? current; + }; + menu.TooltipText = choiceSource.Choices.FirstOrDefault(choice => string.Equals( + choice.Id, + initial, + StringComparison.OrdinalIgnoreCase)) + .Tooltip; + + var option = new StringOptionRow( + initial, + defaultValue, + apply: value => + { + menu.Selected = value; + menu.TooltipText = choiceSource.Choices.FirstOrDefault(choice => string.Equals( + choice.Id, + value, + StringComparison.OrdinalIgnoreCase)) + .Tooltip; + apply(value); + }, + read, + refresh: value => menu.Selected = value); + page.Register(option); + captureOption?.Invoke(option); + menu.OnSelect = payload => + { + if (payload is string value && choiceSource.Choices.Any(choice => + choice.Enabled && string.Equals( + choice.Id, + value, + StringComparison.OrdinalIgnoreCase))) + option.SetCurrentValue(value); + }; + return menu; + } + + private static UiButton? BuildExplicitToggleRow( + UiTemplateListBox listBox, + string labelText, + bool defaultValue, + OptionPage page, + Func read, + Action apply, + Func isCurrent) + { + UiElement? row = listBox.AddItemFromTemplateList(ToggleTemplateIndex); + UiButton? checkbox = row is null ? null : FindCheckbox(row); + if (checkbox is null) + { + Console.WriteLine( + $"[render-pack] Toggle template did not build for '{labelText}'."); + return null; + } + + checkbox.Label = labelText; + checkbox.LabelColor = Vector4.One; + bool initial = read(); + checkbox.Selected = initial; + var option = new BoolOptionRow( + initial, + defaultValue, + apply: value => + { + checkbox.Selected = value; + if (isCurrent()) apply(value); + }, + read: () => isCurrent() ? read() : initial, + refresh: value => checkbox.Selected = value); + page.Register(option); + checkbox.OnClick = () => + { + if (isCurrent()) option.SetCurrentValue(checkbox.Selected); + }; + return checkbox; + } + + private static UiScrollbar? BuildExplicitNumericSliderRow( + UiTemplateListBox listBox, + string labelText, + double min, + double max, + double step, + bool integer, + double defaultValue, + OptionPage page, + Func read, + Action apply, + Func isCurrent) + { + UiElement? row = listBox.AddItemFromTemplateList(RangedSliderTemplateIndex); + if (row is null) + { + Console.WriteLine( + $"[render-pack] Slider template did not build for '{labelText}'."); + return null; + } + if (UiElement.FindDescendant(row, SliderLabelElementId) is UiText label) + { + label.LinesProvider = () => + [ + new UiText.Line(labelText, label.DefaultColor), + ]; + } + if (UiElement.FindDescendant(row, SliderRangeMinElementId) is UiText low) + { + string text = FormatExplicitNumber(min, integer); + low.LinesProvider = () => [new UiText.Line(text, low.DefaultColor)]; + } + if (UiElement.FindDescendant(row, SliderRangeMaxElementId) is UiText high) + { + string text = FormatExplicitNumber(max, integer); + high.LinesProvider = () => [new UiText.Line(text, high.DefaultColor)]; + } + if (UiElement.FindDescendant(row, SliderElementId) is not UiScrollbar slider) + { + Console.WriteLine( + $"[render-pack] No slider leaf found for '{labelText}'."); + return null; + } + + double initialValue = SnapExplicitNumber(read(), min, max, step, integer); + float initial = (float)initialValue; + slider.SetScalarPosition((float)((initialValue - min) / (max - min))); + var option = new FloatOptionRow( + initial, + (float)SnapExplicitNumber(defaultValue, min, max, step, integer), + apply: value => + { + double snapped = SnapExplicitNumber(value, min, max, step, integer); + slider.SetScalarPosition((float)((snapped - min) / (max - min))); + if (isCurrent()) apply(snapped); + }, + read: () => isCurrent() + ? (float)SnapExplicitNumber(read(), min, max, step, integer) + : initial, + refresh: value => + { + double snapped = SnapExplicitNumber(value, min, max, step, integer); + slider.SetScalarPosition((float)((snapped - min) / (max - min))); + }); + page.Register(option); + slider.ScalarChanged = normalized => + { + if (!isCurrent()) return; + double raw = min + normalized * (max - min); + option.SetCurrentValue((float)SnapExplicitNumber(raw, min, max, step, integer)); + }; + return slider; + } + + private static double SnapExplicitNumber( + double value, + double min, + double max, + double step, + bool integer) + { + double clamped = Math.Clamp(value, min, max); + if (step > 0) + clamped = min + Math.Round((clamped - min) / step) * step; + if (integer) + clamped = Math.Round(clamped); + return Math.Clamp(clamped, min, max); + } + + private static string FormatExplicitNumber(double value, bool integer) => + integer + ? checked((long)Math.Round(value)).ToString(CultureInfo.InvariantCulture) + : value.ToString("R", CultureInfo.InvariantCulture); + private static void ApplyLabelAndTooltip( UiButton checkbox, string labelKey, Func resolveString, bool storeOnly) { diff --git a/src/AcDream.App/UI/Layout/OptionPageModel.cs b/src/AcDream.App/UI/Layout/OptionPageModel.cs index d1125e50..682a67b2 100644 --- a/src/AcDream.App/UI/Layout/OptionPageModel.cs +++ b/src/AcDream.App/UI/Layout/OptionPageModel.cs @@ -678,6 +678,24 @@ public sealed class OptionPage _rows.Add(row); } + /// + /// Detaches a dynamically-owned suffix while preserving the registered + /// prefix. Detached rows no longer participate in Apply/Reset/Defaults and + /// their page-notify callback is severed, so retained widgets removed from + /// the visual tree cannot keep this page alive or re-arm its buttons. + /// + public void RemoveTail(int retainedRowCount) + { + if (retainedRowCount < 0 || retainedRowCount > _rows.Count) + throw new ArgumentOutOfRangeException(nameof(retainedRowCount)); + for (int i = _rows.Count - 1; i >= retainedRowCount; i--) + { + _rows[i].AttachPageNotify(static () => { }); + _rows.RemoveAt(i); + } + OnOptionChanged?.Invoke(); + } + /// OptionPage::Changed @0x004F2D60: true if ANY /// registered row's own is true. public bool Changed => _rows.Any(static row => row.Changed); @@ -687,8 +705,9 @@ public sealed class OptionPage /// , then re-evaluates . public void Apply() { - foreach (IOptionRow row in _rows) - row.SaveCurrentValue(); + foreach (IOptionRow row in _rows.ToArray()) + if (_rows.Contains(row)) + row.SaveCurrentValue(); AfterApply?.Invoke(); OnOptionChanged?.Invoke(); } @@ -702,7 +721,8 @@ public sealed class OptionPage public void Reset() { foreach (IOptionRow row in _rows.Where(static row => row.Changed).ToArray()) - row.RestoreSavedValue(); + if (_rows.Contains(row)) + row.RestoreSavedValue(); OnOptionChanged?.Invoke(); } @@ -711,8 +731,9 @@ public sealed class OptionPage /// committing, then re-evaluates . public void Defaults() { - foreach (IOptionRow row in _rows) - row.RestoreDefaultValue(); + foreach (IOptionRow row in _rows.ToArray()) + if (_rows.Contains(row)) + row.RestoreDefaultValue(); OnOptionChanged?.Invoke(); } @@ -735,8 +756,9 @@ public sealed class OptionPage /// public void ReloadFromLive() { - foreach (IOptionRow row in _rows) - row.SaveCurrentValue(); + foreach (IOptionRow row in _rows.ToArray()) + if (_rows.Contains(row)) + row.SaveCurrentValue(); OnOptionChanged?.Invoke(); } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 043aab3f..62b8c251 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -224,7 +224,11 @@ public sealed record OptionsRuntimeBindings( Func LoadDisplay, Action SaveDisplay, Func LoadAudio, - Action SaveAudio); + Action SaveAudio, + Func>? + LoadRenderPackChoices = null, + Func? LoadRenderPackCatalogRevision = null, + Func? LoadRenderPackFailureNotice = null); /// /// Campaign FA slice FA3: the social panel's (Friends/Allegiance/ @@ -2884,7 +2888,18 @@ public sealed class RetailUiRuntime : IDisposable // silently clobber CH6's filter/opacity edits with a // stale snapshot the next time either surface saves. LoadChat: () => _bindings.Chat.Store?.LoadChat() ?? ChatSettings.Default, - SaveChat: chat => _bindings.Chat.Store?.SaveChat(chat)), + SaveChat: chat => _bindings.Chat.Store?.SaveChat(chat)) + { + RenderPacks = _bindings.Options.LoadRenderPackChoices is { } load + ? new Layout.ConfigOptionsPageController.RenderPackBindings(load) + { + LoadRevision = + _bindings.Options.LoadRenderPackCatalogRevision, + LoadFailureNotice = + _bindings.Options.LoadRenderPackFailureNotice, + } + : null, + }, // #378: the eight Config-tab dropdown menus need the SAME // sprite/font resolvers every other retail-menu consumer // (ChatWindowController's channel menu, VendorUiController's diff --git a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs index ca5ab3a9..1f82b6f7 100644 --- a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs +++ b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs @@ -23,6 +23,29 @@ public interface IRetailUiAutomationCheckpoint string? Error { get; } } +public enum RetailUiAutomationRenderPackState +{ + Retail, + CandidatePending, + Active, + FailedToRetail, +} + +public readonly record struct RetailUiAutomationRenderPackStatus( + RetailUiAutomationRenderPackState State, + string PackId, + string PresetId, + long ActivationGeneration, + string? FailureReason) +{ + public static RetailUiAutomationRenderPackStatus Retail { get; } = new( + RetailUiAutomationRenderPackState.Retail, + "retail", + "off", + ActivationGeneration: 0, + FailureReason: null); +} + /// /// Narrow bridge from retained-UI scripts to render/world lifecycle /// diagnostics. Implementations run on the same update/render thread as the @@ -33,6 +56,42 @@ public interface IRetailUiAutomationRuntime bool IsWorldReady { get; } bool IsWorldViewportVisible { get; } int PortalMaterializationCount { get; } + int RenderPackPerformanceSampleCount => 0; + bool RenderPackFailedToRetail => false; + RetailUiAutomationRenderPackStatus RenderPackStatus => + RetailUiAutomationRenderPackStatus.Retail; + int FramebufferWidth => 0; + int FramebufferHeight => 0; + bool TrySelectRenderPack(string presetId, out string error) + { + error = "render-pack selection automation is unavailable"; + return false; + } + bool TryDisableRenderPack(out string error) + { + error = "render-pack selection automation is unavailable"; + return false; + } + bool TryReenableRenderPack(out string error) + { + error = "render-pack selection automation is unavailable"; + return false; + } + bool TryResizeFramebuffer(int width, int height, out string error) + { + error = "framebuffer resize automation is unavailable"; + return false; + } + bool TryResetRenderPackPerformance(out string error) + { + error = "render-pack performance automation is unavailable"; + return false; + } + bool TryRequestClientClose(out string error) + { + error = "client-close automation is unavailable"; + return false; + } bool TryRequestCheckpoint( string name, out IRetailUiAutomationCheckpoint? checkpoint, @@ -205,7 +264,10 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable "command" => DoCommand(command), "input" => DoInput(command), "checkpoint" => DoCheckpoint(command), + "renderpack" => DoRenderPack(command), + "resize" => DoResize(command), "screenshot" => DoScreenshot(command), + "close-client" => DoCloseClient(command), _ => Stop(command, $"unknown command '{p[0]}'"), }; } @@ -333,7 +395,7 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable private bool DoWait(ScriptCommand command) { var p = command.Parts; - if (p.Length < 2) return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized ..."); + if (p.Length < 2) return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized|render-pack|render-pack-samples|framebuffer ..."); string target = p[1].ToLowerInvariant(); if (target == "item") @@ -376,7 +438,168 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable return WaitOrTimeout(command, TimeoutMs(p, 3, 60000), $"portal materialization {occurrence}"); } - return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized ..."); + if (target == "render-pack-samples") + { + if (_runtime is null) + return Stop(command, "render-pack performance automation is unavailable"); + if (p.Length < 3 + || !TryParseInt(p[2], out int required) + || required <= 0) + { + return Stop( + command, + "usage: wait render-pack-samples [timeoutMs]"); + } + if (_runtime.RenderPackPerformanceSampleCount >= required) + return true; + // An unavailable preset cannot ever fill an enhanced performance + // window. Finish this wait immediately so the following screenshot + // captures the controller's fully resource-free default fallback; + // the harness validates the exact failure class and zero-work + // metadata instead of turning every fallback into a five-minute + // timeout. + if (_runtime.RenderPackFailedToRetail) + return true; + return WaitOrTimeout( + command, + TimeoutMs(p, 3, 300000), + $"{required} render-pack performance samples"); + } + + if (target == "render-pack") + { + if (_runtime is null) + return Stop(command, "render-pack selection automation is unavailable"); + if (p.Length < 3 || !TryNormalizeRenderPackPreset(p[2], out string preset)) + { + return Stop( + command, + "usage: wait render-pack retail|low|medium|high|auto [timeoutMs]"); + } + + RetailUiAutomationRenderPackStatus status = _runtime.RenderPackStatus; + bool expectRetail = string.Equals( + preset, + "retail", + StringComparison.Ordinal); + if (expectRetail + && status.State == RetailUiAutomationRenderPackState.Retail + && string.Equals(status.PackId, "retail", StringComparison.OrdinalIgnoreCase) + && string.Equals(status.PresetId, "off", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + if (!expectRetail + && status.State == RetailUiAutomationRenderPackState.Active + && string.Equals( + status.PackId, + "acdream.atmospheric", + StringComparison.OrdinalIgnoreCase) + && string.Equals(status.PresetId, preset, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + if (status.State == RetailUiAutomationRenderPackState.FailedToRetail) + { + return Stop( + command, + $"render pack '{preset}' failed to retail: " + + (status.FailureReason ?? "no failure reason was published")); + } + return WaitOrTimeout( + command, + TimeoutMs(p, 3, 90000), + $"render pack '{preset}' activation"); + } + + if (target == "framebuffer") + { + if (_runtime is null) + return Stop(command, "framebuffer resize automation is unavailable"); + if (p.Length < 4 + || !TryParseInt(p[2], out int width) + || !TryParseInt(p[3], out int height) + || width <= 0 + || height <= 0) + { + return Stop( + command, + "usage: wait framebuffer [timeoutMs]"); + } + if (_runtime.FramebufferWidth == width + && _runtime.FramebufferHeight == height) + { + return true; + } + return WaitOrTimeout( + command, + TimeoutMs(p, 4, 30000), + $"framebuffer {width}x{height}"); + } + + return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized|render-pack|render-pack-samples|framebuffer ..."); + } + + private bool DoRenderPack(ScriptCommand command) + { + if (_runtime is null) + return Stop(command, "render-pack automation is unavailable"); + var p = command.Parts; + if (p.Length == 2 + && string.Equals(p[1], "reset-performance", StringComparison.OrdinalIgnoreCase)) + { + return _runtime.TryResetRenderPackPerformance(out string error) + || Stop(command, error); + } + if (p.Length == 3 + && string.Equals(p[1], "select", StringComparison.OrdinalIgnoreCase) + && TryNormalizeRenderPackPreset(p[2], out string preset)) + { + return _runtime.TrySelectRenderPack(preset, out string error) + || Stop(command, error); + } + if (p.Length == 2 + && string.Equals(p[1], "disable", StringComparison.OrdinalIgnoreCase)) + { + return _runtime.TryDisableRenderPack(out string error) + || Stop(command, error); + } + if (p.Length == 2 + && string.Equals(p[1], "reenable", StringComparison.OrdinalIgnoreCase)) + { + return _runtime.TryReenableRenderPack(out string error) + || Stop(command, error); + } + return Stop( + command, + "usage: renderpack reset-performance | renderpack select retail|low|medium|high|auto | renderpack disable | renderpack reenable"); + } + + private bool DoResize(ScriptCommand command) + { + if (_runtime is null) + return Stop(command, "framebuffer resize automation is unavailable"); + var p = command.Parts; + if (p.Length != 3 + || !TryParseInt(p[1], out int width) + || !TryParseInt(p[2], out int height) + || width <= 0 + || height <= 0) + { + return Stop(command, "usage: resize "); + } + return _runtime.TryResizeFramebuffer(width, height, out string error) + || Stop(command, error); + } + + private static bool TryNormalizeRenderPackPreset( + string value, + out string preset) + { + preset = value.ToLowerInvariant(); + if (preset == "off") + preset = "retail"; + return preset is "retail" or "low" or "medium" or "high" or "auto"; } private bool DoSleep(ScriptCommand command) @@ -540,6 +763,16 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable return WaitOrTimeout(command, TimeoutMs(command.Parts, 2, 10000), $"screenshot '{name}'"); } + private bool DoCloseClient(ScriptCommand command) + { + if (command.Parts.Length != 1) + return Stop(command, "usage: close-client"); + if (_runtime is null) + return Stop(command, "client-close automation is unavailable"); + return _runtime.TryRequestClientClose(out string error) + || Stop(command, error); + } + private bool WaitOrTimeout(ScriptCommand command, int timeoutMs, string label) { if (!HasExceeded(timeoutMs)) return false; diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index 7b087934..5cff6a3c 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -39,6 +39,13 @@ public sealed class UiMenu : UiElement /// public Action? OnOpen { get; set; } + /// + /// Optional interaction-boundary refresh invoked immediately before a + /// closed popup opens. Retained settings menus use it to consume an + /// event-driven catalog revision without polling during draw/update. + /// + public Action? BeforeOpen { get; set; } + /// Per-payload enabled gate (disabled rows render greyed + are inert). Null ⇒ all enabled. public Func? EnabledProvider { get; set; } @@ -55,9 +62,20 @@ public sealed class UiMenu : UiElement /// surface for the row. public string? TooltipText { get; set; } + /// Optional live tooltip source. Controllers use this when the + /// reason or cost behind a menu selection can change while the panel stays + /// open. A non-blank provider value takes precedence over + /// . + public Func? TooltipTextProvider { get; set; } + /// - public override string? GetTooltipText() => - string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText; + public override string? GetTooltipText() + { + string? live = TooltipTextProvider?.Invoke(); + if (!string.IsNullOrWhiteSpace(live)) + return live; + return string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText; + } public int RowsPerColumn { get; set; } = 7; // items per column (dat item template); // ALSO the visible-row window height when Scrollable @@ -271,7 +289,11 @@ public sealed class UiMenu : UiElement if (_open == value) return; // Before _open flips, so a handler that replaces Items is reflected in // the very first measure/draw of this opening. - if (value) OnOpen?.Invoke(); + if (value) + { + BeforeOpen?.Invoke(); + OnOpen?.Invoke(); + } _open = value; if (FindRoot() is not { } root) return; if (value) root.SetActivePopup(this, () => SetOpen(false)); diff --git a/src/AcDream.App/UI/UiTemplateListBox.cs b/src/AcDream.App/UI/UiTemplateListBox.cs index f10241ff..f1f16b25 100644 --- a/src/AcDream.App/UI/UiTemplateListBox.cs +++ b/src/AcDream.App/UI/UiTemplateListBox.cs @@ -272,6 +272,29 @@ public sealed class UiTemplateListBox : UiDatElement return row; } + /// + /// Removes the dynamically-owned suffix after + /// without disturbing the retained prefix. This is the structural seam used by + /// optional Config-page extensions whose schema can change at runtime: retail's + /// authored prefix remains mounted, while only extension rows are detached. + /// Existing scroll is preserved and clamped by . + /// + public void RemoveTail(int retainedItemCount) + { + int count = _viewport?.Children.Count ?? 0; + if (retainedItemCount < 0 || retainedItemCount > count) + throw new ArgumentOutOfRangeException(nameof(retainedItemCount)); + if (_viewport is null || retainedItemCount == count) + return; + + for (int i = count - 1; i >= retainedItemCount; i--) + _viewport.RemoveChild(_viewport.Children[i]); + } + + /// Current number of materialized row widgets. Reading this does + /// not wake a dormant list box. + public int ItemCount => _viewport?.Children.Count ?? 0; + /// /// Campaign FA slice FA3: removes every row previously added via /// /, resetting diff --git a/src/AcDream.App/World/WorldEnvironmentController.cs b/src/AcDream.App/World/WorldEnvironmentController.cs index c605d733..ffec43eb 100644 --- a/src/AcDream.App/World/WorldEnvironmentController.cs +++ b/src/AcDream.App/World/WorldEnvironmentController.cs @@ -1,4 +1,5 @@ using AcDream.Core.Audio; +using AcDream.Core.Content; using AcDream.App.Rendering; using AcDream.Core.World; using AcDream.Runtime.World; @@ -60,13 +61,15 @@ internal sealed class WorldEnvironmentController : IWorldSceneSkyStateSource } } + public int ActiveDayGroupIndex => Runtime.ActiveDayGroupIndex; + public float DayFraction => (float)WorldTime.DayFraction; - public void Initialize(Region region) + public void Initialize(Region region, IDatObjectSource? dats = null) { ArgumentNullException.ThrowIfNull(region); Initialize( - SkyDescLoader.LoadFromRegion(region), + SkyDescLoader.LoadFromRegion(region, dats), region.GameTime?.ZeroTimeOfYear); } diff --git a/src/AcDream.Core/Plugins/LoadedPlugin.cs b/src/AcDream.Core/Plugins/LoadedPlugin.cs index 9f1f534a..b73190e9 100644 --- a/src/AcDream.Core/Plugins/LoadedPlugin.cs +++ b/src/AcDream.Core/Plugins/LoadedPlugin.cs @@ -1,14 +1,16 @@ using System.Runtime.Loader; using AcDream.Plugin.Abstractions; +using AcDream.Plugin.Abstractions.Rendering; namespace AcDream.Core.Plugins; /// /// Outcome of a plugin load attempt. -/// On success, is the instantiated plugin, -/// owns its assembly, and is null. +/// On success, at least one of or +/// is instantiated, +/// owns the assembly, and is null. /// On failure, describes what went wrong. A partial -/// and/or may still be present; +/// entry-point instances and/or may still be present; /// the caller owns their cleanup. The loader never requests collectible unload /// itself because the session must first roll back host registrations. /// @@ -16,8 +18,11 @@ public sealed record LoadedPlugin( PluginManifest Manifest, IAcDreamPlugin? Plugin, AssemblyLoadContext? LoadContext, - Exception? Error) + Exception? Error, + IRenderPackPlugin? RenderPackPlugin = null) { public bool Success => - Plugin is not null && LoadContext is not null && Error is null; + (Plugin is not null || RenderPackPlugin is not null) + && LoadContext is not null + && Error is null; } diff --git a/src/AcDream.Core/Plugins/PluginLoader.cs b/src/AcDream.Core/Plugins/PluginLoader.cs index 7683f705..7e37ef60 100644 --- a/src/AcDream.Core/Plugins/PluginLoader.cs +++ b/src/AcDream.Core/Plugins/PluginLoader.cs @@ -1,5 +1,6 @@ using System.Reflection; using AcDream.Plugin.Abstractions; +using AcDream.Plugin.Abstractions.Rendering; namespace AcDream.Core.Plugins; @@ -7,14 +8,19 @@ public static class PluginLoader { /// /// Load a plugin DLL from into a collectible - /// , find the first type - /// implementing , instantiate it, and call its - /// with the supplied host. Any failure - /// is returned as a failed rather than thrown. + /// , resolve the gameplay + /// and/or render-pack entry points declared by its manifest, and invoke only + /// the facilities this host supplied. Any failure is returned as a failed + /// rather than thrown. /// A returned partial plugin/context remains caller-owned; this method never - /// requests unload because the caller must close host registrations first. + /// requests unload because the caller must close host and render-pack + /// registrations first. /// - public static LoadedPlugin Load(string pluginDirectory, PluginManifest manifest, IPluginHost host) + public static LoadedPlugin Load( + string pluginDirectory, + PluginManifest manifest, + IPluginHost host, + IRenderPackRegistry? renderPacks = null) { ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory); ArgumentNullException.ThrowIfNull(manifest); @@ -45,6 +51,7 @@ public static class PluginLoader PluginAssemblyLoadContext? alc = null; IAcDreamPlugin? instance = null; + IRenderPackPlugin? renderPackInstance = null; try { alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath); @@ -60,10 +67,51 @@ public static class PluginLoader types = rtle.Types.OfType(); } - var pluginType = types - .FirstOrDefault(t => !t.IsAbstract && typeof(IAcDreamPlugin).IsAssignableFrom(t)); + Type[] concreteTypes = types + .Where(static type => !type.IsAbstract && !type.IsInterface) + .ToArray(); + Type? pluginType = manifest.Declares(PluginKind.Gameplay) + ? concreteTypes.FirstOrDefault( + static type => typeof(IAcDreamPlugin).IsAssignableFrom(type)) + : null; + bool registerRenderPack = + manifest.Declares(PluginKind.RenderPack) && renderPacks is not null; + CountingRenderPackRegistry? countedRenderPacks = registerRenderPack + ? new CountingRenderPackRegistry(renderPacks!) + : null; + Type? renderPackType = null; + if (registerRenderPack) + { + Type[] renderPackTypes = concreteTypes + .Where(static type => typeof(IRenderPackPlugin).IsAssignableFrom(type)) + .ToArray(); + if (renderPackTypes.Length != 1) + { + return new LoadedPlugin( + manifest, + Plugin: null, + LoadContext: alc, + Error: new InvalidOperationException( + $"render-pack entry DLL '{manifest.EntryDll}' must contain exactly " + + "one IRenderPackPlugin implementation; found " + + renderPackTypes.Length)); + } - if (pluginType is null) + renderPackType = renderPackTypes[0]; + if (!renderPackType.IsVisible + || renderPackType.GetConstructor(Type.EmptyTypes) is null) + { + return new LoadedPlugin( + manifest, + Plugin: null, + LoadContext: alc, + Error: new InvalidOperationException( + $"render-pack entry type '{renderPackType.FullName}' must be public, " + + "non-abstract, and expose a public parameterless constructor")); + } + } + + if (manifest.Declares(PluginKind.Gameplay) && pluginType is null) { return new LoadedPlugin( manifest, @@ -73,9 +121,58 @@ public static class PluginLoader $"no IAcDreamPlugin implementation found in {manifest.EntryDll}")); } - instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!; - instance.Initialize(host); - return new LoadedPlugin(manifest, instance, alc, Error: null); + if (registerRenderPack && renderPackType is null) + { + return new LoadedPlugin( + manifest, + Plugin: null, + LoadContext: alc, + Error: new InvalidOperationException( + $"no IRenderPackPlugin implementation found in {manifest.EntryDll}")); + } + + if (pluginType is null && renderPackType is null) + { + return new LoadedPlugin( + manifest, + Plugin: null, + LoadContext: alc, + Error: new InvalidOperationException( + "the host did not supply any facility declared by this plugin")); + } + + object? sharedInstance = null; + if (pluginType is not null) + { + sharedInstance = Activator.CreateInstance(pluginType); + instance = (IAcDreamPlugin?)sharedInstance + ?? throw new InvalidOperationException( + $"could not construct IAcDreamPlugin {pluginType.FullName}"); + instance.Initialize(host); + } + + if (renderPackType is not null) + { + object renderObject = ReferenceEquals(renderPackType, pluginType) + ? sharedInstance! + : Activator.CreateInstance(renderPackType) + ?? throw new InvalidOperationException( + $"could not construct IRenderPackPlugin {renderPackType.FullName}"); + renderPackInstance = (IRenderPackPlugin)renderObject; + renderPackInstance.Register(countedRenderPacks!); + if (countedRenderPacks!.RegistrationCount == 0) + { + throw new InvalidOperationException( + $"render-pack entry point '{renderPackType.FullName}' registered no packs"); + } + } + + return new LoadedPlugin( + manifest, + instance, + alc, + Error: null, + renderPackInstance); } catch (Exception ex) { @@ -87,7 +184,45 @@ public static class PluginLoader manifest, Plugin: instance, LoadContext: alc, - Error: ex); + Error: ex, + RenderPackPlugin: renderPackInstance); + } + } + + private sealed class CountingRenderPackRegistry(IRenderPackRegistry inner) : + IRenderPackRegistry + { + private int _registrationCount; + + internal int RegistrationCount => Volatile.Read(ref _registrationCount); + + public IDisposable Register( + RenderPackDescriptor descriptor, + IRenderPackAssets assets) + { + IDisposable registration = inner.Register(descriptor, assets) + ?? throw new InvalidOperationException( + "The render-pack registry returned a null registration handle."); + Interlocked.Increment(ref _registrationCount); + return new CountedRegistration(this, registration); + } + + private sealed class CountedRegistration( + CountingRenderPackRegistry owner, + IDisposable inner) : IDisposable + { + private CountingRenderPackRegistry? _owner = owner; + private IDisposable? _inner = inner; + + public void Dispose() + { + CountingRenderPackRegistry? activeOwner = + Interlocked.Exchange(ref _owner, null); + if (activeOwner is null) + return; + Interlocked.Decrement(ref activeOwner._registrationCount); + Interlocked.Exchange(ref _inner, null)?.Dispose(); + } } } } diff --git a/src/AcDream.Core/Plugins/PluginManifest.cs b/src/AcDream.Core/Plugins/PluginManifest.cs index a6cfbd97..8bf2a708 100644 --- a/src/AcDream.Core/Plugins/PluginManifest.cs +++ b/src/AcDream.Core/Plugins/PluginManifest.cs @@ -2,6 +2,13 @@ using System.Text.Json; namespace AcDream.Core.Plugins; +/// Host facility an entry assembly declares in plugin.json. +public enum PluginKind +{ + Gameplay, + RenderPack, +} + public sealed record PluginManifest( string Id, string DisplayName, @@ -10,6 +17,32 @@ public sealed record PluginManifest( int ApiVersion, IReadOnlyList Dependencies) { + /// + /// Declared entry-point kinds. Old manifests omit kinds and remain + /// gameplay plugins, preserving the pre-render-pack loading contract. + /// + public IReadOnlyList Kinds { get; init; } = [PluginKind.Gameplay]; + + public PluginManifest( + string Id, + string DisplayName, + string Version, + string EntryDll, + int ApiVersion, + IReadOnlyList Dependencies, + IReadOnlyList Kinds) + : this(Id, DisplayName, Version, EntryDll, ApiVersion, Dependencies) + { + ArgumentNullException.ThrowIfNull(Kinds); + if (Kinds.Count == 0) + throw new ArgumentException("At least one plugin kind is required.", nameof(Kinds)); + this.Kinds = Kinds + .Distinct() + .ToArray(); + } + + public bool Declares(PluginKind kind) => Kinds.Contains(kind); + public static PluginManifest Parse(string json) { PluginManifestDto? dto; @@ -32,13 +65,40 @@ public sealed record PluginManifest( if (dto.ApiVersion <= 0) throw new PluginManifestException("apiVersion must be >= 1"); + IReadOnlyList kinds = ParseKinds(dto.Kinds); + return new PluginManifest( dto.Id!, dto.DisplayName!, dto.Version!, dto.EntryDll!, dto.ApiVersion, - dto.Dependencies ?? Array.Empty()); + dto.Dependencies ?? Array.Empty(), + kinds); + } + + private static IReadOnlyList ParseKinds(IReadOnlyList? values) + { + if (values is null) + return [PluginKind.Gameplay]; + if (values.Count == 0) + throw new PluginManifestException("kinds must contain at least one entry"); + + var kinds = new List(values.Count); + foreach (string? value in values) + { + if (string.IsNullOrWhiteSpace(value) + || !Enum.TryParse(value, ignoreCase: true, out PluginKind kind) + || !Enum.IsDefined(kind)) + { + throw new PluginManifestException( + $"unknown plugin kind: {value ?? ""}"); + } + + if (!kinds.Contains(kind)) + kinds.Add(kind); + } + return kinds; } private static void Require(string? value, string jsonFieldName) @@ -61,6 +121,7 @@ public sealed record PluginManifest( public string? EntryDll { get; set; } public int ApiVersion { get; set; } public IReadOnlyList? Dependencies { get; set; } + public IReadOnlyList? Kinds { get; set; } } } diff --git a/src/AcDream.Core/Plugins/PluginSession.cs b/src/AcDream.Core/Plugins/PluginSession.cs index 1436fb6b..82d1a2d3 100644 --- a/src/AcDream.Core/Plugins/PluginSession.cs +++ b/src/AcDream.Core/Plugins/PluginSession.cs @@ -1,4 +1,5 @@ using AcDream.Plugin.Abstractions; +using AcDream.Plugin.Abstractions.Rendering; namespace AcDream.Core.Plugins; @@ -17,16 +18,25 @@ public readonly record struct PluginSessionStatus( PluginSessionStatusKind Kind, string? Error = null); +/// A configured plugin declares no entry point usable by this host. +public sealed class PluginHostKindException : Exception +{ + public PluginHostKindException(string message) : base(message) { } +} + /// /// One host/session-scoped plugin lifetime. Discovery, allow-listing, -/// initialize/enable, failure isolation, reverse-order disable, and collectible -/// load-context release are shared by graphical and no-window hosts so their -/// configured plugin-set semantics cannot drift. +/// initialize/enable, declarative render-pack registration, failure isolation, +/// reverse-order disable, and collectible load-context release are shared by +/// graphical and no-window hosts so their configured plugin-set semantics +/// cannot drift. Unsupported kinds are filtered before assembly loading. /// public sealed class PluginSession : IDisposable { private readonly IPluginHost _host; private readonly Action? _report; + private readonly IRenderPackRegistry? _renderPacks; + private readonly HashSet _supportedKinds; private readonly List _loaded = []; private readonly List _releasedContexts = []; private bool _started; @@ -34,10 +44,28 @@ public sealed class PluginSession : IDisposable public PluginSession( IPluginHost host, - Action? report = null) + Action? report = null, + IRenderPackRegistry? renderPacks = null, + IEnumerable? supportedKinds = null) { _host = host ?? throw new ArgumentNullException(nameof(host)); _report = report; + _renderPacks = renderPacks; + _supportedKinds = new HashSet( + supportedKinds + ?? (renderPacks is null + ? [PluginKind.Gameplay] + : [PluginKind.Gameplay, PluginKind.RenderPack])); + if (_supportedKinds.Count == 0) + throw new ArgumentException( + "At least one supported plugin kind is required.", + nameof(supportedKinds)); + if (_supportedKinds.Contains(PluginKind.RenderPack) && renderPacks is null) + { + throw new ArgumentException( + "A host that supports render-pack plugins must supply a render-pack registry.", + nameof(renderPacks)); + } } public int LoadedCount => _loaded.Count; @@ -122,6 +150,25 @@ public sealed class PluginSession : IDisposable string id = result.Manifest!.Id; if (requestedSet is not null && !requestedSet.Contains(id)) continue; + if (!result.Manifest.Kinds.Any(_supportedKinds.Contains)) + { + // A shared plugin root may contain graphical-only packs. + // An unfiltered/headless scan omits those silently. An + // explicitly requested id gets one precise host-kind + // failure, still without loading its assembly. + if (requestedSet is not null) + { + AddOrdered(discoveredOrder, id); + AddError( + errors, + id, + new PluginHostKindException( + $"plugin '{id}' declares only " + + $"{string.Join(", ", result.Manifest.Kinds)} entry points, " + + "which this host does not support.")); + } + continue; + } AddOrdered(discoveredOrder, id); if (!candidates.TryGetValue(id, out List? list)) { @@ -160,23 +207,27 @@ public sealed class PluginSession : IDisposable { ActivePlugin active = _loaded[index]; LoadedPlugin loaded = active.Loaded; - try + if (loaded.Plugin is not null) { - loaded.Plugin!.Disable(); - } - catch (Exception error) - { - SafeLog( - static (log, message, exception) => - log.Error(message, exception), - $"plugin disable failed: {loaded.Manifest.Id}", - error); + try + { + loaded.Plugin.Disable(); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin disable failed: {loaded.Manifest.Id}", + error); + } } // Host-owned registrations are released even when Disable throws. // This must precede ALC unload so no UI binding or event delegate // can keep the plugin assembly reachable. active.Scope.Dispose(); + ReleaseRenderScope(active.RenderPackScope, loaded.Manifest.Id); try { @@ -208,16 +259,23 @@ public sealed class PluginSession : IDisposable foreach (PluginDiscoveryResult candidate in available) { var scope = new ScopedPluginHost(_host); + ScopedRenderPackRegistry? renderPackScope = + candidate.Manifest!.Declares(PluginKind.RenderPack) + && _renderPacks is not null + ? new ScopedRenderPackRegistry(_renderPacks) + : null; LoadedPlugin loaded = PluginLoader.Load( candidate.PluginDirectory, - candidate.Manifest!, - scope); + candidate.Manifest, + scope, + renderPackScope); if (!loaded.Success) { // Initialize can register callbacks before it fails. The // registration transaction closes before plugin cleanup // and, critically, before any ALC Unloading notification. scope.Dispose(); + ReleaseRenderScope(renderPackScope, candidate.Manifest.Id); ReleaseFailedLoad(loaded); AddError( errors, @@ -229,8 +287,8 @@ public sealed class PluginSession : IDisposable try { - loaded.Plugin!.Enable(); - _loaded.Add(new ActivePlugin(loaded, scope)); + loaded.Plugin?.Enable(); + _loaded.Add(new ActivePlugin(loaded, scope, renderPackScope)); SafeLog( static (log, message, _) => log.Info(message), $"plugin loaded: {loaded.Manifest.Id} " @@ -244,7 +302,7 @@ public sealed class PluginSession : IDisposable catch (Exception error) { AddError(errors, id, error); - ReleaseFailedEnable(loaded, scope); + ReleaseFailedEnable(loaded, scope, renderPackScope); } } } @@ -274,22 +332,27 @@ public sealed class PluginSession : IDisposable private void ReleaseFailedEnable( LoadedPlugin loaded, - ScopedPluginHost scope) + ScopedPluginHost scope, + ScopedRenderPackRegistry? renderPackScope) { - try + if (loaded.Plugin is not null) { - loaded.Plugin!.Disable(); - } - catch (Exception error) - { - SafeLog( - static (log, message, exception) => - log.Error(message, exception), - $"plugin cleanup after enable failure failed: {loaded.Manifest.Id}", - error); + try + { + loaded.Plugin.Disable(); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin cleanup after enable failure failed: {loaded.Manifest.Id}", + error); + } } scope.Dispose(); + ReleaseRenderScope(renderPackScope, loaded.Manifest.Id); _releasedContexts.Add(new WeakReference(loaded.LoadContext!)); try @@ -342,6 +405,26 @@ public sealed class PluginSession : IDisposable } } + private void ReleaseRenderScope( + ScopedRenderPackRegistry? scope, + string pluginId) + { + if (scope is null) + return; + try + { + scope.Dispose(); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"render-pack registration cleanup failed: {pluginId}", + error); + } + } + private void Report(PluginSessionStatus status) { if (_report is null) @@ -417,5 +500,6 @@ public sealed class PluginSession : IDisposable private sealed record ActivePlugin( LoadedPlugin Loaded, - ScopedPluginHost Scope); + ScopedPluginHost Scope, + ScopedRenderPackRegistry? RenderPackScope); } diff --git a/src/AcDream.Core/Plugins/ScopedRenderPackRegistry.cs b/src/AcDream.Core/Plugins/ScopedRenderPackRegistry.cs new file mode 100644 index 00000000..6479fd4e --- /dev/null +++ b/src/AcDream.Core/Plugins/ScopedRenderPackRegistry.cs @@ -0,0 +1,96 @@ +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.Core.Plugins; + +/// +/// Per-plugin render-pack registration transaction. Every successful catalog +/// registration is withdrawn before the plugin's collectible load context is +/// released, including partial Register failures. +/// +internal sealed class ScopedRenderPackRegistry : IRenderPackRegistry, IDisposable +{ + private readonly IRenderPackRegistry _inner; + private readonly object _gate = new(); + private readonly List _registrations = []; + private bool _disposed; + + internal ScopedRenderPackRegistry(IRenderPackRegistry inner) => + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + + public IDisposable Register( + RenderPackDescriptor descriptor, + IRenderPackAssets assets) + { + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(assets); + + lock (_gate) + ObjectDisposedException.ThrowIf(_disposed, this); + + IDisposable innerRegistration = _inner.Register(descriptor, assets) + ?? throw new InvalidOperationException( + "The render-pack registry returned a null registration handle."); + var registration = new RegistrationHandle(this, innerRegistration); + lock (_gate) + { + if (!_disposed) + { + _registrations.Add(registration); + return registration; + } + } + + registration.Dispose(); + throw new ObjectDisposedException(nameof(ScopedRenderPackRegistry)); + } + + public void Dispose() + { + RegistrationHandle[] registrations; + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + registrations = _registrations.ToArray(); + _registrations.Clear(); + } + + List? failures = null; + for (int index = registrations.Length - 1; index >= 0; index--) + { + try { registrations[index].Dispose(); } + catch (Exception error) { (failures ??= []).Add(error); } + } + + if (failures is not null) + { + throw new AggregateException( + "One or more render-pack registrations could not be withdrawn.", + failures); + } + } + + private void Release(RegistrationHandle registration) + { + lock (_gate) + _registrations.Remove(registration); + registration.DisposeInner(); + } + + private sealed class RegistrationHandle( + ScopedRenderPackRegistry owner, + IDisposable inner) : IDisposable + { + private ScopedRenderPackRegistry? _owner = owner; + private IDisposable? _inner = inner; + + public void Dispose() + { + Interlocked.Exchange(ref _owner, null)?.Release(this); + } + + internal void DisposeInner() => + Interlocked.Exchange(ref _inner, null)?.Dispose(); + } +} diff --git a/src/AcDream.Core/Rendering/TranslucencyFadeManager.cs b/src/AcDream.Core/Rendering/TranslucencyFadeManager.cs index 58be5c04..4e9633a9 100644 --- a/src/AcDream.Core/Rendering/TranslucencyFadeManager.cs +++ b/src/AcDream.Core/Rendering/TranslucencyFadeManager.cs @@ -53,6 +53,15 @@ public sealed class TranslucencyFadeManager // frames keep reading the settled value. private readonly Dictionary> _committed = new(); + private ulong _revision = 1; + + /// + /// Advances whenever the committed per-part opacity topology changes. + /// Render products may use this to retain classification while no fade + /// state changes, and to rebuild exact caster membership when it does. + /// + public ulong Revision => _revision; + /// /// Start (or replace) a translucency ramp for one Setup part of one /// entity. Mirrors CPhysicsObj::SetPartTranslucency: a @@ -151,8 +160,10 @@ public sealed class TranslucencyFadeManager /// Drop all fade state for an entity (despawn / unload). public void ClearEntity(uint entityId) { - _activeFades.Remove(entityId); - _committed.Remove(entityId); + bool changed = _activeFades.Remove(entityId); + changed |= _committed.Remove(entityId); + if (changed) + AdvanceRevision(); } private void Commit(uint entityId, uint partIndex, float value) @@ -162,6 +173,26 @@ public sealed class TranslucencyFadeManager parts = new Dictionary(); _committed[entityId] = parts; } + + if (parts.TryGetValue(partIndex, out float prior) + && BitConverter.SingleToInt32Bits(prior) + == BitConverter.SingleToInt32Bits(value)) + { + return; + } + parts[partIndex] = value; + AdvanceRevision(); + } + + private void AdvanceRevision() + { + if (_revision == ulong.MaxValue) + { + throw new InvalidOperationException( + "Translucency fade revision space was exhausted."); + } + + _revision++; } } diff --git a/src/AcDream.Core/Terrain/LandblockMesh.cs b/src/AcDream.Core/Terrain/LandblockMesh.cs index 81e67249..e3a09fca 100644 --- a/src/AcDream.Core/Terrain/LandblockMesh.cs +++ b/src/AcDream.Core/Terrain/LandblockMesh.cs @@ -56,27 +56,21 @@ public static class LandblockMesh throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable)); // Pre-sample all 81 heights into a 2D array (x-major indexing). This - // doubles as the source for per-vertex normals via central differences - // (Phase 3b lighting, preserved through the per-cell refactor). + // is also the source for retail's topology-aware vertex normals. var heights = new float[HeightmapSide, HeightmapSide]; for (int x = 0; x < HeightmapSide; x++) for (int y = 0; y < HeightmapSide; y++) heights[x, y] = heightTable[block.Height[x * HeightmapSide + y]]; - // Pre-compute all 81 vertex normals so the inner cell loop is a pure - // lookup. Central differences on the heightmap → smooth normal field. - var normals = new Vector3[HeightmapSide, HeightmapSide]; - for (int x = 0; x < HeightmapSide; x++) - for (int y = 0; y < HeightmapSide; y++) - { - int xL = Math.Max(x - 1, 0); - int xR = Math.Min(x + 1, HeightmapSide - 1); - int yD = Math.Max(y - 1, 0); - int yU = Math.Min(y + 1, HeightmapSide - 1); - float dx = (heights[xR, y] - heights[xL, y]) / ((xR - xL) * CellSize); - float dy = (heights[x, yU] - heights[x, yD]) / ((yU - yD) * CellSize); - normals[x, y] = Vector3.Normalize(new Vector3(-dx, -dy, 1f)); - } + // Retail CLandBlockStruct::calc_lighting accumulates the normalized + // plane normal of every incident terrain polygon at each of the 81 + // shared height-sample vertices, then normalizes the sum. Use the same + // split hash and triangle topology as the emitted mesh; this changes + // lighting only, never positions, indices, or the collision surface. + var normals = BuildRetailVertexNormals( + heights, + landblockX, + landblockY); var vertices = new TerrainVertex[VerticesPerLandblock]; var indices = new uint[VerticesPerLandblock]; // 1 index per vertex (no deduplication) @@ -173,6 +167,85 @@ public static class LandblockMesh return new LandblockMeshData(vertices, indices); } + private static Vector3[,] BuildRetailVertexNormals( + float[,] heights, + uint landblockX, + uint landblockY) + { + var normalSums = new Vector3[HeightmapSide, HeightmapSide]; + + for (int cy = 0; cy < CellsPerSide; cy++) + { + for (int cx = 0; cx < CellsPerSide; cx++) + { + var posBL = new Vector3( cx * CellSize, cy * CellSize, heights[cx, cy ]); + var posBR = new Vector3((cx + 1) * CellSize, cy * CellSize, heights[cx + 1, cy ]); + var posTR = new Vector3((cx + 1) * CellSize, (cy + 1) * CellSize, heights[cx + 1, cy + 1]); + var posTL = new Vector3( cx * CellSize, (cy + 1) * CellSize, heights[cx, cy + 1]); + + var split = TerrainBlending.CalculateSplitDirection( + landblockX, (uint)cx, landblockY, (uint)cy); + + if (split == CellSplitDirection.SWtoNE) + { + AccumulateFaceNormal( + normalSums, + posBL, cx, cy, + posBR, cx + 1, cy, + posTR, cx + 1, cy + 1); + AccumulateFaceNormal( + normalSums, + posBL, cx, cy, + posTR, cx + 1, cy + 1, + posTL, cx, cy + 1); + } + else + { + AccumulateFaceNormal( + normalSums, + posBL, cx, cy, + posBR, cx + 1, cy, + posTL, cx, cy + 1); + AccumulateFaceNormal( + normalSums, + posBR, cx + 1, cy, + posTR, cx + 1, cy + 1, + posTL, cx, cy + 1); + } + } + } + + var normals = new Vector3[HeightmapSide, HeightmapSide]; + for (int x = 0; x < HeightmapSide; x++) + { + for (int y = 0; y < HeightmapSide; y++) + { + Vector3 sum = normalSums[x, y]; + normals[x, y] = sum.LengthSquared() > 0f + ? Vector3.Normalize(sum) + : Vector3.UnitZ; + } + } + + return normals; + } + + private static void AccumulateFaceNormal( + Vector3[,] normalSums, + Vector3 p0, int x0, int y0, + Vector3 p1, int x1, int y1, + Vector3 p2, int x2, int y2) + { + Vector3 cross = Vector3.Cross(p1 - p0, p2 - p0); + if (cross.LengthSquared() <= 0f) + return; + + Vector3 faceNormal = Vector3.Normalize(cross); + normalSums[x0, y0] += faceNormal; + normalSums[x1, y1] += faceNormal; + normalSums[x2, y2] += faceNormal; + } + private static void WriteCell( TerrainVertex[] verts, ref int vi, uint d0, uint d1, uint d2, uint d3, diff --git a/src/AcDream.Core/Terrain/TerrainVertex.cs b/src/AcDream.Core/Terrain/TerrainVertex.cs index a031e837..69775c23 100644 --- a/src/AcDream.Core/Terrain/TerrainVertex.cs +++ b/src/AcDream.Core/Terrain/TerrainVertex.cs @@ -11,11 +11,11 @@ namespace AcDream.Core.Terrain; /// which of the 4 cell corners a given vertex represents from /// gl_VertexID % 6 plus the split direction bit. /// -/// Normal is stored per vertex via Phase 3b's central-difference scheme on -/// the 9×9 heightmap — this lets the fragment shader interpolate a smooth -/// normal across triangles (softer than WorldBuilder's dFdx/dFdy -/// flat-shaded approach). UVs are derived from the corner index in the -/// vertex shader — not stored here. +/// Normal is stored per vertex using retail's terrain-lighting rule: each +/// shared height-sample vertex receives the normalized plane normals of its +/// incident, split-aware terrain triangles and normalizes their sum. The +/// fragment shader interpolates that smooth result across triangles. UVs are +/// derived from the corner index in the vertex shader — not stored here. /// /// Size: 12 (position) + 12 (normal) + 4*4 (Data0..3) = 40 bytes. /// diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs index 3311cef4..fdfb2faf 100644 --- a/src/AcDream.Core/World/SkyDescLoader.cs +++ b/src/AcDream.Core/World/SkyDescLoader.cs @@ -38,6 +38,14 @@ public sealed class SkyObjectData public uint PesObjectId; public uint Properties; + /// + /// Source GfxObj sort centre. Celestial billboards are authored at their + /// apparent direction from the camera, so transforming and normalizing + /// this point yields the exact direction rendered by the sky pass. Zero + /// means the optional DAT lookup was unavailable. + /// + public Vector3 AuthoredSortCenter; + /// /// True when this SkyObject is gated on the weather system (Properties /// bit 0x04). Per the named retail decomp, @@ -140,6 +148,9 @@ public sealed class SkyObjectReplaceData public float Transparent; public float Luminosity; public float MaxBright; + + /// Sort centre for a replacement . + public Vector3 AuthoredSortCenter; } /// @@ -334,7 +345,7 @@ public static class SkyDescLoader ArgumentNullException.ThrowIfNull(dats); var region = dats.Get(RegionDatId); if (region is null) return null; - return LoadFromRegion(region); + return LoadFromRegion(region, dats); } /// @@ -352,7 +363,9 @@ public static class SkyDescLoader /// GfxObjReplace swap pattern. /// /// - public static LoadedSkyDesc? LoadFromRegion(Region region) + public static LoadedSkyDesc? LoadFromRegion( + Region region, + IDatObjectSource? dats = null) { ArgumentNullException.ThrowIfNull(region); if (!region.PartsMask.HasFlag(PartsMask.HasSkyInfo) || region.SkyInfo is null) @@ -367,8 +380,12 @@ public static class SkyDescLoader foreach (var dg in sky.DayGroups) { - var objs = dg.SkyObjects.Select(ConvertSkyObject).ToList(); - var times = dg.SkyTime.Select(ConvertTimeOfDay).ToList(); + var objs = dg.SkyObjects + .Select(value => ConvertSkyObject(value, dats)) + .ToList(); + var times = dg.SkyTime + .Select(value => ConvertTimeOfDay(value, dats)) + .ToList(); dayGroups.Add(new DayGroupData { @@ -495,7 +512,9 @@ public static class SkyDescLoader Console.WriteLine("[sky-dump] ======== END SkyDesc dump ========"); } - private static SkyObjectData ConvertSkyObject(SkyObject s) => new() + private static SkyObjectData ConvertSkyObject( + SkyObject s, + IDatObjectSource? dats) => new() { BeginTime = s.BeginTime, EndTime = s.EndTime, @@ -506,9 +525,14 @@ public static class SkyDescLoader GfxObjId = s.DefaultGfxObjectId?.DataId ?? 0u, PesObjectId = s.DefaultPesObjectId?.DataId ?? 0u, Properties = s.Properties, + AuthoredSortCenter = ResolveSortCenter( + s.DefaultGfxObjectId?.DataId ?? 0u, + dats), }; - private static DatSkyKeyframeData ConvertTimeOfDay(SkyTimeOfDay s) + private static DatSkyKeyframeData ConvertTimeOfDay( + SkyTimeOfDay s, + IDatObjectSource? dats) { // Transparent / Luminosity / MaxBright are stored in the retail // Region dat as PERCENTAGES (0..100), not fractions (0..1). Our @@ -537,6 +561,9 @@ public static class SkyDescLoader Transparent = r.Transparent / 100f, Luminosity = r.Luminosity / 100f, MaxBright = r.MaxBright / 100f, + AuthoredSortCenter = ResolveSortCenter( + r.GfxObjId?.DataId ?? 0u, + dats), }).ToList(); var fogMode = s.WorldFog switch @@ -577,6 +604,26 @@ public static class SkyDescLoader }; } + private static Vector3 ResolveSortCenter( + uint gfxObjId, + IDatObjectSource? dats) + { + if (dats is null || (gfxObjId & 0xFF000000u) != 0x01000000u) + return Vector3.Zero; + + try + { + return dats.TryGet(gfxObjId, out var gfx) && gfx is not null + ? gfx.SortCenter + : Vector3.Zero; + } + catch + { + // Enhancement metadata cannot make authoritative sky loading fail. + return Vector3.Zero; + } + } + /// /// stores bytes as B,G,R,A — but the logical /// channel mapping is just "R/G/B in 0..255". Convert to linear diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs index d199752b..6791670f 100644 --- a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs +++ b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs @@ -60,7 +60,9 @@ internal sealed class HeadlessPluginSession : IDisposable () => runtime.Generation.Value)); var plugins = new PluginSession( host, - status => Report(statusWriter, sessionId, status)); + status => Report(statusWriter, sessionId, status), + renderPacks: null, + supportedKinds: [PluginKind.Gameplay]); return new HeadlessPluginSession( host, plugins, diff --git a/src/AcDream.Platform/ApplicationPathSet.cs b/src/AcDream.Platform/ApplicationPathSet.cs index a6743ad1..76e3a1f5 100644 --- a/src/AcDream.Platform/ApplicationPathSet.cs +++ b/src/AcDream.Platform/ApplicationPathSet.cs @@ -72,6 +72,16 @@ public sealed record ApplicationPathSet( { platform ??= ApplicationPathEnvironment.Instance; + // Explicit method/CLI arguments remain authoritative. The environment + // seam lets graphical automation and portable installations isolate + // all mutable user state without rewriting the real user's settings. + configDirectory ??= NonEmpty( + platform.GetEnvironmentVariable("ACDREAM_CONFIG_DIR")); + dataDirectory ??= NonEmpty( + platform.GetEnvironmentVariable("ACDREAM_DATA_DIR")); + cacheDirectory ??= NonEmpty( + platform.GetEnvironmentVariable("ACDREAM_CACHE_DIR")); + string config; string data; string cache; @@ -140,6 +150,9 @@ public sealed record ApplicationPathSet( return Path.Combine(root, leaf); } + private static string? NonEmpty(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value; + private static string RequireFolder( IApplicationPathEnvironment platform, Environment.SpecialFolder folder) diff --git a/src/AcDream.Plugin.Abstractions/Rendering/RenderPackContracts.cs b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackContracts.cs new file mode 100644 index 00000000..03ea1d65 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackContracts.cs @@ -0,0 +1,50 @@ +namespace AcDream.Plugin.Abstractions.Rendering; + +/// The public render-pack contract version. +public static class RenderPackApi +{ + /// The newest contract this build implements. + public const int Current = 1; + + /// The oldest contract this build can still consume. + public const int MinimumSupported = 1; + + /// Whether a descriptor's contract version can be consumed. + public static bool IsSupported(int apiVersion) => + apiVersion >= MinimumSupported && apiVersion <= Current; +} + +/// +/// Optional plugin entry point for declarative graphics enhancements. The host +/// invokes this only in a graphical process; no-window hosts never expose a +/// registry or ask a render-pack entry point to register. +/// +public interface IRenderPackPlugin +{ + /// Register every pack supplied by this plugin. + void Register(IRenderPackRegistry registry); +} + +/// +/// Host-owned render-pack catalog. Registration is descriptive only: it must +/// not open assets or allocate GPU objects. +/// +public interface IRenderPackRegistry +{ + /// + /// Register one immutable descriptor and its lazy asset source. Disposing + /// the returned handle withdraws the pack and every host reference to the + /// asset source. + /// + IDisposable Register(RenderPackDescriptor descriptor, IRenderPackAssets assets); +} + +/// +/// Lazy, key-addressed assets for one pack. The renderer opens assets only +/// while validating an explicitly selected candidate. +/// +public interface IRenderPackAssets +{ + /// Open a new readable stream for a descriptor-declared key. + Stream OpenRead(string assetKey); +} diff --git a/src/AcDream.Plugin.Abstractions/Rendering/RenderPackDeclarations.cs b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackDeclarations.cs new file mode 100644 index 00000000..d93fb470 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackDeclarations.cs @@ -0,0 +1,432 @@ +namespace AcDream.Plugin.Abstractions.Rendering; + +/// Prerequisite tier reached by a pack. +public enum RenderPackTier +{ + Tier1 = 1, + Tier2 = 2, + Tier2Plus = 3, +} + +/// +/// Renderer-owned facilities a pack may require or use opportunistically. +/// These are semantic capabilities, not Vulkan extension or feature names. +/// +public enum RenderCapability +{ + MainWorldColorIntermediate, + FullscreenPasses, + SceneDepthSampling, + SceneNormalSampling, + AuthoredSunDirection, + AuthoredSunScreenPosition, + AuthoredWeather, + DirectionalShadowMaps, + OutdoorDirectionalShadowCasterReplay, + AnimatedCasterTransforms, + AlphaCutoutShadowCasters, + GpuTimestampQueries, + /// One layered directional-depth pass may address multiple cascade views. + MultiviewDirectionalShadowCascades, + /// One renderer-selected authored sun-or-moon shadow direction. + AuthoredCelestialDirectionalLight, +} + +/// Fixed renderer-owned positions at which a declared pass may run. +public enum RenderPassHook +{ + ShadowDepthBeforeWorld, + AtmosphereBeforeToneMap, + ToneMap, + AfterToneMapBeforePrivateViewports, +} + +/// Immutable frame facts the renderer may bind for a pack. +public enum RenderSemanticInput +{ + WorldColor, + SceneDepth, + SceneNormals, + SunDirection, + SunScreenPosition, + ActiveDayGroup, + Weather, + CameraMatrices, + ShadowCasterTransforms, + DirectionalShadowMaps, + FrameTime, + /// Selected surface-to-sun-or-moon direction for shadow work. + SelectedCelestialDirectionalLight, +} + +/// Renderer-owned replay operations available to a declaration. +public enum RenderSceneReplaySemantic +{ + OutdoorDirectionalShadowCasters, +} + +/// Existing retained-scene classes eligible for a scene replay. +[Flags] +public enum RenderCasterClass +{ + None = 0, + Terrain = 1 << 0, + OpaqueWorld = 1 << 1, + AlphaCutoutWorld = 1 << 2, + AnimatedOpaque = 1 << 3, + AnimatedAlphaCutout = 1 << 4, +} + +/// Base renderer pipeline a pack may specialize. +public enum RenderPipelineBaseSemantic +{ + Terrain, + WorldMesh, + EnvCell, +} + +/// Material classifications accepted by a pipeline variant. +[Flags] +public enum RenderMaterialClass +{ + None = 0, + Opaque = 1 << 0, + AlphaCutout = 1 << 1, + AnimatedOpaque = 1 << 2, + AnimatedAlphaCutout = 1 << 3, +} + +/// Kind of renderer-owned intermediate resource. +public enum RenderResourceKind +{ + Image2D, + Image2DArray, + Buffer, +} + +/// Portable format families resolved by the renderer. +public enum RenderFormatClass +{ + LdrColor, + HdrColor, + SingleChannel, + DirectionalDepth, + StructuredData, +} + +/// How declared image dimensions are interpreted. +public enum RenderExtentMode +{ + AbsolutePixels, + RelativeToMainWorld, + RelativeToOutput, +} + +/// Permitted uses of a declared resource. +[Flags] +public enum RenderResourceUsage +{ + None = 0, + Sampled = 1 << 0, + ColorAttachment = 1 << 1, + DepthAttachment = 1 << 2, + Storage = 1 << 3, + TransferSource = 1 << 4, + TransferDestination = 1 << 5, +} + +/// Lifetime class used by the renderer's frame-flight allocator. +public enum RenderResourceLifetime +{ + TransientPass, + FrameFlight, + ActivePack, +} + +/// +/// Renderer-owned meaning of a declared resource. is +/// available to ordinary declarative fullscreen graphs; the remaining values +/// let a pack request host executors without relying on magic resource IDs. +/// +public enum RenderResourceSemantic +{ + Custom, + MainWorldHdr, + BloomPing, + BloomPong, + SunOcclusionMask, + SunRays, + DirectionalShadowDepth, + VolumetricShafts, +} + +/// Shape of one image declaration. +/// Absolute pixels or a scale relative to a renderer surface. +/// Pixel width for absolute mode; horizontal scale otherwise. +/// Pixel height for absolute mode; vertical scale otherwise. +/// Array layers; one for an ordinary 2-D image. +public sealed record RenderExtentDeclaration( + RenderExtentMode Mode, + double Width, + double Height, + int Layers = 1); + +/// One renderer-owned intermediate image or buffer. +public sealed record RenderResourceDeclaration( + string Id, + RenderResourceKind Kind, + RenderFormatClass Format, + RenderExtentDeclaration? Extent, + long SizeBytes, + RenderResourceUsage Usage, + RenderResourceLifetime Lifetime, + long EstimatedResidentBytes) +{ + public RenderResourceSemantic Semantic { get; init; } = RenderResourceSemantic.Custom; +} + +/// +/// Renderer-owned execution meaning of a pass. IDs remain pack-owned stable +/// identifiers; semantic execution never depends on a particular ID string. +/// +public enum RenderPassSemantic +{ + CustomFullscreen, + DirectionalShadowDepth, + BloomDownsample, + BloomBlurHorizontal, + BloomBlurVertical, + SunOcclusion, + SunRays, + VolumetricShafts, + FilmicComposite, +} + +/// One declarative full-screen, atmosphere, or tone-map pass. +public sealed record RenderPassDeclaration( + string Id, + RenderPassHook Hook, + string VertexShaderAsset, + string FragmentShaderAsset, + IReadOnlyList SemanticInputs, + IReadOnlyList ResourceReads, + IReadOnlyList ResourceWrites) +{ + public RenderPassSemantic Semantic { get; init; } = RenderPassSemantic.CustomFullscreen; +} + +/// A renderer-owned replay of retained scene geometry. +public sealed record SceneReplayDeclaration( + string Id, + RenderSceneReplaySemantic Semantic, + RenderCasterClass CasterClasses, + int ViewCount); + +/// A shader specialization of an existing renderer pipeline. +public sealed record PipelineVariantDeclaration( + string Id, + RenderPipelineBaseSemantic BaseSemantic, + string VertexShaderAsset, + string FragmentShaderAsset, + RenderMaterialClass CompatibleMaterials, + IReadOnlyList SemanticInputs) +{ + public RenderPipelineVariantSemantic Semantic { get; init; } = + RenderPipelineVariantSemantic.Custom; +} + +/// Renderer-owned role of a fixed retained-scene pipeline variant. +public enum RenderPipelineVariantSemantic +{ + Custom, + TerrainDirectionalShadowCaster, + WorldOpaqueDirectionalShadowCaster, + WorldAlphaCutoutDirectionalShadowCaster, + TerrainDirectionalShadowReceiver, + WorldDirectionalShadowReceiver, + TerrainMultiviewDirectionalShadowCaster, + WorldOpaqueMultiviewDirectionalShadowCaster, + WorldAlphaCutoutMultiviewDirectionalShadowCaster, +} + +/// Per-preset replacement for one resource's size. +public sealed record RenderQualityResourceOverride( + string ResourceId, + RenderExtentDeclaration? Extent, + long SizeBytes, + long EstimatedResidentBytes); + +/// Per-preset value for a declared user setting. +public sealed record RenderQualitySettingOverride( + string SettingId, + string Value); + +/// One user-selectable, independently capability-gated preset. +public sealed record RenderQualityPreset( + string Id, + string DisplayName, + IReadOnlyList RequiredCapabilities, + IReadOnlyList ResourceOverrides, + IReadOnlyList SettingOverrides, + long MaxResidentGpuBytes, + double MaxIncrementalGpuMillisecondsP50, + double MaxIncrementalGpuMillisecondsP99, + double MaxIncrementalCpuMillisecondsP50, + double MaxIncrementalCpuMillisecondsP99, + bool AutoEligible = true) +{ + public RenderQualitySemantic Semantic { get; init; } = RenderQualitySemantic.Custom; + + /// + /// Optional renderer-owned execution optimizations whose shader ABI the + /// pack explicitly implements. The host never infers these from a pack ID. + /// + public RenderQualityExecutionHints ExecutionHints { get; init; } = + RenderQualityExecutionHints.None; +} + +/// +/// Opt-in execution forms for renderer-owned atmospheric work. These hints +/// may fuse passes or compatible submissions; they do not remove declared +/// effects or caster classes from the final image. +/// +[Flags] +public enum RenderQualityExecutionHints +{ + None = 0, + + /// + /// The sun-rays shader accepts scene depth directly and filmic composite + /// evaluates the declared bloom extraction/filter while composing the + /// image. See the standard PackPass ABI flags. + /// + FusedAtmosphericPostProcess = 1 << 0, + + /// + /// The three multiview caster variants select the exact cascade matrix with + /// the renderer-owned view index and render every declared Low cascade in + /// one layered depth pass. + /// + MultiviewDirectionalShadowCascades = 1 << 1, +} + +/// +/// Optional host quality role. Pack-owned IDs remain persisted; this semantic +/// is used only when a pack opts into the host's automatic-quality controller. +/// +public enum RenderQualitySemantic +{ + Custom, + Low, + Medium, + High, + Automatic, +} + +/// Storage and presentation kind for a pack-defined setting. +public enum RenderSettingKind +{ + Boolean, + Integer, + Float, + Choice, +} + +/// A bounded, user-visible pack setting. +public sealed record RenderSettingDeclaration( + string Id, + string DisplayName, + RenderSettingKind Kind, + string DefaultValue, + double? Minimum, + double? Maximum, + double? Step, + IReadOnlyList Choices) +{ + public RenderSettingSemantic Semantic { get; init; } = RenderSettingSemantic.Custom; +} + +/// +/// Optional host meaning for settings consumed by a renderer-owned atmospheric +/// executor. Ordinary pack settings use . +/// +public enum RenderSettingSemantic +{ + Custom, + BloomStrength, + FilmicStrength, + Exposure, + GradeSaturation, + GradeContrast, + VignetteStrength, + SunRayStrength, + DirectionalShadowStrength, + DirectionalShadowReachMetres, + DirectionalShadowPcfTaps, + VolumetricStrength, + VolumetricRayMarchSteps, + AutomaticQuality, +} + +/// One point on a declared sun-elevation response curve. +public sealed record SunElevationResponsePoint( + double ElevationDegrees, + double Multiplier); + +/// Explicit mapping from an authored AC day group to an effect multiplier. +public sealed record ActiveDayGroupMultiplier( + int ActiveDayGroup, + double Multiplier); + +/// Visible authored-atmosphere interpretation owned by the pack. +public sealed record AtmospherePolicyDeclaration( + IReadOnlyList SunElevationResponse, + IReadOnlyList ActiveDayGroupMultipliers) +{ + /// + /// Optional selected-light elevation curve for directional shadows. The host + /// linearly interpolates adjacent points in sine-of-elevation space and + /// clamps beyond the endpoints. + /// The resolved multiplier must be zero at and below the authored + /// 0-degree horizon: every non-positive control point must be zero and, + /// when no exact 0-degree point is declared, the first positive control + /// point must also be zero. + /// A pack using the directional-shadow semantic must declare this curve. + /// + public IReadOnlyList DirectionalShadowLightElevationResponse + { get; init; } = []; + + /// + /// Optional moving-sun strength curve for volumetric shafts. The host + /// smoothstep-interpolates adjacent points in elevation-degree space and + /// clamps beyond the endpoints. + /// A pack using the volumetric-shaft semantic must declare this curve. + /// + public IReadOnlyList VolumetricShaftSunElevationResponse + { get; init; } = []; +} + +/// +/// Complete immutable declaration for one render pack. Packs describe what +/// they need; the renderer validates and owns every concrete resource, pass, +/// pipeline, barrier, and scene replay. +/// +public sealed record RenderPackDescriptor( + string Id, + string DisplayName, + Version PackVersion, + int PackApiVersion, + RenderPackTier HighestTier, + IReadOnlyList RequiredCapabilities, + IReadOnlyList OptionalCapabilities, + IReadOnlyList Resources, + IReadOnlyList Passes, + IReadOnlyList SceneReplays, + IReadOnlyList PipelineVariants, + IReadOnlyList QualityPresets, + IReadOnlyList Settings, + AtmospherePolicyDeclaration? AtmospherePolicy) +{ + /// Short user-facing description shown beside compatibility and cost. + public string FeatureSummary { get; init; } = string.Empty; +} diff --git a/src/AcDream.Plugin.Abstractions/Rendering/RenderPackSettingValueCodec.cs b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackSettingValueCodec.cs new file mode 100644 index 00000000..e6261f9f --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackSettingValueCodec.cs @@ -0,0 +1,113 @@ +using System.Globalization; + +namespace AcDream.Plugin.Abstractions.Rendering; + +/// +/// API-v1 grammar and scalar encoding for a declared render-pack setting. +/// Both authoring tools and the graphical host use this codec so a value +/// cannot validate one way and bind another way. +/// +public static class RenderPackSettingValueCodec +{ + private const long ExactFloatIntegerLimit = 16_777_216L; + + /// + /// Validate and encode one string value for set-1/binding-8. The encoded + /// value is zero on failure, matching the host block's fail-safe fill. + /// + public static bool TryEncode( + RenderSettingDeclaration setting, + string? value, + out float encoded) + { + ArgumentNullException.ThrowIfNull(setting); + encoded = 0f; + if (value is null) + return false; + + switch (setting.Kind) + { + case RenderSettingKind.Boolean: + if (!bool.TryParse(value, out bool boolean)) + return false; + encoded = boolean ? 1f : 0f; + return true; + + case RenderSettingKind.Integer: + if (!long.TryParse( + value, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out long integer) + || integer is < -ExactFloatIntegerLimit or > ExactFloatIntegerLimit + || !WithinBounds(integer, setting) + || !AlignedToStep(integer, setting)) + { + return false; + } + encoded = integer; + return true; + + case RenderSettingKind.Float: + if (!double.TryParse( + value, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out double floating) + || !double.IsFinite(floating) + || floating < -float.MaxValue + || floating > float.MaxValue + || !WithinBounds(floating, setting) + || !AlignedToStep(floating, setting)) + { + return false; + } + encoded = (float)floating; + return float.IsFinite(encoded); + + case RenderSettingKind.Choice: + int choice = IndexOf(setting.Choices, value); + if (choice < 0) + return false; + encoded = choice; + return true; + + default: + return false; + } + } + + private static bool WithinBounds( + double value, + RenderSettingDeclaration setting) => + (setting.Minimum is null || value >= setting.Minimum.Value) + && (setting.Maximum is null || value <= setting.Maximum.Value); + + private static bool AlignedToStep( + double value, + RenderSettingDeclaration setting) + { + if (setting.Step is not { } step) + return true; + if (!double.IsFinite(step) || step <= 0) + return false; + double origin = setting.Minimum ?? 0d; + double quotient = (value - origin) / step; + if (!double.IsFinite(quotient)) + return false; + double tolerance = Math.Max(1e-7, Math.Abs(quotient) * 1e-7); + return Math.Abs(quotient - Math.Round(quotient)) <= tolerance; + } + + private static int IndexOf(IReadOnlyList? choices, string value) + { + if (choices is null) + return -1; + for (int i = 0; i < choices.Count; i++) + { + if (string.Equals(choices[i], value, StringComparison.Ordinal)) + return i; + } + return -1; + } +} diff --git a/src/AcDream.Plugin.Abstractions/Rendering/RenderPackShaderAbi.cs b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackShaderAbi.cs new file mode 100644 index 00000000..71221a30 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackShaderAbi.cs @@ -0,0 +1,28 @@ +namespace AcDream.Plugin.Abstractions.Rendering; + +/// +/// Numeric SPIR-V interface contract for v1. +/// Descriptor sets and resources remain host-owned; these constants expose +/// layout numbers only and are not Vulkan handles. +/// +public static class RenderPackShaderAbi +{ + public const int UniformDescriptorSet = 3; + public const int AtmosphericFrameBinding = 5; + public const int AtmosphericFrameSizeBytes = 160; + public const int DirectionalShadowBinding = 6; + public const int DirectionalShadowSizeBytes = 336; + public const int PackPassBinding = 7; + public const int PackPassSizeBytes = 64; + public const int PackSettingsBinding = 8; + public const int PackSettingsSizeBytes = 256; + public const int PackSettingScalarCapacity = 64; + + public const int SampledTextureDescriptorSet = 2; + public const int SampledTextureBinding = 0; + public const int SampledPassInputCapacity = 4; + + public const int PushConstantSizeBytes = 96; + + public const int MaximumShaderAssetBytes = 16 * 1024 * 1024; +} diff --git a/src/AcDream.Plugin.Abstractions/Rendering/RenderPackSpirvValidator.cs b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackSpirvValidator.cs new file mode 100644 index 00000000..0d371633 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackSpirvValidator.cs @@ -0,0 +1,611 @@ +using System.Buffers.Binary; + +namespace AcDream.Plugin.Abstractions.Rendering; + +/// The shader stage a render-pack declaration assigns to one SPIR-V asset. +public enum RenderPackShaderStage +{ + Vertex, + Fragment, +} + +/// Hardware-independent validation result for one declared SPIR-V module. +public readonly record struct RenderPackSpirvValidationResult(bool Success, string? Reason) +{ + public static RenderPackSpirvValidationResult Valid() => new(true, null); + + public static RenderPackSpirvValidationResult Invalid(string reason) => new(false, reason); +} + +/// +/// BCL-only SPIR-V interface validator for render-pack API v1. This validates +/// the binary handed to Vulkan, not filenames or GLSL source. Descriptor and +/// push-constant handles remain host-owned. +/// +public static class RenderPackSpirvValidator +{ + private const uint SpirvMagic = 0x0723_0203; + private const uint VertexExecutionModel = 0; + private const uint FragmentExecutionModel = 4; + + public static RenderPackSpirvValidationResult ValidatePassShader( + ReadOnlySpan spirv, + RenderPackShaderStage stage, + RenderPassDeclaration pass) + { + ArgumentNullException.ThrowIfNull(pass); + bool declaresSampledInput = pass.ResourceReads.Count != 0 + || pass.SemanticInputs.Any(static semantic => semantic is + RenderSemanticInput.WorldColor + or RenderSemanticInput.SceneDepth + or RenderSemanticInput.SceneNormals + or RenderSemanticInput.DirectionalShadowMaps); + bool directionalDepth = pass.Semantic == RenderPassSemantic.DirectionalShadowDepth; + var access = new AllowedInterface( + StorageBindings: directionalDepth ? [0u] : [], + UniformBindings: [], + AllowSampledTable: declaresSampledInput, + AllowAtmosphericFrame: directionalDepth || UsesAtmosphericFrame(pass.SemanticInputs), + AllowDirectionalShadow: directionalDepth + || pass.SemanticInputs.Contains(RenderSemanticInput.DirectionalShadowMaps) + || pass.SemanticInputs.Contains( + RenderSemanticInput.SelectedCelestialDirectionalLight), + AllowPackPass: !directionalDepth, + AllowPackSettings: !directionalDepth); + return Validate(spirv, stage, access); + } + + public static RenderPackSpirvValidationResult ValidatePipelineVariantShader( + ReadOnlySpan spirv, + RenderPackShaderStage stage, + PipelineVariantDeclaration variant) + { + ArgumentNullException.ThrowIfNull(variant); + AllowedInterface access = variant.Semantic switch + { + RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster => + new([], [], false, false, true, false, false), + RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster => + new([], [], false, false, true, false, false), + RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster => + new([0u], [], false, false, true, false, false), + RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster => + new([0u], [], false, false, true, false, false), + RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster => + new([0u, 1u], [], true, false, true, false, false), + RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster => + new([0u, 1u], [], true, false, true, false, false), + RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver => + new([], [1u, 2u, 3u], true, false, true, false, true), + RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver => + new([0u, 1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u], [1u], + true, false, true, false, true), + _ => new( + [], + [], + variant.SemanticInputs.Any(static semantic => semantic is + RenderSemanticInput.WorldColor + or RenderSemanticInput.SceneDepth + or RenderSemanticInput.SceneNormals + or RenderSemanticInput.DirectionalShadowMaps), + UsesAtmosphericFrame(variant.SemanticInputs), + variant.SemanticInputs.Contains(RenderSemanticInput.DirectionalShadowMaps) + || variant.SemanticInputs.Contains( + RenderSemanticInput.SelectedCelestialDirectionalLight), + false, + true), + }; + return Validate(spirv, stage, access); + } + + private static bool UsesAtmosphericFrame(IReadOnlyList inputs) => + inputs.Any(static semantic => semantic is + RenderSemanticInput.SunDirection + or RenderSemanticInput.SunScreenPosition + or RenderSemanticInput.ActiveDayGroup + or RenderSemanticInput.Weather + or RenderSemanticInput.CameraMatrices + or RenderSemanticInput.FrameTime); + + private static RenderPackSpirvValidationResult Validate( + ReadOnlySpan bytes, + RenderPackShaderStage stage, + AllowedInterface access) + { + if (bytes.Length < 20 || (bytes.Length & 3) != 0) + return Invalid("the module is not a word-aligned SPIR-V binary"); + uint[] words = new uint[bytes.Length / 4]; + for (int i = 0; i < words.Length; i++) + words[i] = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(i * 4, 4)); + if (words[0] != SpirvMagic) + return Invalid("the module does not have the SPIR-V magic word"); + + Module module; + try + { + module = Module.Parse(words); + } + catch (InvalidDataException error) + { + return Invalid(error.Message); + } + + uint expectedModel = stage == RenderPackShaderStage.Vertex + ? VertexExecutionModel + : FragmentExecutionModel; + if (module.EntryPoints.Count != 1 + || module.EntryPoints[0].ExecutionModel != expectedModel + || !string.Equals(module.EntryPoints[0].Name, "main", StringComparison.Ordinal)) + { + return Invalid( + $"the declared {stage.ToString().ToLowerInvariant()} asset must expose exactly " + + "entry point 'main' for that stage"); + } + + var descriptors = new HashSet<(uint Set, uint Binding)>(); + int pushBlockCount = 0; + foreach (Variable variable in module.Variables) + { + if (variable.StorageClass == StorageClass.PushConstant) + { + if (++pushBlockCount > 1) + return Invalid("the module declares more than one push-constant block"); + string? pushFailure = ValidatePushBlock(module, variable); + if (pushFailure is not null) + return Invalid(pushFailure); + continue; + } + if (variable.StorageClass is not StorageClass.UniformConstant + and not StorageClass.Uniform + and not StorageClass.StorageBuffer) + continue; + + if (!module.DescriptorSets.TryGetValue(variable.Id, out uint set) + || !module.Bindings.TryGetValue(variable.Id, out uint binding)) + return Invalid($"descriptor %{variable.Id} does not declare both set and binding"); + if (!descriptors.Add((set, binding))) + return Invalid($"descriptor set {set} binding {binding} is declared more than once"); + + if (set == RenderPackShaderAbi.SampledTextureDescriptorSet + && binding == RenderPackShaderAbi.SampledTextureBinding) + { + if (!access.AllowSampledTable) + return Invalid("set 2 binding 0 is sampled without a declared semantic/resource input"); + if (!module.IsGlobalSampledTextureTable(variable)) + return Invalid("set 2 binding 0 must be one runtime array of combined 2-D-array samplers"); + continue; + } + + if (set == RenderPackShaderAbi.UniformDescriptorSet) + { + if (variable.StorageClass != StorageClass.Uniform) + return Invalid($"set 3 binding {binding} must be a uniform buffer"); + bool allowed = binding switch + { + RenderPackShaderAbi.AtmosphericFrameBinding => access.AllowAtmosphericFrame, + RenderPackShaderAbi.DirectionalShadowBinding => access.AllowDirectionalShadow, + RenderPackShaderAbi.PackPassBinding => access.AllowPackPass, + RenderPackShaderAbi.PackSettingsBinding => access.AllowPackSettings, + _ => false, + }; + if (!allowed) + return Invalid($"set 3 binding {binding} is not declared for this shader role"); + string? blockFailure = ValidatePackBlock(module, variable, binding); + if (blockFailure is not null) + return Invalid(blockFailure); + continue; + } + + if (set == 0 && variable.StorageClass == StorageClass.StorageBuffer) + { + if (!access.StorageBindings.Contains(binding)) + return Invalid($"set 0 binding {binding} storage access is not allowed for this shader role"); + if (!module.IsReadOnlyStorage(variable)) + return Invalid($"set 0 binding {binding} is writable; render-pack storage writes are forbidden"); + if (!module.IsSingleBlockDescriptor(variable)) + return Invalid($"set 0 binding {binding} must be one storage-buffer descriptor"); + continue; + } + + if (set == 1 && variable.StorageClass == StorageClass.Uniform) + { + if (!access.UniformBindings.Contains(binding)) + return Invalid($"set 1 binding {binding} aliases renderer state not allowed for this shader role"); + if (!module.IsSingleBlockDescriptor(variable)) + return Invalid($"set 1 binding {binding} must be one uniform-buffer descriptor"); + continue; + } + + return Invalid( + $"descriptor set {set} binding {binding} has no render-pack API v1 binding"); + } + + if (module.ContainsImageWrite) + return Invalid("storage image writes are forbidden by render-pack API v1"); + return RenderPackSpirvValidationResult.Valid(); + } + + private static string? ValidatePackBlock(Module module, Variable variable, uint binding) + { + if (!module.TryPointeeStruct(variable, out uint structId, out uint[] members) + || !module.Blocks.Contains(structId)) + return $"set 3 binding {binding} must point to one std140 Block struct"; + + return binding switch + { + RenderPackShaderAbi.AtmosphericFrameBinding => + ValidateAtmosphericFrame(module, structId, members), + RenderPackShaderAbi.DirectionalShadowBinding => + ValidateDirectionalShadow(module, structId, members), + RenderPackShaderAbi.PackPassBinding => + ValidateVec4Block(module, structId, members, 4, "PackPass"), + RenderPackShaderAbi.PackSettingsBinding => + ValidatePackSettings(module, structId, members), + _ => $"set 3 binding {binding} is reserved", + }; + } + + private static string? ValidateAtmosphericFrame(Module module, uint id, uint[] members) + { + if (members.Length != 7) + return "AtmosphericFrame must contain exactly seven members and occupy 160 bytes"; + for (int i = 0; i < 6; i++) + { + if (!module.IsFloatVector(members[i], 4) || module.MemberOffset(id, i) != (uint)(i * 16)) + return "AtmosphericFrame member types/offsets do not match ABI v1"; + } + if (!module.IsFloatMatrix(members[6], 4, 4) + || module.MemberOffset(id, 6) != 96 + || module.MemberDecoration(id, 6, Decoration.ColMajor) is null + || module.MemberDecoration(id, 6, Decoration.MatrixStride) != 16) + return "AtmosphericFrame inverse-view-projection layout does not match ABI v1"; + return null; + } + + private static string? ValidateDirectionalShadow(Module module, uint id, uint[] members) + { + if (members.Length != 6 + || !module.IsArray(members[0], 4, 64, static (m, t) => m.IsFloatMatrix(t, 4, 4)) + || module.MemberOffset(id, 0) != 0 + || module.MemberDecoration(id, 0, Decoration.ColMajor) is null + || module.MemberDecoration(id, 0, Decoration.MatrixStride) != 16) + return "DirectionalShadow matrix array does not match the 336-byte ABI v1 layout"; + for (int i = 1; i <= 3; i++) + { + if (!module.IsFloatVector(members[i], 4) + || module.MemberOffset(id, i) != (uint)(240 + i * 16)) + return "DirectionalShadow vec4 member types/offsets do not match ABI v1"; + } + if (!module.IsUIntVector(members[4], 4) || module.MemberOffset(id, 4) != 304) + return "DirectionalShadow flags member does not match ABI v1"; + if (!module.IsFloatVector(members[5], 4) || module.MemberOffset(id, 5) != 320) + { + return "DirectionalShadow selected-light direction/source member does not " + + "match the 336-byte ABI v1 layout"; + } + return null; + } + + private static string? ValidateVec4Block( + Module module, + uint id, + uint[] members, + int count, + string name) + { + if (members.Length != count) + return $"{name} must contain {count} vec4 members"; + for (int i = 0; i < count; i++) + { + if (!module.IsFloatVector(members[i], 4) || module.MemberOffset(id, i) != (uint)(i * 16)) + return $"{name} member types/offsets do not match ABI v1"; + } + return null; + } + + private static string? ValidatePackSettings(Module module, uint id, uint[] members) + { + if (members.Length != 1 + || module.MemberOffset(id, 0) != 0 + || !module.IsArray(members[0], 16, 16, static (m, t) => m.IsFloatVector(t, 4))) + return "PackSettings must be one std140 vec4[16] block occupying 256 bytes"; + return null; + } + + private static string? ValidatePushBlock(Module module, Variable variable) + { + if (!module.TryPointeeStruct(variable, out uint id, out uint[] members) + || !module.Blocks.Contains(id) + || members.Length != 9) + return "the push-constant block must match the exact 96-byte retail layout"; + uint[] offsets = [0, 64, 68, 72, 76, 80, 84, 88, 92]; + for (int i = 0; i < offsets.Length; i++) + { + if (module.MemberOffset(id, i) != offsets[i]) + return "the push-constant member offsets do not match the exact 96-byte retail layout"; + } + if (!module.IsFloatMatrix(members[0], 4, 4) + || module.MemberDecoration(id, 0, Decoration.ColMajor) is null + || module.MemberDecoration(id, 0, Decoration.MatrixStride) != 16 + || !module.IsInt(members[1], signed: true) + || !module.IsInt(members[2], signed: true) + || !module.IsInt(members[3], signed: true) + || !module.IsInt(members[4], signed: true) + || !module.IsInt(members[5], signed: false) + || !module.IsInt(members[6], signed: false) + || !module.IsFloat(members[7]) + || !module.IsFloat(members[8])) + return "the push-constant member types do not match the exact 96-byte retail layout"; + return null; + } + + private static RenderPackSpirvValidationResult Invalid(string reason) => + RenderPackSpirvValidationResult.Invalid(reason); + + private sealed record AllowedInterface( + IReadOnlyList StorageBindings, + IReadOnlyList UniformBindings, + bool AllowSampledTable, + bool AllowAtmosphericFrame, + bool AllowDirectionalShadow, + bool AllowPackPass, + bool AllowPackSettings); + + private enum StorageClass : uint + { + UniformConstant = 0, + Uniform = 2, + PushConstant = 9, + StorageBuffer = 12, + } + + private enum Decoration : uint + { + Block = 2, + ColMajor = 5, + ArrayStride = 6, + MatrixStride = 7, + NonWritable = 24, + Binding = 33, + DescriptorSet = 34, + Offset = 35, + } + + private readonly record struct EntryPoint(uint ExecutionModel, string Name); + private readonly record struct Variable(uint ResultType, uint Id, StorageClass StorageClass); + private sealed record TypeInstruction(uint Opcode, uint[] Operands); + + private sealed class Module + { + private const uint OpEntryPoint = 15; + private const uint OpTypeInt = 21; + private const uint OpTypeFloat = 22; + private const uint OpTypeVector = 23; + private const uint OpTypeMatrix = 24; + private const uint OpTypeImage = 25; + private const uint OpTypeSampledImage = 27; + private const uint OpTypeArray = 28; + private const uint OpTypeRuntimeArray = 29; + private const uint OpTypeStruct = 30; + private const uint OpTypePointer = 32; + private const uint OpConstant = 43; + private const uint OpVariable = 59; + private const uint OpDecorate = 71; + private const uint OpMemberDecorate = 72; + private const uint OpImageWrite = 99; + + internal List EntryPoints { get; } = []; + internal List Variables { get; } = []; + internal Dictionary DescriptorSets { get; } = []; + internal Dictionary Bindings { get; } = []; + internal HashSet NonWritable { get; } = []; + internal HashSet Blocks { get; } = []; + internal bool ContainsImageWrite { get; private set; } + + private Dictionary Types { get; } = []; + private Dictionary Constants { get; } = []; + private Dictionary<(uint Id, Decoration Decoration), uint> Decorations { get; } = []; + private Dictionary<(uint Id, int Member, Decoration Decoration), uint> MemberDecorations { get; } = []; + + internal static Module Parse(uint[] words) + { + var module = new Module(); + int index = 5; + while (index < words.Length) + { + uint header = words[index]; + int count = (int)(header >> 16); + uint opcode = header & 0xffff; + if (count <= 0 || index + count > words.Length) + throw new InvalidDataException($"malformed SPIR-V instruction at word {index}"); + ReadOnlySpan instruction = words.AsSpan(index, count); + module.ReadInstruction(opcode, instruction); + index += count; + } + return module; + } + + private void ReadInstruction(uint opcode, ReadOnlySpan words) + { + if (opcode == OpEntryPoint) + { + if (words.Length < 4) + throw new InvalidDataException("malformed SPIR-V OpEntryPoint"); + EntryPoints.Add(new EntryPoint(words[1], ReadString(words[3..]))); + } + else if (opcode is >= OpTypeInt and <= OpTypePointer) + { + if (words.Length < 2) + throw new InvalidDataException("malformed SPIR-V type instruction"); + Types[words[1]] = new TypeInstruction(opcode, words[1..].ToArray()); + } + else if (opcode == OpConstant && words.Length >= 4) + { + Constants[words[2]] = words[3]; + } + else if (opcode == OpVariable) + { + if (words.Length < 4) + throw new InvalidDataException("malformed SPIR-V OpVariable"); + Variables.Add(new Variable(words[1], words[2], (StorageClass)words[3])); + } + else if (opcode == OpDecorate) + { + if (words.Length < 3) + throw new InvalidDataException("malformed SPIR-V OpDecorate"); + Decoration decoration = (Decoration)words[2]; + uint value = words.Length >= 4 ? words[3] : 1; + Decorations[(words[1], decoration)] = value; + if (decoration == Decoration.DescriptorSet) DescriptorSets[words[1]] = value; + if (decoration == Decoration.Binding) Bindings[words[1]] = value; + if (decoration == Decoration.NonWritable) NonWritable.Add(words[1]); + if (decoration == Decoration.Block) Blocks.Add(words[1]); + } + else if (opcode == OpMemberDecorate) + { + if (words.Length < 4) + throw new InvalidDataException("malformed SPIR-V OpMemberDecorate"); + Decoration decoration = (Decoration)words[3]; + MemberDecorations[(words[1], checked((int)words[2]), decoration)] = + words.Length >= 5 ? words[4] : 1; + } + else if (opcode == OpImageWrite) + { + ContainsImageWrite = true; + } + } + + private static string ReadString(ReadOnlySpan words) + { + var bytes = new List(words.Length * 4); + foreach (uint word in words) + { + for (int shift = 0; shift < 32; shift += 8) + { + byte value = (byte)(word >> shift); + if (value == 0) + return System.Text.Encoding.UTF8.GetString([.. bytes]); + bytes.Add(value); + } + } + throw new InvalidDataException("unterminated SPIR-V string"); + } + + internal bool IsSingleBlockDescriptor(Variable variable) => + TryPointeeStruct(variable, out uint id, out _) && Blocks.Contains(id); + + internal bool IsReadOnlyStorage(Variable variable) + { + if (NonWritable.Contains(variable.Id)) + return true; + return TryPointeeStruct(variable, out uint id, out uint[] members) + && members.Length != 0 + && Enumerable.Range(0, members.Length).All(member => + MemberDecoration(id, member, Decoration.NonWritable) is not null); + } + + internal bool TryPointeeStruct(Variable variable, out uint id, out uint[] members) + { + id = 0; + members = []; + if (!Types.TryGetValue(variable.ResultType, out TypeInstruction? pointer) + || pointer.Opcode != OpTypePointer + || pointer.Operands.Length < 3) + return false; + id = pointer.Operands[2]; + if (!Types.TryGetValue(id, out TypeInstruction? structure) + || structure.Opcode != OpTypeStruct) + return false; + members = structure.Operands[1..]; + return true; + } + + internal bool IsGlobalSampledTextureTable(Variable variable) + { + if (!TryPointee(variable.ResultType, out uint arrayId) + || !Types.TryGetValue(arrayId, out TypeInstruction? array) + || array.Opcode != OpTypeRuntimeArray + || array.Operands.Length != 2 + || !Types.TryGetValue(array.Operands[1], out TypeInstruction? sampled) + || sampled.Opcode != OpTypeSampledImage + || sampled.Operands.Length != 2 + || !Types.TryGetValue(sampled.Operands[1], out TypeInstruction? image) + || image.Opcode != OpTypeImage + || image.Operands.Length < 8) + return false; + // Dim=2D (1), Arrayed=true, Sampled=image used with a sampler (1). + return image.Operands[2] == 1 && image.Operands[4] == 1 && image.Operands[6] == 1; + } + + private bool TryPointee(uint pointerId, out uint pointee) + { + pointee = 0; + if (!Types.TryGetValue(pointerId, out TypeInstruction? pointer) + || pointer.Opcode != OpTypePointer + || pointer.Operands.Length < 3) + return false; + pointee = pointer.Operands[2]; + return true; + } + + internal uint? MemberOffset(uint id, int member) => + MemberDecoration(id, member, Decoration.Offset); + + internal uint? MemberDecoration(uint id, int member, Decoration decoration) => + MemberDecorations.TryGetValue((id, member, decoration), out uint value) ? value : null; + + internal bool IsFloat(uint id) => IsScalar(id, OpTypeFloat, 32, signed: null); + internal bool IsInt(uint id, bool signed) => IsScalar(id, OpTypeInt, 32, signed); + + private bool IsScalar(uint id, uint opcode, uint width, bool? signed) + { + if (!Types.TryGetValue(id, out TypeInstruction? type) + || type.Opcode != opcode + || type.Operands.Length < 2 + || type.Operands[1] != width) + return false; + return signed is null + || type.Operands.Length >= 3 && type.Operands[2] == (signed.Value ? 1u : 0u); + } + + internal bool IsFloatVector(uint id, uint count) => + IsVector(id, count, static (m, t) => m.IsFloat(t)); + + internal bool IsUIntVector(uint id, uint count) => + IsVector(id, count, static (m, t) => m.IsInt(t, signed: false)); + + private bool IsVector(uint id, uint count, Func element) + { + return Types.TryGetValue(id, out TypeInstruction? vector) + && vector.Opcode == OpTypeVector + && vector.Operands.Length == 3 + && vector.Operands[2] == count + && element(this, vector.Operands[1]); + } + + internal bool IsFloatMatrix(uint id, uint rows, uint columns) + { + return Types.TryGetValue(id, out TypeInstruction? matrix) + && matrix.Opcode == OpTypeMatrix + && matrix.Operands.Length == 3 + && matrix.Operands[2] == columns + && IsFloatVector(matrix.Operands[1], rows); + } + + internal bool IsArray( + uint id, + uint length, + uint stride, + Func element) + { + return Types.TryGetValue(id, out TypeInstruction? array) + && array.Opcode == OpTypeArray + && array.Operands.Length == 3 + && Constants.TryGetValue(array.Operands[2], out uint actualLength) + && actualLength == length + && Decorations.TryGetValue((id, Decoration.ArrayStride), out uint actualStride) + && actualStride == stride + && element(this, array.Operands[1]); + } + } +} diff --git a/src/AcDream.Plugins.MossTank/MossTankPanel.cs b/src/AcDream.Plugins.MossTank/MossTankPanel.cs index 30b39259..db09934e 100644 --- a/src/AcDream.Plugins.MossTank/MossTankPanel.cs +++ b/src/AcDream.Plugins.MossTank/MossTankPanel.cs @@ -20,6 +20,13 @@ internal sealed class MossTankPanel /// private const double StallTimeoutSeconds = 30.0; + /// + /// The retained markup host reads label bindings while drawing. Coverage + /// walks the complete known-buff table, so it belongs on the update side + /// and only needs to refresh at human-readable cadence. + /// + private const double CoverageRefreshIntervalSeconds = 1.0; + private readonly IPluginHost _host; private readonly BuffSettings _buffSettings = new(); private readonly VitalSettings _vitalSettings = new(); @@ -34,6 +41,18 @@ internal sealed class MossTankPanel private double _sinceProgress; private int _castThisPass; private string _status = "Idle."; + private string _vitals = string.Empty; + private string _coverage = string.Empty; + private bool _vitalsInitialized; + private uint _currentHealth; + private uint _maxHealth; + private uint _currentStamina; + private uint _maxStamina; + private uint _currentMana; + private uint _maxMana; + private IReadOnlyList? _coverageSpellSnapshot; + private int _coverageBuffLineCount; + private double _coverageRefreshRemaining; /// /// What the player had selected before the pass, so targeting yourself for @@ -59,42 +78,10 @@ internal sealed class MossTankPanel public string Status => _status; /// Vitals line, using the same numbers the character panel shows. - public string Vitals - { - get - { - ICharacterInfo character = _host.Automation.Character; - if (!_host.Automation.IsAvailable) - return string.Empty; - return $"Health {character.CurrentHealth}/{character.MaxHealth}" - + $" Stam {character.CurrentStamina}/{character.MaxStamina}" - + $" Mana {character.CurrentMana}/{character.MaxMana}"; - } - } + public string Vitals => _vitals; /// What a buff pass would cover, named from the retail tables. - public string Coverage - { - get - { - IAutomationSurface automation = _host.Automation; - if (!automation.IsAvailable) - return string.Empty; - - int trained = 0; - foreach (PluginSkillInfo skill in automation.Character.Skills) - { - if (skill.Training is PluginSkillTraining.Trained - or PluginSkillTraining.Specialized) - { - trained++; - } - } - return $"{automation.Character.Attributes.Count} attributes, " - + $"{trained} trained skills, " - + $"{BuffProfile.Build(automation.Spells.KnownSelfBuffs).Count} buff lines"; - } - } + public string Coverage => _coverage; // ── settings bindings ───────────────────────────────────────────────── // Adjuster buttons rather than typed entry: buttons are a proven primitive @@ -251,6 +238,8 @@ internal sealed class MossTankPanel /// Driven by on the host update thread. public void OnTick(double elapsedSeconds) { + RefreshDisplayBindings(elapsedSeconds); + if (!_running) return; @@ -295,6 +284,74 @@ internal sealed class MossTankPanel _queueIndex++; } + private void RefreshDisplayBindings(double elapsedSeconds) + { + IAutomationSurface automation = _host.Automation; + if (!automation.IsAvailable) + { + _vitals = string.Empty; + _coverage = string.Empty; + _vitalsInitialized = false; + _coverageSpellSnapshot = null; + _coverageBuffLineCount = 0; + _coverageRefreshRemaining = 0.0; + return; + } + + ICharacterInfo character = automation.Character; + uint currentHealth = character.CurrentHealth; + uint maxHealth = character.MaxHealth; + uint currentStamina = character.CurrentStamina; + uint maxStamina = character.MaxStamina; + uint currentMana = character.CurrentMana; + uint maxMana = character.MaxMana; + if (!_vitalsInitialized + || currentHealth != _currentHealth + || maxHealth != _maxHealth + || currentStamina != _currentStamina + || maxStamina != _maxStamina + || currentMana != _currentMana + || maxMana != _maxMana) + { + _currentHealth = currentHealth; + _maxHealth = maxHealth; + _currentStamina = currentStamina; + _maxStamina = maxStamina; + _currentMana = currentMana; + _maxMana = maxMana; + _vitals = $"Health {currentHealth}/{maxHealth}" + + $" Stam {currentStamina}/{maxStamina}" + + $" Mana {currentMana}/{maxMana}"; + _vitalsInitialized = true; + } + + IReadOnlyList spells = automation.Spells.KnownSelfBuffs; + bool spellbookChanged = !ReferenceEquals(spells, _coverageSpellSnapshot); + _coverageRefreshRemaining -= Math.Max(0.0, elapsedSeconds); + if (!spellbookChanged && _coverageRefreshRemaining > 0.0) + return; + + if (spellbookChanged) + { + _coverageSpellSnapshot = spells; + _coverageBuffLineCount = BuffProfile.Build(spells).Count; + } + + int trained = 0; + foreach (PluginSkillInfo skill in character.Skills) + { + if (skill.Training is PluginSkillTraining.Trained + or PluginSkillTraining.Specialized) + { + trained++; + } + } + _coverage = $"{character.Attributes.Count} attributes, " + + $"{trained} trained skills, " + + $"{_coverageBuffLineCount} buff lines"; + _coverageRefreshRemaining = CoverageRefreshIntervalSeconds; + } + private bool TryVitalUpkeep(IAutomationSurface automation) { VitalAction action = VitalPlan.Decide(automation.Character, _vitalSettings); diff --git a/src/AcDream.Plugins.MossTank/packages.linux-x64.lock.json b/src/AcDream.Plugins.MossTank/packages.linux-x64.lock.json new file mode 100644 index 00000000..fe625889 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/packages.linux-x64.lock.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "acdream.plugin.abstractions": { + "type": "Project" + } + }, + "net10.0/linux-x64": {} + } +} \ No newline at end of file diff --git a/src/AcDream.Plugins.MossTank/packages.win-x64.lock.json b/src/AcDream.Plugins.MossTank/packages.win-x64.lock.json new file mode 100644 index 00000000..b12a77e4 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/packages.win-x64.lock.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "acdream.plugin.abstractions": { + "type": "Project" + } + }, + "net10.0/win-x64": {} + } +} \ No newline at end of file diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs index 93a27cad..33e48982 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs @@ -14,6 +14,117 @@ public enum ParticleRange Extended = 1, } +/// +/// Immutable, value-equal user overrides for one selected render pack. Keys +/// are stable setting IDs and compare case-insensitively; values remain the +/// declaration's invariant string representation until descriptor validation. +/// +public sealed class RenderPackSettingOverrides : + IReadOnlyDictionary, + IEquatable +{ + private readonly SortedDictionary _values; + + public static RenderPackSettingOverrides Empty { get; } = new([]); + + public RenderPackSettingOverrides( + IEnumerable> values) + { + ArgumentNullException.ThrowIfNull(values); + _values = new SortedDictionary(StringComparer.OrdinalIgnoreCase); + foreach ((string key, string value) in values) + { + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(value); + _values[key] = value; + } + } + + public int Count => _values.Count; + + public IEnumerable Keys => _values.Keys; + + public IEnumerable Values => _values.Values; + + public string this[string key] => _values[key]; + + public bool ContainsKey(string key) => _values.ContainsKey(key); + + public bool TryGetValue(string key, out string value) => + _values.TryGetValue(key, out value!); + + public IEnumerator> GetEnumerator() => + _values.GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => + GetEnumerator(); + + public RenderPackSettingOverrides Set(string settingId, string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(settingId); + ArgumentNullException.ThrowIfNull(value); + var next = new SortedDictionary( + _values, + StringComparer.OrdinalIgnoreCase) + { + [settingId] = value, + }; + return new RenderPackSettingOverrides(next); + } + + public bool Equals(RenderPackSettingOverrides? other) => + other is not null + && _values.Count == other._values.Count + && _values.All(pair => other._values.TryGetValue(pair.Key, out string? value) + && string.Equals(pair.Value, value, StringComparison.Ordinal)); + + public override bool Equals(object? obj) => + obj is RenderPackSettingOverrides other && Equals(other); + + public override int GetHashCode() + { + var hash = new HashCode(); + foreach ((string key, string value) in _values) + { + hash.Add(key, StringComparer.OrdinalIgnoreCase); + hash.Add(value, StringComparer.Ordinal); + } + return hash.ToHashCode(); + } +} + +/// +/// Stable, user-authored selection of one optional render pack. Logical ids +/// are persisted instead of menu indexes so discovery order can never select a +/// different pack or preset after an install/update. The renderer normalizes a +/// missing, malformed, unavailable, or incompatible selection back to +/// and retains the precise reason for diagnostics. +/// +public sealed record RenderPackSelectionSettings( + string PackId, + string? PackVersion, + string PresetId) +{ + public const string RetailPackId = "retail"; + public const string RetailPresetId = "off"; + + public static RenderPackSelectionSettings Retail { get; } = new( + RetailPackId, + PackVersion: null, + RetailPresetId); + + /// + /// User-authored values keyed by the selected pack's stable setting IDs. + /// Empty by default so pre-render-pack settings files upgrade without a + /// migration write. + /// + public RenderPackSettingOverrides SettingOverrides { get; init; } = + RenderPackSettingOverrides.Empty; + + public bool IsRetail => + string.Equals(PackId, RetailPackId, StringComparison.OrdinalIgnoreCase); +} + /// /// Display-related preferences persisted to settings.json. /// Originally documented as "no retail equivalent for FOV / vsync etc" — @@ -77,6 +188,16 @@ public sealed record DisplaySettings( bool BuildingDetailTextures = true, bool MultiPassAlpha = false) { + /// + /// Opt-in graphics enhancement selection. This is deliberately separate + /// from , which remains the authoritative retail + /// renderer/streaming quality preset. Keeping the default here means an + /// upgraded settings file that predates shader packs deserializes to the + /// exact retail path without a migration write. + /// + public RenderPackSelectionSettings RenderPack { get; init; } = + RenderPackSelectionSettings.Retail; + /// Values used on first launch / when settings.json is absent. /// Geometry defaults preserve the pre-L.0 runtime state: Resolution /// matches the WindowOptions startup size (1280×720). FieldOfView is diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs index acaa247d..a2cdebd7 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -97,7 +97,10 @@ public sealed class SettingsStore TextureFiltering: ReadInt (disp, "textureFiltering", d.TextureFiltering), LandscapeDrawDistance: ReadInt (disp, "landscapeDrawDistance", d.LandscapeDrawDistance), BuildingDetailTextures: ReadBool (disp, "buildingDetailTextures", d.BuildingDetailTextures), - MultiPassAlpha: ReadBool (disp, "multiPassAlpha", d.MultiPassAlpha)); + MultiPassAlpha: ReadBool (disp, "multiPassAlpha", d.MultiPassAlpha)) + { + RenderPack = ReadRenderPackSelection(disp, d.RenderPack), + }; } catch (Exception ex) { @@ -686,6 +689,7 @@ public sealed class SettingsStore ["multiPassAlpha"] = d.MultiPassAlpha, ["particleRange"] = d.ParticleRange.ToString(), ["quality"] = d.Quality.ToString(), + ["renderPack"] = BuildRenderPackObject(d.RenderPack), ["resolution"] = d.Resolution, ["screenBrightness"] = d.ScreenBrightness, ["showFps"] = d.ShowFps, @@ -693,6 +697,21 @@ public sealed class SettingsStore ["vsync"] = d.VSync, }; + private static SortedDictionary BuildRenderPackObject( + RenderPackSelectionSettings selection) + { + var overrides = new SortedDictionary(StringComparer.Ordinal); + foreach ((string key, string value) in selection.SettingOverrides) + overrides[key] = value; + return new SortedDictionary(StringComparer.Ordinal) + { + ["packId"] = selection.PackId, + ["packVersion"] = selection.PackVersion, + ["presetId"] = selection.PresetId, + ["settingOverrides"] = overrides, + }; + } + private static SortedDictionary BuildMiscObject(MiscSettings m) => new(StringComparer.Ordinal) { @@ -777,6 +796,49 @@ public sealed class SettingsStore File.WriteAllText(_path, sb.ToString()); } + private static RenderPackSelectionSettings ReadRenderPackSelection( + JsonElement display, + RenderPackSelectionSettings fallback) + { + if (!display.TryGetProperty("renderPack", out JsonElement value) + || value.ValueKind != JsonValueKind.Object) + { + return fallback; + } + + string packId = ReadString(value, "packId", string.Empty); + string presetId = ReadString(value, "presetId", string.Empty); + if (string.IsNullOrWhiteSpace(packId) || string.IsNullOrWhiteSpace(presetId)) + return fallback; + string? version = null; + if (value.TryGetProperty("packVersion", out JsonElement versionElement) + && versionElement.ValueKind == JsonValueKind.String) + { + version = versionElement.GetString(); + } + + var overrides = new List>(); + if (value.TryGetProperty("settingOverrides", out JsonElement overrideElement)) + { + if (overrideElement.ValueKind != JsonValueKind.Object) + return fallback; + foreach (JsonProperty property in overrideElement.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.String + || property.Value.GetString() is not { } settingValue) + { + return fallback; + } + overrides.Add(new KeyValuePair(property.Name, settingValue)); + } + } + + return new RenderPackSelectionSettings(packId, version, presetId) + { + SettingOverrides = new RenderPackSettingOverrides(overrides), + }; + } + private static string ReadString(JsonElement obj, string name, string fallback) => obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String ? (el.GetString() ?? fallback) : fallback; diff --git a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj index 2a7a6d82..e31fed1b 100644 --- a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj +++ b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj @@ -32,6 +32,14 @@ false true + + false + true + + + false + true + diff --git a/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs b/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs index ea1fb2a9..6bd44921 100644 --- a/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs @@ -35,6 +35,32 @@ public sealed class HostInputCameraCompositionTests Assert.Equal((1280, 720), fixture.Factory.Viewport.Size); } + [Fact] + public void DiagnosticOrbitOverridesReachOnlyTheInitialOrbitCamera() + { + using var fixture = new Fixture(); + + HostInputCameraResult result = fixture + .Phase(180f, -135f, 7.5f) + .Compose(fixture.Platform); + + Assert.Equal(180f, result.CameraController.Orbit.Distance); + Assert.Equal(-135f * MathF.PI / 180f, result.CameraController.Orbit.Yaw); + Assert.Equal(7.5f * MathF.PI / 180f, result.CameraController.Orbit.Pitch); + } + + [Fact] + public void VulkanFactoryConvertsDiagnosticOrbitAnglesFromDegrees() + { + var factory = new VulkanHostInputCameraCompositionFactory(); + + CameraController camera = factory.CreateCameraController(200f, 180f, -12f); + + Assert.Equal(200f, camera.Orbit.Distance); + Assert.Equal(MathF.PI, camera.Orbit.Yaw, precision: 6); + Assert.Equal(-12f * MathF.PI / 180f, camera.Orbit.Pitch, precision: 6); + } + [Theory] [MemberData(nameof(FaultPointValues))] public void FailureAtEveryProductionBoundaryStopsTheExactSuffix( @@ -138,7 +164,10 @@ public sealed class HostInputCameraCompositionTests public Factory Factory { get; } public Publication Publication { get; } - public HostInputCameraCompositionPhase Phase() => new( + public HostInputCameraCompositionPhase Phase( + float? initialOrbitDistanceMeters = null, + float? initialOrbitYawDegrees = null, + float? initialOrbitPitchDegrees = null) => new( new HostInputCameraDependencies( Framebuffer, new Vector2D(1280, 720), @@ -150,7 +179,10 @@ public sealed class HostInputCameraCompositionTests PlayerMode, Chase, Pointer, - new DiagnosticLog()), + new DiagnosticLog(), + initialOrbitDistanceMeters, + initialOrbitYawDegrees, + initialOrbitPitchDegrees), Publication, Factory, point => @@ -318,8 +350,20 @@ public sealed class HostInputCameraCompositionTests KeyBindings bindings) => InputDispatcher.CreateDetached(keyboard, mouse, bindings); - public CameraController CreateCameraController() => - new(new OrbitCamera(), new FlyCamera()); + public CameraController CreateCameraController( + float? initialOrbitDistanceMeters, + float? initialOrbitYawDegrees, + float? initialOrbitPitchDegrees) + { + var orbit = new OrbitCamera(); + if (initialOrbitDistanceMeters is { } distance) + orbit.Distance = distance; + if (initialOrbitYawDegrees is { } yaw) + orbit.Yaw = yaw * (MathF.PI / 180f); + if (initialOrbitPitchDegrees is { } pitch) + orbit.Pitch = pitch * (MathF.PI / 180f); + return new CameraController(orbit, new FlyCamera()); + } public IFramebufferCameraTarget CreateCameraTarget(CameraController camera) => new CameraTarget(camera); diff --git a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs index 8017787d..fe53d729 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs @@ -181,9 +181,25 @@ public sealed class InteractionUiRuntimeSourcesTests Assert.True(source.IsWorldReady); Assert.True(source.TryRequestCheckpoint("ready", out _, out _)); Assert.Equal("ready", target.LastCheckpoint); + Assert.Equal(target.RenderPackStatus, source.RenderPackStatus); + Assert.Equal(1280, source.FramebufferWidth); + Assert.Equal(720, source.FramebufferHeight); + Assert.True(source.TrySelectRenderPack("high", out _)); + Assert.Equal("high", target.LastRenderPackPreset); + Assert.True(source.TryDisableRenderPack(out _)); + Assert.True(target.RenderPackDisabled); + Assert.True(source.TryReenableRenderPack(out _)); + Assert.True(target.RenderPackReenabled); + Assert.True(source.TryResizeFramebuffer(1024, 768, out _)); + Assert.Equal((1024, 768), target.LastFramebufferSize); binding.Dispose(); Assert.False(source.IsWorldReady); + Assert.Equal( + AcDream.App.UI.Testing.RetailUiAutomationRenderPackStatus.Retail, + source.RenderPackStatus); + Assert.False(source.TrySelectRenderPack("high", out string unboundError)); + Assert.Contains("not bound", unboundError); source.Deactivate(); Assert.Throws(() => source.Bind(target)); } @@ -454,7 +470,48 @@ public sealed class InteractionUiRuntimeSourcesTests public bool IsWorldReady => true; public bool IsWorldViewportVisible => true; public int PortalMaterializationCount => 2; + public AcDream.App.UI.Testing.RetailUiAutomationRenderPackStatus + RenderPackStatus { get; } = new( + AcDream.App.UI.Testing.RetailUiAutomationRenderPackState.Active, + "acdream.atmospheric", + "high", + 7, + null); + public int FramebufferWidth => 1280; + public int FramebufferHeight => 720; public string? LastCheckpoint { get; private set; } + public string? LastRenderPackPreset { get; private set; } + public bool RenderPackDisabled { get; private set; } + public bool RenderPackReenabled { get; private set; } + public (int Width, int Height)? LastFramebufferSize { get; private set; } + + public bool TrySelectRenderPack(string presetId, out string error) + { + LastRenderPackPreset = presetId; + error = string.Empty; + return true; + } + + public bool TryDisableRenderPack(out string error) + { + RenderPackDisabled = true; + error = string.Empty; + return true; + } + + public bool TryReenableRenderPack(out string error) + { + RenderPackReenabled = true; + error = string.Empty; + return true; + } + + public bool TryResizeFramebuffer(int width, int height, out string error) + { + LastFramebufferSize = (width, height); + error = string.Empty; + return true; + } public bool TryRequestCheckpoint( string name, diff --git a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs index 364b218a..9c4316e9 100644 --- a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs @@ -223,7 +223,8 @@ public sealed class WorldRenderCompositionTests public void InitializeEnvironment( WorldEnvironmentController environment, - Region region) { } + Region region, + IDatReaderWriter dats) { } /// /// Campaign V slice V6i-2: the arm a backend with no GL context takes. diff --git a/tests/AcDream.App.Tests/Diagnostics/AtmosphericPerformanceMatrixContractTests.cs b/tests/AcDream.App.Tests/Diagnostics/AtmosphericPerformanceMatrixContractTests.cs new file mode 100644 index 00000000..1b6afd0c --- /dev/null +++ b/tests/AcDream.App.Tests/Diagnostics/AtmosphericPerformanceMatrixContractTests.cs @@ -0,0 +1,567 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace AcDream.App.Tests.Diagnostics; + +public sealed class AtmosphericPerformanceMatrixContractTests +{ + [Fact] + public void ScriptsParseWithoutLaunchingTheMatrix() + { + foreach (string script in new[] + { + ScriptPath(), + Path.Combine(FindRepoRoot(), "tools", "run-offline-pixel-gate.ps1"), + Path.Combine(FindRepoRoot(), "tools", "atmospheric-performance-matrix-common.ps1"), + }) + AssertPowerShellParses(script); + } + + private static void AssertPowerShellParses(string script) + { + var start = new ProcessStartInfo + { + FileName = OperatingSystem.IsWindows() ? "pwsh.exe" : "pwsh", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + start.ArgumentList.Add("-NoProfile"); + start.ArgumentList.Add("-NonInteractive"); + start.ArgumentList.Add("-Command"); + string quotedScript = "'" + script.Replace("'", "''", StringComparison.Ordinal) + "'"; + start.ArgumentList.Add( + $"[scriptblock]::Create((Get-Content -Raw -LiteralPath {quotedScript})) | Out-Null"); + + using Process process = Process.Start(start) + ?? throw new InvalidOperationException("Could not start pwsh parser process."); + string stdout = process.StandardOutput.ReadToEnd(); + string stderr = process.StandardError.ReadToEnd(); + Assert.True(process.WaitForExit(30_000), "PowerShell parser did not exit."); + Assert.True( + process.ExitCode == 0, + $"PowerShell parser failed for {script} with exit code {process.ExitCode}.\n{stdout}\n{stderr}"); + } + + [Fact] + public void MatrixPinsAllRequiredRowsAndExplicitFramePacingModes() + { + string source = ReadScript(); + + Assert.Contains("[string]$FramePacing = 'both'", source, StringComparison.Ordinal); + Assert.Contains( + "[ValidateSet('capped', 'uncapped', 'both')]", + source, + StringComparison.Ordinal); + Assert.Contains( + "[string[]]$PresetSet = @('retail', 'low', 'medium', 'high', 'auto')", + source, + StringComparison.Ordinal); + Assert.Contains( + "[string[]]$ResolutionSet = @('1920x1080', '2560x1440', '3840x2160')", + source, + StringComparison.Ordinal); + Assert.Contains( + "$presets = @($PresetSet | ForEach-Object { $_.ToLowerInvariant() } | Select-Object -Unique)", + source, + StringComparison.Ordinal); + Assert.Contains( + "$resolutions = @($ResolutionSet | Select-Object -Unique)", + source, + StringComparison.Ordinal); + Assert.Contains("default { @('capped', 'uncapped') }", source, StringComparison.Ordinal); + Assert.Contains("if ($pacing -eq 'uncapped')", source, StringComparison.Ordinal); + Assert.Contains("$arguments += '-Uncapped'", source, StringComparison.Ordinal); + Assert.Contains( + "'-RequiredRenderPackSamples', '2048'", + source, + StringComparison.Ordinal); + Assert.Contains( + "'-RenderPackSampleTimeoutMs', '300000'", + source, + StringComparison.Ordinal); + Assert.Contains( + "ExplicitPresetPerformanceWindowResetAfterWarmup = $true", + source, + StringComparison.Ordinal); + Assert.Contains( + "AutomaticPerformanceWindowPolicy", + source, + StringComparison.Ordinal); + Assert.Contains("'-AllowSafeRenderPackFallback'", source, StringComparison.Ordinal); + + AssertAppearsInOrder( + source, + "$rowDirectory = Assert-MatrixContainedPath $outputRoot (Join-Path $outputRoot $rowId)", + "'-Out', $rowDirectory", + "'-WarmupMs', \"$WarmupMs\"", + "'-DayGroup', \"$DayGroup\"", + "'-WorldDayFraction'", + "'-SkyPhaseSeconds'", + "'-MsaaSamples', '0'", + "'-RenderPackPreset', $preset", + "'-Resolution', $resolution", + "'-OrbitDistanceMeters'", + "'-SkipBuild'"); + + string pixelGate = File.ReadAllText( + Path.Combine(FindRepoRoot(), "tools", "run-offline-pixel-gate.ps1")); + Assert.Contains( + "$probeCommands.Add('sleep 2000')", + pixelGate, + StringComparison.Ordinal); + Assert.Contains( + "last completed swapchain image", + pixelGate, + StringComparison.Ordinal); + } + + [Fact] + public void DeclaredCeilingsAreExactAndNewIncrementalMetricsAreRequired() + { + string source = ReadScript(); + + AssertPresetBudget(source, "low", "0.15", "0.50", "2.00", "3.00", "64L"); + AssertPresetBudget(source, "medium", "0.25", "0.75", "3.25", "4.50", "128L"); + AssertPresetBudget(source, "high", "0.35", "1.00", "4.50", "6.00", "256L"); + + foreach (string field in new[] + { + "IncrementalCpuMillisecondsP50", + "IncrementalCpuMillisecondsP95", + "IncrementalCpuMillisecondsP99", + "AbsoluteReceiverCpuMillisecondsP50", + "AbsoluteReceiverCpuMillisecondsP95", + "AbsoluteReceiverCpuMillisecondsP99", + "InclusiveGpuMillisecondsP50", + "InclusiveGpuMillisecondsP95", + "InclusiveGpuMillisecondsP99", + "ResidentGpuBytes", + }) + { + Assert.Contains($"'{field}'", source, StringComparison.Ordinal); + } + + Assert.DoesNotContain("'CpuMillisecondsP50'", source, StringComparison.Ordinal); + Assert.DoesNotContain("'CpuMillisecondsP99'", source, StringComparison.Ordinal); + Assert.DoesNotContain("'GpuMillisecondsP50'", source, StringComparison.Ordinal); + Assert.DoesNotContain("'GpuMillisecondsP99'", source, StringComparison.Ordinal); + + Assert.Contains( + "$gpuBudgetApplies = $availability -eq 'Active' -and", + source, + StringComparison.Ordinal); + Assert.Contains("$budget = $budgets[$effectiveQuality]", source, StringComparison.Ordinal); + Assert.Contains( + "$cpuP50 -gt $budget.IncrementalCpuMillisecondsP50", + source, + StringComparison.Ordinal); + Assert.Contains( + "$cpuP99 -gt $budget.IncrementalCpuMillisecondsP99", + source, + StringComparison.Ordinal); + Assert.Contains( + "$residentGpuBytes -gt $budget.ResidentGpuBytes", + source, + StringComparison.Ordinal); + Assert.Contains( + "$gpuP50 -gt $budget.InclusiveGpuMillisecondsP50At1080p", + source, + StringComparison.Ordinal); + Assert.Contains( + "$gpuP99 -gt $budget.InclusiveGpuMillisecondsP99At1080p", + source, + StringComparison.Ordinal); + } + + [Fact] + public void MetadataAndSummariesCarryTheRequiredEvidenceWithoutSecrets() + { + string source = ReadScript(); + + Assert.Contains( + "$screenshotLeaf = 'world-offline'", + source, + StringComparison.Ordinal); + foreach (string evidence in new[] + { + "CpuSampleCount", + "GpuSampleCount", + "ShadowCasterCount", + "CascadeDrawCount", + "DrawCalls", + "DispatchCalls", + }) + { + Assert.Contains($"'{evidence}'", source, StringComparison.Ordinal); + } + + Assert.Contains("atmospheric-performance-matrix.json", source, StringComparison.Ordinal); + Assert.Contains("atmospheric-performance-matrix.md", source, StringComparison.Ordinal); + Assert.Contains("DeclaredBudgets", source, StringComparison.Ordinal); + Assert.Contains("Rows = @($rows)", source, StringComparison.Ordinal); + Assert.Contains("Failures = @($matrixFailures)", source, StringComparison.Ordinal); + + Assert.DoesNotContain("ACDREAM_TEST_USER", source, StringComparison.Ordinal); + Assert.DoesNotContain("ACDREAM_TEST_PASS", source, StringComparison.Ordinal); + Assert.DoesNotContain("Get-ChildItem Env:", source, StringComparison.Ordinal); + } + + [Fact] + public void ExecutableMetadataOracleAcceptsCompleteShapeAndRejectsAdversarialEvidence() + { + string directory = Path.Combine(Path.GetTempPath(), $"acdream-matrix-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + string metadataPath = Path.Combine(directory, "capture.metadata.json"); + foreach (string preset in new[] { "low", "medium", "high" }) + { + File.WriteAllText(metadataPath, CreateMetadata(preset)); + JsonElement valid = RunMetadataOracle(metadataPath, preset); + Assert.True(valid.GetProperty("Passed").GetBoolean()); + Assert.Equal(9498, valid.GetProperty("ShadowCasterCount").GetInt32()); + } + + JsonNode automatic = JsonNode.Parse(CreateMetadata("high"))!; + automatic["RenderPack"]!["PresetId"] = "auto"; + automatic["RenderPack"]!["ActivationGeneration"] = 4; + File.WriteAllText(metadataPath, automatic.ToJsonString()); + JsonElement validAutomatic = RunMetadataOracle(metadataPath, "auto"); + Assert.True(validAutomatic.GetProperty("Passed").GetBoolean()); + Assert.Equal( + "high", + validAutomatic.GetProperty("EffectiveQuality").GetString()); + + File.WriteAllText(metadataPath, CreateMetadata("high")); + JsonNode invalid = JsonNode.Parse(File.ReadAllText(metadataPath))!; + JsonNode pack = invalid["RenderPack"]!; + pack["ShadowCasterCount"] = 0; + pack["CascadeDrawCount"] = 3; + pack["CpuClassificationCalls"] = 1; + pack["RetainedGpuBytes"] = 99; + pack["Performance"]!["CpuSampleCount"] = 2047; + pack["Performance"]!["IncrementalCpuMillisecondsP95"] = -1.0; + pack["Passes"]![1]!["DrawCalls"] = 4; + File.WriteAllText(metadataPath, invalid.ToJsonString()); + + JsonElement rejected = RunMetadataOracle(metadataPath, "high"); + Assert.False(rejected.GetProperty("Passed").GetBoolean()); + string failures = rejected.GetProperty("Failures").ToString(); + Assert.Contains("at least one shadow caster", failures, StringComparison.Ordinal); + Assert.Contains("exactly 4 cascades", failures, StringComparison.Ordinal); + Assert.Contains("zero CPU classifications", failures, StringComparison.Ordinal); + Assert.Contains("GPU bytes disagree", failures, StringComparison.Ordinal); + Assert.Contains("complete 2048-sample window", failures, StringComparison.Ordinal); + Assert.Contains("finite and non-negative", failures, StringComparison.Ordinal); + Assert.Contains("exactly 5 draws and zero dispatches", failures, StringComparison.Ordinal); + } + finally { Directory.Delete(directory, recursive: true); } + } + + [Fact] + public void ExecutableMetadataOracleReportsOnlyStrictZeroWorkUnavailability() + { + string directory = Path.Combine(Path.GetTempPath(), $"acdream-matrix-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + string metadataPath = Path.Combine(directory, "capture.metadata.json"); + const string resourceReason = + "Directional shadow rendering failed: Render pack preset 'low' needs 67465216 resident GPU bytes after materializing its scene-dependent shadow command buffers; the active pack budget is 67108864 bytes."; + File.WriteAllText(metadataPath, CreateFallbackMetadata(resourceReason)); + + JsonElement resourceUnavailable = RunMetadataOracle( + metadataPath, + "low", + allowSafeFallback: true); + Assert.True(resourceUnavailable.GetProperty("Passed").GetBoolean()); + Assert.Equal("Unavailable", resourceUnavailable.GetProperty("Outcome").GetString()); + Assert.Equal( + "ResourceUnavailable", + resourceUnavailable.GetProperty("UnavailableClassification").GetString()); + Assert.Equal(resourceReason, resourceUnavailable.GetProperty("FailureReason").GetString()); + + JsonElement automaticUnavailable = RunMetadataOracle( + metadataPath, + "auto", + allowSafeFallback: true); + Assert.True(automaticUnavailable.GetProperty("Passed").GetBoolean()); + Assert.Equal( + "ResourceUnavailable", + automaticUnavailable.GetProperty("UnavailableClassification").GetString()); + + JsonElement notOptedIn = RunMetadataOracle(metadataPath, "low"); + Assert.False(notOptedIn.GetProperty("Passed").GetBoolean()); + Assert.Contains( + "not explicitly allowed", + notOptedIn.GetProperty("Failures").ToString(), + StringComparison.Ordinal); + + const string capabilityReason = + "Preset 'low' requires unsupported capability 'MultiviewDirectionalShadowCascades'."; + File.WriteAllText(metadataPath, CreateFallbackMetadata(capabilityReason)); + JsonElement capabilityUnavailable = RunMetadataOracle( + metadataPath, + "low", + allowSafeFallback: true); + Assert.True(capabilityUnavailable.GetProperty("Passed").GetBoolean()); + Assert.Equal( + "CapabilityUnavailable", + capabilityUnavailable.GetProperty("UnavailableClassification").GetString()); + + const string performanceReason = + "Automatic quality disabled render pack 'acdream.atmospheric' because Low remained over its declared performance budget for 180 stable samples: GPU p99 24.234 ms (budget 3.000 ms), CPU p99 0.630 ms (budget 0.500 ms), resident GPU bytes 41648404 (budget 67108864)."; + File.WriteAllText(metadataPath, CreateFallbackMetadata(performanceReason)); + JsonElement performanceUnavailable = RunMetadataOracle( + metadataPath, + "auto", + allowSafeFallback: true); + Assert.True(performanceUnavailable.GetProperty("Passed").GetBoolean()); + Assert.Equal( + "PerformanceUnavailable", + performanceUnavailable.GetProperty("UnavailableClassification").GetString()); + + JsonElement explicitLowCannotUseAutoPerformanceFallback = RunMetadataOracle( + metadataPath, + "low", + allowSafeFallback: true); + Assert.False( + explicitLowCannotUseAutoPerformanceFallback.GetProperty("Passed").GetBoolean()); + + const string forgedWithinBudget = + "Automatic quality disabled render pack 'acdream.atmospheric' because Low remained over its declared performance budget for 180 stable samples: GPU p99 2.000 ms (budget 3.000 ms), CPU p99 0.400 ms (budget 0.500 ms), resident GPU bytes 41648404 (budget 67108864)."; + File.WriteAllText(metadataPath, CreateFallbackMetadata(forgedWithinBudget)); + JsonElement forged = RunMetadataOracle( + metadataPath, + "auto", + allowSafeFallback: true); + Assert.False(forged.GetProperty("Passed").GetBoolean()); + + const string arbitraryFailure = + "Render pack 'acdream.atmospheric' could not be prepared: shader validation failed: unsupported binding."; + File.WriteAllText(metadataPath, CreateFallbackMetadata(arbitraryFailure)); + JsonElement unexpected = RunMetadataOracle(metadataPath, "low", allowSafeFallback: true); + Assert.False(unexpected.GetProperty("Passed").GetBoolean()); + Assert.Equal( + "UnexpectedFailure", + unexpected.GetProperty("UnavailableClassification").GetString()); + Assert.Contains( + "not a strict resource/capability/Auto-performance unavailability", + unexpected.GetProperty("Failures").ToString(), + StringComparison.Ordinal); + + JsonNode unsafeFallback = JsonNode.Parse(CreateFallbackMetadata(resourceReason))!; + unsafeFallback["RenderPack"]!["ShadowCasterCount"] = 1; + unsafeFallback["RenderPack"]!["Performance"]!["GpuSampleCount"] = 1; + File.WriteAllText(metadataPath, unsafeFallback.ToJsonString()); + JsonElement rejectedWork = RunMetadataOracle(metadataPath, "low", allowSafeFallback: true); + Assert.False(rejectedWork.GetProperty("Passed").GetBoolean()); + string failures = rejectedWork.GetProperty("Failures").ToString(); + Assert.Contains("zero pack work and resources", failures, StringComparison.Ordinal); + Assert.Contains("zero GpuSampleCount", failures, StringComparison.Ordinal); + } + finally { Directory.Delete(directory, recursive: true); } + } + + private static string CreateMetadata(string preset) + { + int cascades = preset switch { "low" => 2, "medium" => 3, _ => 4 }; + const int shadowDraws = 5; + var passIds = new List { "atmospheric-world-receiver" }; + if (preset == "low") + passIds.Add("directional-shadow-multiview"); + else for (int cascade = 0; cascade < cascades; cascade++) + passIds.Add($"directional-shadow-cascade-{cascade}"); + passIds.AddRange([ + "atmospheric-sun-occlusion", "atmospheric-sun-rays", + "atmospheric-volumetric-shafts", "atmospheric-bloom-downsample", + "atmospheric-bloom-blur-horizontal", "atmospheric-bloom-blur-vertical", + "atmospheric-filmic"]); + object[] passes = passIds.Select((id, index) => new + { + PassId = id, + GpuMilliseconds = 0.1, + DrawCalls = id.StartsWith("directional-", StringComparison.Ordinal) ? shadowDraws : + id is "atmospheric-world-receiver" or "atmospheric-volumetric-shafts" ? 0 : 1, + DispatchCalls = 0, + }).Cast().ToArray(); + var performance = new + { + CpuSampleCount = 2048, + AbsoluteReceiverCpuSampleCount = 2048, + GpuSampleCount = 2048, + IncrementalCpuMillisecondsP50 = 0.1, + IncrementalCpuMillisecondsP95 = 0.2, + IncrementalCpuMillisecondsP99 = 0.3, + AbsoluteReceiverCpuMillisecondsP50 = 1.0, + AbsoluteReceiverCpuMillisecondsP95 = 1.5, + AbsoluteReceiverCpuMillisecondsP99 = 2.0, + InclusiveGpuMillisecondsP50 = 2.0, + InclusiveGpuMillisecondsP95 = 3.0, + InclusiveGpuMillisecondsP99 = 4.0, + ResidentGpuBytes = 1000L, + TransientGpuBytes = 0L, + }; + return JsonSerializer.Serialize(new + { + SchemaVersion = 1, + Width = 1920, + Height = 1080, + RenderPack = new + { + State = 2, + PackId = "acdream.atmospheric", + PackVersion = "1.0.0", + PresetId = preset, + EffectiveQuality = preset, + FailureReason = (string?)null, + ActivationGeneration = 1, + RetainedGpuBytes = 1000L, + TransientGpuBytes = 0L, + ImageCount = 1, + BufferCount = 1, + DrawCalls = (preset == "low" ? shadowDraws : shadowDraws * cascades) + 6, + DispatchCalls = 0, + ShadowCasterCount = 9498, + CascadeDrawCount = cascades, + CpuClassificationCalls = 0, + Passes = passes, + Performance = performance, + }, + }); + } + + private static string CreateFallbackMetadata(string failureReason) => JsonSerializer.Serialize(new + { + SchemaVersion = 1, + Width = 1920, + Height = 1080, + RenderPack = new + { + State = 3, + PackId = "retail", + PackVersion = (string?)null, + PresetId = "off", + EffectiveQuality = "off", + FailureReason = failureReason, + ActivationGeneration = 2, + RetainedGpuBytes = 0L, + TransientGpuBytes = 0L, + ImageCount = 0, + BufferCount = 0, + DrawCalls = 0, + DispatchCalls = 0, + ShadowCasterCount = 0, + CascadeDrawCount = 0, + CpuClassificationCalls = 0, + Passes = Array.Empty(), + Performance = new + { + CpuSampleCount = 0, + AbsoluteReceiverCpuSampleCount = 0, + GpuSampleCount = 0, + IncrementalCpuMillisecondsP50 = 0.0, + IncrementalCpuMillisecondsP95 = 0.0, + IncrementalCpuMillisecondsP99 = 0.0, + AbsoluteReceiverCpuMillisecondsP50 = 0.0, + AbsoluteReceiverCpuMillisecondsP95 = 0.0, + AbsoluteReceiverCpuMillisecondsP99 = 0.0, + InclusiveGpuMillisecondsP50 = 0.0, + InclusiveGpuMillisecondsP95 = 0.0, + InclusiveGpuMillisecondsP99 = 0.0, + ResidentGpuBytes = 0L, + TransientGpuBytes = 0L, + }, + }, + }); + + private static JsonElement RunMetadataOracle( + string metadataPath, + string preset, + bool allowSafeFallback = false) + { + string helper = Path.Combine(FindRepoRoot(), "tools", "atmospheric-performance-matrix-common.ps1"); + string quote(string value) => "'" + value.Replace("'", "''", StringComparison.Ordinal) + "'"; + var start = new ProcessStartInfo + { + FileName = OperatingSystem.IsWindows() ? "pwsh.exe" : "pwsh", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + start.ArgumentList.Add("-NoProfile"); + start.ArgumentList.Add("-NonInteractive"); + start.ArgumentList.Add("-Command"); + start.ArgumentList.Add( + $". {quote(helper)}; Test-AtmosphericPerformanceMetadataEvidence " + + $"-MetadataPath {quote(metadataPath)} -Preset {preset} -ExpectedWidth 1920 " + + "-ExpectedHeight 1080 " + + (allowSafeFallback ? "-AllowSafeFallback " : string.Empty) + + "| ConvertTo-Json -Depth 8 -Compress"); + using Process process = Process.Start(start)!; + string stdout = process.StandardOutput.ReadToEnd(); + string stderr = process.StandardError.ReadToEnd(); + Assert.True(process.WaitForExit(30_000), "PowerShell oracle did not exit."); + Assert.True(process.ExitCode == 0, $"PowerShell oracle failed.\n{stdout}\n{stderr}"); + using JsonDocument document = JsonDocument.Parse(stdout); + return document.RootElement.Clone(); + } + + private static void AssertPresetBudget( + string source, + string preset, + string cpuP50, + string cpuP99, + string gpuP50, + string gpuP99, + string memory) + { + int start = source.IndexOf($"{preset} = [pscustomobject]", StringComparison.Ordinal); + Assert.True(start >= 0, $"Missing {preset} budget."); + int end = source.IndexOf(" }", start, StringComparison.Ordinal); + Assert.True(end > start, $"Malformed {preset} budget."); + string budget = source[start..end]; + + Assert.Contains($"IncrementalCpuMillisecondsP50 = {cpuP50}", budget); + Assert.Contains($"IncrementalCpuMillisecondsP99 = {cpuP99}", budget); + Assert.Contains($"InclusiveGpuMillisecondsP50At1080p = {gpuP50}", budget); + Assert.Contains($"InclusiveGpuMillisecondsP99At1080p = {gpuP99}", budget); + Assert.Contains($"ResidentGpuBytes = {memory} * 1024L * 1024L", budget); + } + + private static string ReadScript() => File.ReadAllText(ScriptPath()); + + private static string ScriptPath() => Path.Combine( + FindRepoRoot(), + "tools", + "run-atmospheric-performance-matrix.ps1"); + + private static void AssertAppearsInOrder(string source, params string[] values) + { + int cursor = -1; + foreach (string value in values) + { + int next = source.IndexOf(value, cursor + 1, StringComparison.Ordinal); + Assert.True(next >= 0, $"Missing expected source fragment: {value}"); + Assert.True(next > cursor, $"Out-of-order source fragment: {value}"); + cursor = next; + } + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/Diagnostics/AtmosphericPreviewLauncherContractTests.cs b/tests/AcDream.App.Tests/Diagnostics/AtmosphericPreviewLauncherContractTests.cs new file mode 100644 index 00000000..278e3377 --- /dev/null +++ b/tests/AcDream.App.Tests/Diagnostics/AtmosphericPreviewLauncherContractTests.cs @@ -0,0 +1,121 @@ +using System.Diagnostics; + +namespace AcDream.App.Tests.Diagnostics; + +public sealed class AtmosphericPreviewLauncherContractTests +{ + [Fact] + public void ScriptParsesWithoutLaunchingTheClient() + { + string script = ScriptPath(); + var start = new ProcessStartInfo + { + FileName = OperatingSystem.IsWindows() ? "pwsh.exe" : "pwsh", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + start.ArgumentList.Add("-NoProfile"); + start.ArgumentList.Add("-NonInteractive"); + start.ArgumentList.Add("-Command"); + string quotedScript = "'" + script.Replace("'", "''", StringComparison.Ordinal) + "'"; + start.ArgumentList.Add( + $"[scriptblock]::Create((Get-Content -Raw -LiteralPath {quotedScript})) | Out-Null"); + + using Process process = Process.Start(start) + ?? throw new InvalidOperationException("Could not start pwsh parser process."); + string stdout = process.StandardOutput.ReadToEnd(); + string stderr = process.StandardError.ReadToEnd(); + Assert.True(process.WaitForExit(30_000), "PowerShell parser did not exit."); + Assert.True( + process.ExitCode == 0, + $"PowerShell parser failed with exit code {process.ExitCode}.\n{stdout}\n{stderr}"); + } + + [Fact] + public void PreviewIsAudioSafeIsolatedAndDiagnosticByDefault() + { + string source = File.ReadAllText(ScriptPath()); + + Assert.Contains("[switch]$EnableAudio", source, StringComparison.Ordinal); + Assert.Contains("$audioEnabled = [bool]$EnableAudio", source, StringComparison.Ordinal); + Assert.Contains( + "$env:ACDREAM_NO_AUDIO = if ($audioEnabled) { $null } else { '1' }", + source, + StringComparison.Ordinal); + Assert.Contains("'enabled-explicit'", source, StringComparison.Ordinal); + Assert.Contains("'disabled-default'", source, StringComparison.Ordinal); + + Assert.Contains( + ".StartsWith('ACDREAM_', [StringComparison]::OrdinalIgnoreCase)", + source, + StringComparison.Ordinal); + Assert.Contains( + "[Environment]::SetEnvironmentVariable($name, $null, 'Process')", + source, + StringComparison.Ordinal); + Assert.Contains( + "[Environment]::SetEnvironmentVariable($name, $prior[$name], 'Process')", + source, + StringComparison.Ordinal); + AssertAppearsInOrder( + source, + "[Environment]::SetEnvironmentVariable($name, $null, 'Process')", + "$env:ACDREAM_CONFIG_DIR = $config", + "$process = Start-Process", + "[Environment]::SetEnvironmentVariable($name, $prior[$name], 'Process')"); + + Assert.Contains("schemaVersion = 2", source, StringComparison.Ordinal); + Assert.Contains("executableSha256", source, StringComparison.Ordinal); + Assert.Contains("executableProductVersion", source, StringComparison.Ordinal); + Assert.Contains("stdoutLog = $stdoutLog", source, StringComparison.Ordinal); + Assert.Contains("stderrLog = $stderrLog", source, StringComparison.Ordinal); + Assert.Contains("status = 'prepared'", source, StringComparison.Ordinal); + Assert.Contains("$launch.status = 'start-failed'", source, StringComparison.Ordinal); + Assert.Contains("$launch.status = 'started'", source, StringComparison.Ordinal); + Assert.Contains("-RedirectStandardOutput $stdoutLog", source, StringComparison.Ordinal); + Assert.Contains("-RedirectStandardError $stderrLog", source, StringComparison.Ordinal); + Assert.DoesNotContain("-WindowStyle Hidden", source, StringComparison.Ordinal); + + Assert.Contains("if (Test-Path -LiteralPath $root)", source, StringComparison.Ordinal); + Assert.Contains( + "New-Item -ItemType Directory -Path $config, $data, $cache", + source, + StringComparison.Ordinal); + Assert.DoesNotContain( + "New-Item -ItemType Directory -Force -Path $config, $data, $cache", + source, + StringComparison.Ordinal); + Assert.DoesNotContain("prior = $prior", source, StringComparison.OrdinalIgnoreCase); + } + + private static string ScriptPath() => Path.Combine( + FindRepoRoot(), + "tools", + "launch-atmospheric-preview.ps1"); + + private static void AssertAppearsInOrder(string source, params string[] fragments) + { + int cursor = -1; + foreach (string fragment in fragments) + { + int next = source.IndexOf(fragment, cursor + 1, StringComparison.Ordinal); + Assert.True(next > cursor, $"Missing or out-of-order fragment: {fragment}"); + cursor = next; + } + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/Diagnostics/ConnectedRenderPackGateContractTests.cs b/tests/AcDream.App.Tests/Diagnostics/ConnectedRenderPackGateContractTests.cs new file mode 100644 index 00000000..843f157c --- /dev/null +++ b/tests/AcDream.App.Tests/Diagnostics/ConnectedRenderPackGateContractTests.cs @@ -0,0 +1,505 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Nodes; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Scene; + +namespace AcDream.App.Tests.Diagnostics; + +public sealed class ConnectedRenderPackGateContractTests +{ + private const string LifecycleScript = "run-connected-world-lifecycle-gate.ps1"; + private const string SoakScript = "run-connected-r6-soak.ps1"; + + [Fact] + public void ScreenshotJsonCarriesAuthoritativeCasterClassCounters() + { + DirectionalShadowTransformChurnDiagnostics churn = default; + churn = churn with + { + CasterClasses = new DirectionalShadowCasterClassDiagnostics( + TerrainCommands: 1, + OutdoorStatics: 2, + Buildings: 3, + AnimatedStatics: 4, + LocalPlayers: 5, + RemotePlayers: 6, + NonPlayerCreatures: 7, + OtherLiveDynamics: 8, + EquippedChildren: 9), + }; + + using JsonDocument json = JsonDocument.Parse( + JsonSerializer.Serialize(churn)); + JsonElement classes = json.RootElement.GetProperty("CasterClasses"); + + Assert.Equal(1, classes.GetProperty("TerrainCommands").GetInt32()); + Assert.Equal(2, classes.GetProperty("OutdoorStatics").GetInt32()); + Assert.Equal(3, classes.GetProperty("Buildings").GetInt32()); + Assert.Equal(4, classes.GetProperty("AnimatedStatics").GetInt32()); + Assert.Equal(5, classes.GetProperty("LocalPlayers").GetInt32()); + Assert.Equal(6, classes.GetProperty("RemotePlayers").GetInt32()); + Assert.Equal(7, classes.GetProperty("NonPlayerCreatures").GetInt32()); + Assert.Equal(8, classes.GetProperty("OtherLiveDynamics").GetInt32()); + Assert.Equal(9, classes.GetProperty("EquippedChildren").GetInt32()); + } + private const string CommonScript = "connected-render-pack-gate-common.ps1"; + + [Fact] + public void ConnectedGatesExposeTheSameRetailDefaultAndOptionalOverrides() + { + foreach (string scriptName in new[] { LifecycleScript, SoakScript }) + { + string source = ReadTool(scriptName); + Assert.Contains( + "[ValidateSet('retail', 'low', 'medium', 'high', 'auto')]", + source, + StringComparison.Ordinal); + Assert.Contains( + "[string]$RenderPackPreset = 'retail'", + source, + StringComparison.Ordinal); + Assert.Contains( + "[hashtable]$RenderPackSettingOverrides = @{}", + source, + StringComparison.Ordinal); + Assert.Contains( + ". (Join-Path $PSScriptRoot 'connected-render-pack-gate-common.ps1')", + source, + StringComparison.Ordinal); + Assert.Contains( + "-Preset $RenderPackPreset", + source, + StringComparison.Ordinal); + Assert.Contains( + "-SettingOverrides $RenderPackSettingOverrides", + source, + StringComparison.Ordinal); + } + } + + [Fact] + public void SharedSeamPinsSchemaAndOwnsTheCompleteConnectedEnvironmentTransaction() + { + string source = ReadTool(CommonScript); + foreach (string variable in new[] + { + "ACDREAM_CONFIG_DIR", "ACDREAM_DATA_DIR", "ACDREAM_CACHE_DIR", + "ACDREAM_DAT_DIR", "ACDREAM_PAK_PATH", "ACDREAM_LIVE", "ACDREAM_TEST_HOST", + "ACDREAM_TEST_PORT", "ACDREAM_TEST_USER", "ACDREAM_TEST_PASS", + "ACDREAM_RETAIL_UI", "ACDREAM_FRAME_PROF", "ACDREAM_FRAME_HISTORY", + "ACDREAM_UNCAPPED_RENDER", "ACDREAM_DEVTOOLS", "ACDREAM_UI_PROBE_DUMP", + "ACDREAM_UI_PROBE_SCRIPT", "ACDREAM_AUTOMATION_ARTIFACT_DIR", + "ACDREAM_DUMP_MOVE_TRUTH", "ACDREAM_NO_AUDIO", "ACDREAM_WB_DIAG", + "ACDREAM_RENDER_BACKEND", "ACDREAM_NET_DROP_PCT", + "ACDREAM_NET_DROP_SEED", "ACDREAM_NET_DROP_DIR", + "ACDREAM_COLLISION_SHADOW_EVERY", "ACDREAM_COLLISION_SHADOW_DIR", + "ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER", "ACDREAM_DAY_GROUP", + "ACDREAM_WORLD_TIME", "ACDREAM_SKY_PHASE_SECONDS", + "ACDREAM_ORBIT_DISTANCE_METERS", "ACDREAM_ORBIT_YAW_DEGREES", + "ACDREAM_ORBIT_PITCH_DEGREES", "ACDREAM_VULKAN_DEVICE", + "ACDREAM_VULKAN_FORCE_UNSUPPORTED", "ACDREAM_VULKAN_PROBE", + "ACDREAM_VULKAN_PROBE_FRAMES", + }) + Assert.Contains($"'{variable}'", source, StringComparison.Ordinal); + Assert.Contains("$State.PreviousEnvironment.GetEnumerator()", source, StringComparison.Ordinal); + Assert.Contains("Get-ChildItem Env:", source, StringComparison.Ordinal); + Assert.Contains("Remove-Item -LiteralPath \"Env:$name\"", source, StringComparison.Ordinal); + Assert.Contains("Assert-ConnectedGateContainedPath", source, StringComparison.Ordinal); + Assert.Contains("Assert-ConnectedGateNoReparsePoint", source, StringComparison.Ordinal); + } + + [Fact] + public void PowerShellRoundTripRestoresEveryConnectedVariableIncludingCredentials() + { + string root = Path.Combine(Path.GetTempPath(), $"acdream-connected-pack-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + string common = PsQuote(Path.Combine(FindRepoRoot(), "tools", CommonScript)); + string rootQuoted = PsQuote(root); + string command = $@" +. {common} +foreach ($name in $script:ConnectedGateEnvironmentNames) {{ + [Environment]::SetEnvironmentVariable($name, ""sentinel-$name"", 'Process') +}} +[Environment]::SetEnvironmentVariable('ACDREAM_FUTURE_GATE_KNOB', 'sentinel-future', 'Process') +$state = New-ConnectedRenderPackGateState -Root {rootQuoted} -Preset medium +$isolated=@($state.PreviousEnvironment.Keys | Where-Object {{ + $_ -notin @('ACDREAM_CONFIG_DIR', 'ACDREAM_DATA_DIR', 'ACDREAM_CACHE_DIR') -and + [Environment]::GetEnvironmentVariable($_, 'Process') -ne $null +}}) +foreach ($name in $script:ConnectedGateEnvironmentNames) {{ + [Environment]::SetEnvironmentVariable($name, ""mutated-$name"", 'Process') +}} +[Environment]::SetEnvironmentVariable('ACDREAM_FUTURE_GATE_KNOB', 'mutated-future', 'Process') +Restore-ConnectedRenderPackGateEnvironment $state +$mismatches=@($script:ConnectedGateEnvironmentNames | Where-Object {{ + [Environment]::GetEnvironmentVariable($_, 'Process') -cne ""sentinel-$_"" +}}) +$unsafeLeafRejected=$false +try {{ Assert-ConnectedGateSafeLeafName '../escape' }} catch {{ $unsafeLeafRejected=$true }} +$escapeRejected=$false +try {{ Assert-ConnectedGateContainedPath {rootQuoted} (Join-Path {rootQuoted} '..\escape') }} catch {{ $escapeRejected=$true }} +[pscustomobject]@{{ + Password=$env:ACDREAM_TEST_PASS; User=$env:ACDREAM_TEST_USER; + Config=$env:ACDREAM_CONFIG_DIR; History=$env:ACDREAM_FRAME_HISTORY; + Future=$env:ACDREAM_FUTURE_GATE_KNOB; Isolated=$isolated; Mismatches=$mismatches; + UnsafeLeafRejected=$unsafeLeafRejected; EscapeRejected=$escapeRejected; + Settings=(Get-Content -Raw -LiteralPath (Join-Path $state.ConfigDirectory 'settings.json') | ConvertFrom-Json).display.renderPack.presetId +}} | ConvertTo-Json -Compress"; + using JsonDocument result = JsonDocument.Parse(RunPowerShell(command)); + JsonElement rootElement = result.RootElement; + Assert.Equal("sentinel-ACDREAM_TEST_PASS", rootElement.GetProperty("Password").GetString()); + Assert.Equal("sentinel-ACDREAM_TEST_USER", rootElement.GetProperty("User").GetString()); + Assert.Equal("sentinel-ACDREAM_CONFIG_DIR", rootElement.GetProperty("Config").GetString()); + Assert.Equal("sentinel-ACDREAM_FRAME_HISTORY", rootElement.GetProperty("History").GetString()); + Assert.Equal("sentinel-future", rootElement.GetProperty("Future").GetString()); + Assert.Empty(rootElement.GetProperty("Isolated").EnumerateArray()); + Assert.Empty(rootElement.GetProperty("Mismatches").EnumerateArray()); + Assert.True(rootElement.GetProperty("UnsafeLeafRejected").GetBoolean()); + Assert.True(rootElement.GetProperty("EscapeRejected").GetBoolean()); + Assert.Equal("medium", rootElement.GetProperty("Settings").GetString()); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Fact] + public void EveryConnectedScreenshotMustProveExactFailureFreeActivation() + { + string common = ReadTool(CommonScript); + Assert.Contains("screenshots\\$name.metadata.json", common, StringComparison.Ordinal); + Assert.Contains("@('PackId', [string]$State.PackId)", common, StringComparison.Ordinal); + Assert.Contains("@('PresetId', [string]$State.PresetId)", common, StringComparison.Ordinal); + Assert.Contains("[int]$actual.State -ne [int]$State.ExpectedState", common, StringComparison.Ordinal); + Assert.Contains( + "[string]::IsNullOrWhiteSpace([string]$actual.FailureReason)", + common, + StringComparison.Ordinal); + Assert.Contains("SchemaVersion", common, StringComparison.Ordinal); + Assert.Contains("ActivationGeneration", common, StringComparison.Ordinal); + Assert.Contains("EffectiveQuality", common, StringComparison.Ordinal); + Assert.Contains("retained GPU byte ledgers disagree", common, StringComparison.Ordinal); + Assert.Contains("expected zero pack work", common, StringComparison.Ordinal); + Assert.Contains("recorded no complete pack graph work", common, StringComparison.Ordinal); + Assert.Contains("positive shadow strength but no shadow casters", common, StringComparison.Ordinal); + + string lifecycle = ReadTool(LifecycleScript); + Assert.Contains("-ScreenshotNames @($name)", lifecycle, StringComparison.Ordinal); + Assert.Contains("-Label $Label", lifecycle, StringComparison.Ordinal); + + string soak = ReadTool(SoakScript); + Assert.Contains("-ScreenshotNames $expectedCheckpointNames", soak, StringComparison.Ordinal); + Assert.Contains("-Label $runName", soak, StringComparison.Ordinal); + } + + [Fact] + public void LifecycleGateExecutesAtomicTransitionsResizeEnvironmentAndFreshContextRow() + { + string lifecycle = ReadTool(LifecycleScript); + Assert.Contains("if ($RenderPackPreset -eq 'medium')", lifecycle, StringComparison.Ordinal); + Assert.Contains("connected-render-pack-transitions.route.txt", lifecycle, StringComparison.Ordinal); + Assert.Contains("Get-ConnectedRenderPackExpectation -Preset high", lifecycle, StringComparison.Ordinal); + Assert.Contains("Get-ConnectedRenderPackExpectation -Preset retail", lifecycle, StringComparison.Ordinal); + Assert.Contains("ScreenshotStateOverrides", lifecycle, StringComparison.Ordinal); + Assert.Contains("Get-FreshContextRecreationGate $capped $uncapped", lifecycle, StringComparison.Ordinal); + Assert.Contains("StartTimeUtc", lifecycle, StringComparison.Ordinal); + Assert.Contains("resized screenshot was", lifecycle, StringComparison.OrdinalIgnoreCase); + Assert.Contains("authored time change did not alter published sun elevation", lifecycle, StringComparison.Ordinal); + Assert.Contains("first weather edge did not publish Overcast", lifecycle, StringComparison.Ordinal); + Assert.Contains("$selected.ShadowTransformChurn.CasterClasses", lifecycle, StringComparison.Ordinal); + foreach (string casterClass in new[] + { + "TerrainCommands", + "OutdoorStatics", + "Buildings", + "AnimatedStatics", + "LocalPlayers", + "NonPlayerCreatures", + "EquippedChildren", + }) + { + Assert.Contains(casterClass, lifecycle, StringComparison.Ordinal); + } + Assert.Contains("second live client", lifecycle, StringComparison.Ordinal); + Assert.Contains("not hostile monster", lifecycle, StringComparison.Ordinal); + Assert.Contains("no authoritative tree discriminator", lifecycle, StringComparison.Ordinal); + + string route = ReadTool("connected-render-pack-transitions.route.txt"); + AssertAppearsInOrder( + route, + "renderpack select high", + "wait render-pack high 90000", + "renderpack disable", + "wait render-pack retail 90000", + "renderpack reenable", + "wait render-pack high 90000", + "resize 1024 768", + "wait framebuffer 1024 768 30000", + "input press AcdreamCycleTimeOfDay", + "input press AcdreamCycleWeather", + "input press AcdreamCycleWeather", + "checkpoint atmospheric_transitions"); + Assert.Contains("transition_selected_high", route, StringComparison.Ordinal); + Assert.Contains("transition_disabled_retail", route, StringComparison.Ordinal); + Assert.Contains("transition_reenabled_high", route, StringComparison.Ordinal); + Assert.Contains("transition_resized_high", route, StringComparison.Ordinal); + Assert.Contains("transition_overcast_high", route, StringComparison.Ordinal); + Assert.Contains("transition_rain_high", route, StringComparison.Ordinal); + } + + [Fact] + public void ExecutableMetadataGateAcceptsAutoQualityAndRejectsLedgerOrVersionDrift() + { + string root = Path.Combine(Path.GetTempPath(), $"acdream-connected-metadata-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path.Combine(root, "screenshots")); + string path = Path.Combine(root, "screenshots", "checkpoint.metadata.json"); + try + { + var document = new + { + SchemaVersion = 1, + RenderPack = new + { + PackId = "acdream.atmospheric", + PackVersion = "1.0.0", + PresetId = "auto", + State = 2, + ActivationGeneration = 3, + EffectiveQuality = "medium", + FailureReason = (string?)null, + RetainedGpuBytes = 123L, + TransientGpuBytes = 4L, + ImageCount = 4, + BufferCount = 2, + DrawCalls = 6, + DispatchCalls = 0, + ShadowCasterCount = 22, + CascadeDrawCount = 3, + CpuClassificationCalls = 0, + SharedWorldTransformUsedInstances = 68_395u, + Outdoor = true, + DirectionalShadowStrength = 0.75, + Passes = new[] + { + new { PassId = "directional-shadow", GpuMilliseconds = 0.2, DrawCalls = 2, DispatchCalls = 0 }, + }, + Performance = new { ResidentGpuBytes = 123L, TransientGpuBytes = 4L }, + }, + }; + File.WriteAllText(path, JsonSerializer.Serialize(document)); + JsonElement valid = RunConnectedMetadataGate(root); + Assert.Empty(valid.GetProperty("Failures").EnumerateArray()); + + JsonNode invalid = JsonNode.Parse(File.ReadAllText(path))!; + invalid["SchemaVersion"] = 2; + invalid["RenderPack"]!["PackVersion"] = "9.9.9"; + invalid["RenderPack"]!["EffectiveQuality"] = "ultra"; + invalid["RenderPack"]!["RetainedGpuBytes"] = 999; + File.WriteAllText(path, invalid.ToJsonString()); + JsonElement rejected = RunConnectedMetadataGate(root); + string failures = rejected.GetProperty("Failures").ToString(); + Assert.Contains("schema was 2", failures, StringComparison.Ordinal); + Assert.Contains("PackVersion '9.9.9'", failures, StringComparison.Ordinal); + Assert.Contains("effective quality 'ultra'", failures, StringComparison.Ordinal); + Assert.Contains("retained GPU byte ledgers disagree", failures, StringComparison.Ordinal); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Fact] + public void ExecutableMetadataGateRejectsActiveNoWorkAndRetailPackWork() + { + string root = Path.Combine(Path.GetTempPath(), $"acdream-connected-work-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path.Combine(root, "screenshots")); + string path = Path.Combine(root, "screenshots", "checkpoint.metadata.json"); + try + { + var activeNoWork = new + { + SchemaVersion = 1, + RenderPack = new + { + PackId = "acdream.atmospheric", + PackVersion = "1.0.0", + PresetId = "auto", + State = 2, + ActivationGeneration = 1, + EffectiveQuality = "low", + FailureReason = (string?)null, + RetainedGpuBytes = 0L, + TransientGpuBytes = 0L, + ImageCount = 0, + BufferCount = 0, + DrawCalls = 0, + DispatchCalls = 0, + ShadowCasterCount = 0, + CascadeDrawCount = 0, + CpuClassificationCalls = 0, + SharedWorldTransformUsedInstances = 0u, + Outdoor = true, + DirectionalShadowStrength = 0.5, + Passes = Array.Empty(), + Performance = new { ResidentGpuBytes = 0L, TransientGpuBytes = 0L }, + }, + }; + File.WriteAllText(path, JsonSerializer.Serialize(activeNoWork)); + JsonElement activeRejected = RunConnectedMetadataGate(root); + string activeFailures = activeRejected.GetProperty("Failures").ToString(); + Assert.Contains("recorded no complete pack graph work", activeFailures, StringComparison.Ordinal); + Assert.Contains("positive shadow strength but no shadow casters", activeFailures, StringComparison.Ordinal); + Assert.Contains("positive shadow strength but no combined shared-world-transform usage", activeFailures, StringComparison.Ordinal); + + var retailWork = new + { + SchemaVersion = 1, + RenderPack = new + { + PackId = "retail", + PackVersion = (string?)null, + PresetId = "off", + State = 0, + ActivationGeneration = 0, + EffectiveQuality = "off", + FailureReason = (string?)null, + RetainedGpuBytes = 64L, + TransientGpuBytes = 0L, + ImageCount = 1, + BufferCount = 0, + DrawCalls = 1, + DispatchCalls = 0, + ShadowCasterCount = 0, + CascadeDrawCount = 0, + CpuClassificationCalls = 0, + SharedWorldTransformUsedInstances = 1u, + Outdoor = false, + DirectionalShadowStrength = 0.0, + Passes = new[] + { + new { PassId = "unexpected", GpuMilliseconds = 0.1, DrawCalls = 1, DispatchCalls = 0 }, + }, + Performance = new { ResidentGpuBytes = 64L, TransientGpuBytes = 0L }, + }, + }; + File.WriteAllText(path, JsonSerializer.Serialize(retailWork)); + JsonElement retailRejected = RunConnectedMetadataGate(root, "retail"); + string retailFailures = retailRejected.GetProperty("Failures").ToString(); + Assert.Contains("expected zero pack work", retailFailures, StringComparison.Ordinal); + Assert.Contains("recorded pack passes, expected none", retailFailures, StringComparison.Ordinal); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Fact] + public void BothGatesFailClosedOnUnprovableBinaryIdentity() + { + string common = ReadTool(CommonScript); + Assert.Contains("Measured binary commit $binaryCommit differs", common, StringComparison.Ordinal); + Assert.Contains("status --short --untracked-files=all", common, StringComparison.Ordinal); + Assert.Contains("Connected closeout evidence cannot prove binary/source identity", common, StringComparison.Ordinal); + foreach (string scriptName in new[] { LifecycleScript, SoakScript }) + Assert.Contains("Get-ConnectedGateBinaryIdentity", ReadTool(scriptName), StringComparison.Ordinal); + } + + [Fact] + public void BothGatesRestoreEnvironmentAndRecordRequestedSelection() + { + foreach (string scriptName in new[] { LifecycleScript, SoakScript }) + { + string source = ReadTool(scriptName); + int selectionStart = source.IndexOf( + "$renderPackGate = New-ConnectedRenderPackGateState", + StringComparison.Ordinal); + Assert.True(selectionStart >= 0, $"{scriptName} does not initialize isolated state."); + string selectionScope = source[selectionStart..]; + + AssertAppearsInOrder( + selectionScope, + "$renderPackGate = New-ConnectedRenderPackGateState", + "try {", + "RenderPackSelection = (Get-ConnectedRenderPackGateReport $renderPackGate)", + "finally {", + "Restore-ConnectedRenderPackGateEnvironment $renderPackGate"); + Assert.Contains( + "Add-ConnectedRenderPackMetadataFailures", + source, + StringComparison.Ordinal); + Assert.Contains("Start-Process -FilePath $exe", source, StringComparison.Ordinal); + } + } + + private static string ReadTool(string fileName) => File.ReadAllText(Path.Combine( + FindRepoRoot(), + "tools", + fileName)); + + private static string PsQuote(string value) => "'" + value.Replace("'", "''", StringComparison.Ordinal) + "'"; + + private static string RunPowerShell(string command) + { + var start = new ProcessStartInfo + { + FileName = OperatingSystem.IsWindows() ? "pwsh.exe" : "pwsh", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + start.ArgumentList.Add("-NoProfile"); + start.ArgumentList.Add("-NonInteractive"); + start.ArgumentList.Add("-Command"); + start.ArgumentList.Add(command); + using Process process = Process.Start(start)!; + string stdout = process.StandardOutput.ReadToEnd(); + string stderr = process.StandardError.ReadToEnd(); + Assert.True(process.WaitForExit(30_000), "PowerShell did not exit."); + Assert.True(process.ExitCode == 0, $"PowerShell failed.\n{stdout}\n{stderr}"); + return stdout.Trim(); + } + + private static JsonElement RunConnectedMetadataGate( + string artifactRoot, + string requestedPreset = "auto") + { + string common = PsQuote(Path.Combine(FindRepoRoot(), "tools", CommonScript)); + string root = PsQuote(artifactRoot); + string preset = PsQuote(requestedPreset); + string packId = PsQuote(requestedPreset == "retail" ? "retail" : "acdream.atmospheric"); + string packVersion = requestedPreset == "retail" ? "$null" : "'1.0.0'"; + string presetId = PsQuote(requestedPreset == "retail" ? "off" : requestedPreset); + int expectedState = requestedPreset == "retail" ? 0 : 2; + string command = $@" +. {common} +$state=[pscustomobject]@{{RequestedPreset={preset};PackId={packId};PackVersion={packVersion};PresetId={presetId};ExpectedState={expectedState};ExpectedSchemaVersion=1}} +$failures=[Collections.Generic.List[string]]::new() +Add-ConnectedRenderPackMetadataFailures -ArtifactDirectory {root} -ScreenshotNames @('checkpoint') -State $state -Failures $failures -Label synthetic +[pscustomobject]@{{Failures=@($failures)}} | ConvertTo-Json -Depth 5 -Compress"; + using JsonDocument document = JsonDocument.Parse(RunPowerShell(command)); + return document.RootElement.Clone(); + } + + private static void AssertAppearsInOrder(string source, params string[] values) + { + int cursor = -1; + foreach (string value in values) + { + int next = source.IndexOf(value, cursor + 1, StringComparison.Ordinal); + Assert.True(next >= 0, $"Missing expected source fragment: {value}"); + Assert.True(next > cursor, $"Out-of-order source fragment: {value}"); + cursor = next; + } + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/Diagnostics/ConnectedWorldSoakRouteContractTests.cs b/tests/AcDream.App.Tests/Diagnostics/ConnectedWorldSoakRouteContractTests.cs index 72050d2c..1d203ded 100644 --- a/tests/AcDream.App.Tests/Diagnostics/ConnectedWorldSoakRouteContractTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/ConnectedWorldSoakRouteContractTests.cs @@ -268,6 +268,115 @@ public sealed class ConnectedWorldSoakRouteContractTests StringComparison.Ordinal); } + [Fact] + public void AtmosphericOfflineGateDefaultsToCappedAndExposesUncappedMeasurement() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "tools", + "run-offline-pixel-gate.ps1")); + + Assert.Contains("[switch]$Uncapped", source, StringComparison.Ordinal); + Assert.Contains( + "$env:ACDREAM_UNCAPPED_RENDER = if ($Uncapped) { '1' } else { $null }", + source, + StringComparison.Ordinal); + Assert.Contains( + "$previousUncappedRender = $env:ACDREAM_UNCAPPED_RENDER", + source, + StringComparison.Ordinal); + Assert.Contains( + "$env:ACDREAM_UNCAPPED_RENDER = $previousUncappedRender", + source, + StringComparison.Ordinal); + Assert.Contains( + "$env:ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER = '1'", + source, + StringComparison.Ordinal); + Assert.Contains( + "$previousExactFramebuffer = $env:ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER", + source, + StringComparison.Ordinal); + Assert.Contains( + "$env:ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER = $previousExactFramebuffer", + source, + StringComparison.Ordinal); + Assert.Contains("-WindowStyle Hidden", source, StringComparison.Ordinal); + Assert.DoesNotContain("-WindowStyle Minimized", source, StringComparison.Ordinal); + Assert.Contains( + "[int]$RequiredRenderPackSamples = 0", + source, + StringComparison.Ordinal); + Assert.Contains( + "$probeCommands.Add('renderpack reset-performance')", + source, + StringComparison.Ordinal); + Assert.Contains( + "if ($RenderPackPreset -ne 'auto')", + source, + StringComparison.Ordinal); + Assert.Contains( + "wait render-pack-samples $RequiredRenderPackSamples $RenderPackSampleTimeoutMs", + source, + StringComparison.Ordinal); + int reset = source.IndexOf( + "$probeCommands.Add('renderpack reset-performance')", + StringComparison.Ordinal); + int wait = source.IndexOf( + "wait render-pack-samples $RequiredRenderPackSamples $RenderPackSampleTimeoutMs", + StringComparison.Ordinal); + int screenshot = source.IndexOf( + "$probeCommands.Add('screenshot world-offline 30000')", + StringComparison.Ordinal); + int closeClient = source.IndexOf( + "$probeCommands.Add('close-client')", + StringComparison.Ordinal); + Assert.True( + reset >= 0 && wait > reset && screenshot > wait + && closeClient > screenshot); + Assert.Contains("$proc.WaitForExit(15000)", source, StringComparison.Ordinal); + Assert.DoesNotContain( + "Get-Process -Name AcDream.App", + source, + StringComparison.Ordinal); + Assert.DoesNotContain(".CloseMainWindow()", source, StringComparison.Ordinal); + } + + [Fact] + public void AtmosphericPreviewLaunchesAcdreamWithDisposableStateAndNoLiveCredentials() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "tools", + "launch-atmospheric-preview.ps1")); + + Assert.Contains("AcDream.App.exe", source, StringComparison.Ordinal); + Assert.Contains("packId = 'acdream.atmospheric'", source, StringComparison.Ordinal); + Assert.Contains("artifacts\\atmospheric-rendering\\visible-", source, StringComparison.Ordinal); + Assert.Contains("$env:ACDREAM_CONFIG_DIR = $config", source, StringComparison.Ordinal); + Assert.Contains("$env:ACDREAM_DATA_DIR = $data", source, StringComparison.Ordinal); + Assert.Contains("$env:ACDREAM_CACHE_DIR = $cache", source, StringComparison.Ordinal); + Assert.Contains( + ".StartsWith('ACDREAM_', [StringComparison]::OrdinalIgnoreCase)", + source, + StringComparison.Ordinal); + Assert.Contains( + "[Environment]::SetEnvironmentVariable($name, $null, 'Process')", + source, + StringComparison.Ordinal); + Assert.Contains( + "[Environment]::SetEnvironmentVariable($name, $prior[$name], 'Process')", + source, + StringComparison.Ordinal); + Assert.Contains( + "$env:ACDREAM_NO_AUDIO = if ($audioEnabled) { $null } else { '1' }", + source, + StringComparison.Ordinal); + Assert.Contains("-RedirectStandardOutput $stdoutLog", source, StringComparison.Ordinal); + Assert.Contains("-RedirectStandardError $stderrLog", source, StringComparison.Ordinal); + Assert.DoesNotContain("-WindowStyle Hidden", source, StringComparison.Ordinal); + } + [Fact] public void StationaryDwellSamplesAfterTheLivenessDeadline() { @@ -317,7 +426,7 @@ public sealed class ConnectedWorldSoakRouteContractTests source, StringComparison.Ordinal); Assert.Contains( - "$sensitive = $_.Name -match '(?i)(PASS|PASSWORD|TOKEN|SECRET|KEY)'", + "$sensitive = $_.Name -match '(?i)(PASS|PASSWORD|TOKEN|SECRET|KEY|USER|ACCOUNT)'", source, StringComparison.Ordinal); Assert.Contains( @@ -337,24 +446,23 @@ public sealed class ConnectedWorldSoakRouteContractTests "tools", "run-connected-r6-soak.ps1")); + string common = File.ReadAllText(Path.Combine( + FindRepoRoot(), "tools", "connected-render-pack-gate-common.ps1")); AssertAppearsInOrder( source, - "$sourceCommit = (& git -C $Repository rev-parse HEAD).Trim()", - "[Diagnostics.FileVersionInfo]::GetVersionInfo($exe).ProductVersion", - "$binaryCommitMatch = [regex]::Match(", - "$commit = if ($null -ne $binaryCommit) { $binaryCommit } else { $sourceCommit }", - "$binaryMatchesSource = $null -ne $binaryCommit -and $binaryCommit -eq $sourceCommit"); - Assert.Contains( - "status --short --untracked-files=no", - source, - StringComparison.Ordinal); + "$binaryIdentity = Get-ConnectedGateBinaryIdentity", + "$sourceCommit = $binaryIdentity.SourceCommit", + "$binaryCommit = $binaryIdentity.BinaryCommit", + "$commit = $binaryCommit"); + Assert.Contains("[Diagnostics.FileVersionInfo]::GetVersionInfo($Executable).ProductVersion", common); + Assert.Contains("status --short --untracked-files=all", common, StringComparison.Ordinal); Assert.Contains("BinaryProductVersion = $binaryProductVersion", source); Assert.Contains("BinaryCommit = $binaryCommit", source); Assert.Contains("BinaryMatchesSource = $binaryMatchesSource", source); Assert.Contains("TrackedSourceStatus = @($sourceStatus)", source); Assert.Contains( - "measured binary commit $binaryCommit differs from checked-out source commit $sourceCommit", - source, + "Measured binary commit $binaryCommit differs from checked-out source commit $sourceCommit", + common, StringComparison.Ordinal); } diff --git a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs index e72f9907..b0046dd8 100644 --- a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using AcDream.App.Diagnostics; using AcDream.App.Rendering; +using AcDream.App.Rendering.Packs; using AcDream.App.Rendering.Residency; using AcDream.App.Rendering.Scene; using AcDream.App.Streaming; @@ -82,6 +83,70 @@ public sealed class WorldLifecycleAutomationControllerTests } } + [Fact] + public void ScreenshotCapture_WritesRenderPackChoiceAndAtmosphereMetadata() + { + string directory = NewDirectory(); + var diagnostics = new RenderPackDiagnosticsSnapshot( + RenderPackActivationState.Active, + "acdream.atmospheric", + "1.0.0", + "high", + "medium", + FailureReason: null, + ActivationGeneration: 7, + RetainedGpuBytes: 64, + TransientGpuBytes: 32, + ImageCount: 5, + BufferCount: 2, + DrawCalls: 12, + DispatchCalls: 4, + ShadowCasterCount: 22, + CascadeDrawCount: 44, + CpuClassificationCalls: 1, + SunElevationDegrees: 14.5, + ActiveDayGroup: 2, + Weather: "Clear", + WeatherIntensity: 0.25, + Outdoor: true, + DirectionalShadowStrength: 0.8, + Passes: []) + { + SharedWorldTransformUsedInstances = 68_395, + }; + var controller = new FrameScreenshotController( + (_, _) => [255, 255, 255, 255], + directory, + renderPackMetadata: () => diagnostics); + Assert.Contains( + "worldTransforms=68395used", + RenderPackDiagnosticsFormatter.Format(diagnostics), + StringComparison.Ordinal); + + try + { + Assert.True(controller.TryRequest("enhanced", out string error), error); + Assert.True(controller.CapturePending(1, 1)); + + using JsonDocument metadata = JsonDocument.Parse( + File.ReadAllText(Path.Combine(directory, "enhanced.metadata.json"))); + JsonElement root = metadata.RootElement; + Assert.Equal(1, root.GetProperty("SchemaVersion").GetInt32()); + JsonElement pack = root.GetProperty("RenderPack"); + Assert.Equal("acdream.atmospheric", pack.GetProperty("PackId").GetString()); + Assert.Equal("medium", pack.GetProperty("EffectiveQuality").GetString()); + Assert.Equal(14.5, pack.GetProperty("SunElevationDegrees").GetDouble()); + Assert.True(pack.GetProperty("Outdoor").GetBoolean()); + Assert.Equal( + 68_395u, + pack.GetProperty("SharedWorldTransformUsedInstances").GetUInt32()); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + [Theory] [InlineData("")] [InlineData("../escape")] @@ -377,6 +442,7 @@ public sealed class WorldLifecycleAutomationControllerTests var screenshots = new FrameScreenshotController( (_, _) => [0, 0, 0, 255], Path.Combine(directory, "screenshots")); + int clientCloseRequests = 0; var controller = new WorldLifecycleAutomationController( () => reveal, () => new RuntimeWorldEnvironmentOwnershipSnapshot( @@ -395,13 +461,18 @@ public sealed class WorldLifecycleAutomationControllerTests () => 3, _ => resources, screenshots, - directory); + directory, + requestClientClose: () => clientCloseRequests++); try { Assert.True(controller.IsWorldReady); Assert.True(controller.IsWorldViewportVisible); Assert.Equal(3, controller.PortalMaterializationCount); + Assert.True( + controller.TryRequestClientClose(out string closeError), + closeError); + Assert.Equal(1, clientCloseRequests); Assert.True(controller.TryRequestCheckpoint( "dungeon", out IRetailUiAutomationCheckpoint? request, @@ -591,6 +662,115 @@ public sealed class WorldLifecycleAutomationControllerTests } } + [Fact] + public void RenderPackPerformanceReset_IsNoOpOnlyAfterFailedToRetail() + { + string directory = NewDirectory(); + bool failedToRetail = true; + int resetCalls = 0; + var controller = new WorldLifecycleAutomationController( + () => default, + () => default, + () => default, + () => 0, + _ => EmptyResources(), + new FrameScreenshotController((_, _) => [], directory), + directory, + getRenderPackPerformanceSampleCount: () => 0, + resetRenderPackPerformance: () => + { + resetCalls++; + return (true, string.Empty); + }, + getRenderPackFailedToRetail: () => failedToRetail); + + try + { + Assert.True(controller.RenderPackFailedToRetail); + Assert.True(controller.TryResetRenderPackPerformance(out string fallbackError)); + Assert.Empty(fallbackError); + Assert.Equal(0, resetCalls); + + failedToRetail = false; + Assert.True(controller.TryResetRenderPackPerformance(out string activeError)); + Assert.Empty(activeError); + Assert.Equal(1, resetCalls); + } + finally + { + controller.Dispose(); + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void TransitionAutomationDelegatesPreserveExactDisableReenableAndResizeOwnership() + { + string directory = NewDirectory(); + var status = new RetailUiAutomationRenderPackStatus( + RetailUiAutomationRenderPackState.Active, + "acdream.atmospheric", + "medium", + ActivationGeneration: 1, + FailureReason: null); + var framebuffer = (Width: 1280, Height: 720); + var calls = new List(); + var controller = new WorldLifecycleAutomationController( + () => default, + () => default, + () => default, + () => 0, + _ => EmptyResources(), + new FrameScreenshotController((_, _) => [], directory), + directory, + getRenderPackStatus: () => status, + selectRenderPack: preset => + { + calls.Add($"select:{preset}"); + return (true, string.Empty); + }, + disableRenderPack: () => + { + calls.Add("disable-exact"); + return (true, string.Empty); + }, + reenableRenderPack: () => + { + calls.Add("reenable-exact"); + return (true, string.Empty); + }, + getFramebufferSize: () => framebuffer, + resizeFramebuffer: (width, height) => + { + calls.Add($"resize:{width}x{height}"); + framebuffer = (width, height); + return (true, string.Empty); + }); + + try + { + Assert.Equal(status, controller.RenderPackStatus); + Assert.True(controller.TrySelectRenderPack("high", out string selectError)); + Assert.Empty(selectError); + Assert.True(controller.TryDisableRenderPack(out string disableError)); + Assert.Empty(disableError); + Assert.True(controller.TryReenableRenderPack(out string reenableError)); + Assert.Empty(reenableError); + Assert.True(controller.TryResizeFramebuffer(1024, 768, out string resizeError)); + Assert.Empty(resizeError); + Assert.Equal(1024, controller.FramebufferWidth); + Assert.Equal(768, controller.FramebufferHeight); + Assert.Equal( + ["select:high", "disable-exact", "reenable-exact", "resize:1024x768"], + calls); + } + finally + { + controller.Dispose(); + Directory.Delete(directory, recursive: true); + } + } + private static WorldLifecycleAutomationController CreateController( string directory, Func capture) => diff --git a/tests/AcDream.App.Tests/Plugins/ExternalRenderPackPackageLifecycleTests.cs b/tests/AcDream.App.Tests/Plugins/ExternalRenderPackPackageLifecycleTests.cs new file mode 100644 index 00000000..799c96f1 --- /dev/null +++ b/tests/AcDream.App.Tests/Plugins/ExternalRenderPackPackageLifecycleTests.cs @@ -0,0 +1,673 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using AcDream.App.Configuration; +using AcDream.App.Plugins; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Packs; +using AcDream.App.Settings; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.Core.Plugins; +using AcDream.Core.Selection; +using AcDream.Platform; +using AcDream.Plugin.Abstractions; +using AcDream.Plugin.Abstractions.Rendering; +using AcDream.Runtime.Session; +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.App.Tests.Plugins; + +/// +/// Crosses the real external-package boundary. The descriptor and asset source +/// originate in a collectible plugin ALC; everything after registration is the +/// graphical host's production catalog/settings/controller path. +/// +public sealed class ExternalRenderPackPackageLifecycleTests +{ + private const string PackageId = "acdream.test.external-render-pack-package"; + private const string PackId = "acdream.test.external-render-pack"; + private static readonly RenderPackActivationExtent Extent = new(1280, 720, 1); + + [Fact] + public void LiveProductionCatalogWithdrawsAtFrameBoundaryAndAcceptsCorrectedReregistration() + { + using var temporary = new TemporaryDirectory(); + RunLiveProductionCatalogLifecycle(temporary); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void RunLiveProductionCatalogLifecycle(TemporaryDirectory temporary) + { + ApplicationPathSet paths = Paths(temporary.Path); + _ = InstallPackage(paths.PluginsDirectory, new Version(1, 0, 0)); + string settingsPath = Path.Combine(paths.ConfigDirectory, "settings.json"); + Directory.CreateDirectory(paths.ConfigDirectory); + var selected = new RenderPackSelectionSettings(PackId, "1.0.0", "low"); + new JsonRuntimeSettingsStorage(settingsPath).SaveDisplay( + DisplaySettings.Default with { RenderPack = selected }); + + using var registry = new BufferedRenderPackRegistry(); + var source = new RenderPackCatalogSource( + registry, + RenderPackHostCapabilities.Conformance); + using var device = new RecordingGpuDevice(); + var factory = new CountingFactory(device); + using var controller = new RenderPackController( + source.Snapshot, + factory, + preparationScheduler: InlineRenderPackPreparationScheduler.Instance, + catalogSource: source); + var settings = new RuntimeSettingsController( + new JsonRuntimeSettingsStorage(settingsPath), + log: static _ => { }); + using var binding = new RenderPackSelectionBinding(settings, controller); + + GraphicalPluginSession first = StartSession( + paths, + Path.Combine(temporary.Path, "live-v1-status.jsonl"), + registry); + Assert.Equal( + RenderPackActivationState.Active, + binding.ApplyAtFrameBoundary(Extent).State); + WeakReference firstAssets = CaptureAssetWeakReference(registry); + WeakReference firstContext = Assert.Single(first.CaptureLoadContextWeakReferences()); + + // No settings write/request accompanies uninstall. The registry event + // alone must retire the active production runtime at the next frame. + first.Dispose(); + RenderPackActivationSnapshot withdrawn = binding.ApplyAtFrameBoundary(Extent); + Assert.Equal(RenderPackActivationState.FailedToRetail, withdrawn.State); + Assert.Contains("was withdrawn", withdrawn.Reason, StringComparison.Ordinal); + Assert.True(settings.Display.RenderPack.IsRetail); + Assert.Null(controller.ActiveRuntime); + Assert.Empty(registry.Snapshot()); + Collect(firstAssets); + Collect(firstContext); + + // The stable persisted identity is deliberately unchanged. A new + // registration generation clears the old quarantine on an explicit + // re-selection; production composition/controller objects stay live. + GraphicalPluginSession corrected = StartSession( + paths, + Path.Combine(temporary.Path, "live-corrected-status.jsonl"), + registry); + long correctedRegistration = CaptureRegistrationId(registry); + Assert.True(correctedRegistration > 1); + settings.SaveDisplay(settings.Display with { RenderPack = selected }); + RenderPackActivationSnapshot recovered = binding.ApplyAtFrameBoundary(Extent); + Assert.Equal(RenderPackActivationState.Active, recovered.State); + Assert.Equal(selected, recovered.Selection); + Assert.Equal(2, factory.BuildCount); + + WeakReference correctedAssets = CaptureAssetWeakReference(registry); + WeakReference correctedContext = Assert.Single( + corrected.CaptureLoadContextWeakReferences()); + corrected.Dispose(); + Assert.Equal( + RenderPackActivationState.FailedToRetail, + binding.ApplyAtFrameBoundary(Extent).State); + binding.Dispose(); + controller.Dispose(); + Collect(correctedAssets); + Collect(correctedContext); + AssertZeroGpuPackResources(device); + } + + [Fact] + public void PackageSelectionUpdateWithdrawalAndPersistenceConvergeWithoutPackResources() + { + using var temporary = new TemporaryDirectory(); + ApplicationPathSet paths = Paths(temporary.Path); + string packageDirectory = InstallPackage(paths.PluginsDirectory, new Version(1, 0, 0)); + string settingsPath = Path.Combine(paths.ConfigDirectory, "settings.json"); + Directory.CreateDirectory(paths.ConfigDirectory); + var persistedV1 = new RenderPackSelectionSettings(PackId, "1.0.0", "low"); + new JsonRuntimeSettingsStorage(settingsPath).SaveDisplay( + DisplaySettings.Default with { RenderPack = persistedV1 }); + + using var registry = new BufferedRenderPackRegistry(); + using var device = new RecordingGpuDevice(); + LifetimeReferences v1 = RunV1Lifecycle( + paths, + temporary.Path, + settingsPath, + persistedV1, + registry, + device); + Collect(v1.Assets); + Collect(v1.Context); + + WritePackageVersion(packageDirectory, new Version(2, 0, 0)); + WriteManifest(packageDirectory, new Version(2, 0, 0)); + new JsonRuntimeSettingsStorage(settingsPath).SaveDisplay( + DisplaySettings.Default with { RenderPack = persistedV1 }); + + LifetimeReferences v2 = RunV2Lifecycle( + paths, + temporary.Path, + settingsPath, + persistedV1, + registry, + device); + Collect(v2.Assets); + Collect(v2.Context); + + Assert.Empty(registry.Snapshot()); + AssertZeroGpuPackResources(device); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static LifetimeReferences RunV1Lifecycle( + ApplicationPathSet paths, + string root, + string settingsPath, + RenderPackSelectionSettings persistedV1, + BufferedRenderPackRegistry registry, + RecordingGpuDevice device) + { + var factory = new CountingFactory(device); + using var controller = Controller(registry, factory); + var settings = new RuntimeSettingsController( + new JsonRuntimeSettingsStorage(settingsPath), + log: static _ => { }); + using var binding = new RenderPackSelectionBinding(settings, controller); + GraphicalPluginSession session = StartSession( + paths, + Path.Combine(root, "v1-status.jsonl"), + registry); + Assert.Equal(1, session.LoadedCount); + AssertRegisteredVersion(registry, new Version(1, 0, 0)); + WeakReference assets = CaptureAssetWeakReference(registry); + + RenderPackActivationSnapshot active = binding.ApplyAtFrameBoundary(Extent); + Assert.Equal(RenderPackActivationState.Active, active.State); + Assert.Equal(persistedV1, active.Selection); + Assert.IsAssignableFrom(controller.ActiveRuntime); + Assert.Equal(1, factory.BuildCount); + AssertZeroGpuPackResources(device); + + WeakReference context = Assert.Single(session.CaptureLoadContextWeakReferences()); + session.Dispose(); + Assert.Empty(registry.Snapshot()); + settings.SaveDisplay(settings.Display with + { + RenderPack = RenderPackSelectionSettings.Retail, + }); + Assert.Equal( + RenderPackActivationState.Retail, + binding.ApplyAtFrameBoundary(Extent).State); + Assert.Null(controller.ActiveRuntime); + + settings.SaveDisplay(settings.Display with { RenderPack = persistedV1 }); + RenderPackActivationSnapshot removed = binding.ApplyAtFrameBoundary(Extent); + Assert.Equal(RenderPackActivationState.FailedToRetail, removed.State); + Assert.Contains("is not installed", removed.Reason, StringComparison.Ordinal); + Assert.True(settings.Display.RenderPack.IsRetail); + Assert.True( + new JsonRuntimeSettingsStorage(settingsPath).LoadDisplay().RenderPack.IsRetail); + Assert.Equal(1, factory.BuildCount); + + controller.Request(persistedV1); + RenderPackActivationSnapshot latched = controller.ApplyAtFrameBoundary(Extent); + Assert.Equal(RenderPackActivationState.FailedToRetail, latched.State); + Assert.Contains( + "failed for the current registration", + latched.Reason, + StringComparison.Ordinal); + Assert.Equal(1, factory.BuildCount); + return new LifetimeReferences(context, assets); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static LifetimeReferences RunV2Lifecycle( + ApplicationPathSet paths, + string root, + string settingsPath, + RenderPackSelectionSettings persistedV1, + BufferedRenderPackRegistry registry, + RecordingGpuDevice device) + { + var factory = new CountingFactory(device); + using var controller = Controller(registry, factory); + GraphicalPluginSession session = StartSession( + paths, + Path.Combine(root, "v2-status.jsonl"), + registry); + Assert.Equal(1, session.LoadedCount); + AssertRegisteredVersion(registry, new Version(2, 0, 0)); + WeakReference assets = CaptureAssetWeakReference(registry); + var settings = new RuntimeSettingsController( + new JsonRuntimeSettingsStorage(settingsPath), + log: static _ => { }); + using var binding = new RenderPackSelectionBinding(settings, controller); + + RenderPackActivationSnapshot stale = binding.ApplyAtFrameBoundary(Extent); + Assert.Equal(RenderPackActivationState.FailedToRetail, stale.State); + Assert.Equal( + "Render pack 'acdream.test.external-render-pack' version 1.0.0 was selected, " + + "but version 2.0.0 is installed.", + stale.Reason); + Assert.True(settings.Display.RenderPack.IsRetail); + Assert.Equal(0, factory.BuildCount); + + controller.Request(persistedV1); + RenderPackActivationSnapshot noRetry = controller.ApplyAtFrameBoundary(Extent); + Assert.Equal(RenderPackActivationState.FailedToRetail, noRetry.State); + Assert.Contains("will not be retried", noRetry.Reason, StringComparison.Ordinal); + Assert.Equal(0, factory.BuildCount); + + var selectedV2 = new RenderPackSelectionSettings(PackId, "2.0.0", "high"); + settings.SaveDisplay(settings.Display with { RenderPack = selectedV2 }); + RenderPackActivationSnapshot updated = binding.ApplyAtFrameBoundary(Extent); + Assert.Equal(RenderPackActivationState.Active, updated.State); + Assert.Equal(selectedV2, updated.Selection); + Assert.Equal("high", controller.ActiveRuntime!.Preset.Id); + Assert.Equal(1, factory.BuildCount); + Assert.Equal( + selectedV2, + new JsonRuntimeSettingsStorage(settingsPath).LoadDisplay().RenderPack); + AssertZeroGpuPackResources(device); + + WeakReference context = Assert.Single(session.CaptureLoadContextWeakReferences()); + session.Dispose(); + Assert.Empty(registry.Snapshot()); + settings.SaveDisplay(settings.Display with + { + RenderPack = RenderPackSelectionSettings.Retail, + }); + Assert.Equal( + RenderPackActivationState.Retail, + binding.ApplyAtFrameBoundary(Extent).State); + Assert.Null(controller.ActiveRuntime); + return new LifetimeReferences(context, assets); + } + + [Fact] + public void MalformedThenFailingPackageCanBeCorrectedWithoutLeakingRegistrationOrAlc() + { + using var temporary = new TemporaryDirectory(); + ApplicationPathSet paths = Paths(temporary.Path); + string packageDirectory = InstallPackage(paths.PluginsDirectory, new Version(1, 0, 0)); + string manifestPath = Path.Combine(packageDirectory, "plugin.json"); + File.WriteAllText(manifestPath, "{ this is not a plugin manifest }"); + using var registry = new BufferedRenderPackRegistry(); + + GraphicalPluginSession malformed = StartSession( + paths, + Path.Combine(temporary.Path, "malformed-status.jsonl"), + registry); + Assert.Equal(0, malformed.LoadedCount); + Assert.Empty(registry.Snapshot()); + Assert.Empty(malformed.CaptureLoadContextWeakReferences()); + JsonElement malformedFailure = Assert.Single(ReadStatuses( + Path.Combine(temporary.Path, "malformed-status.jsonl")), + value => value.GetProperty("e").GetString() == "pluginFailed"); + Assert.Contains( + "invalid start of a property name", + malformedFailure.GetProperty("error").GetString(), + StringComparison.OrdinalIgnoreCase); + malformed.Dispose(); + + WriteManifest(packageDirectory, new Version(1, 0, 0)); + string failureMarker = Path.Combine( + packageDirectory, + "throw-after-render-pack-register"); + File.WriteAllText(failureMarker, string.Empty); + GraphicalPluginSession failing = StartSession( + paths, + Path.Combine(temporary.Path, "failing-status.jsonl"), + registry); + Assert.Equal(0, failing.LoadedCount); + Assert.Empty(registry.Snapshot()); + WeakReference failedContext = Assert.Single( + failing.CaptureLoadContextWeakReferences()); + Assert.Contains( + "failed after publishing its descriptor", + Assert.Single(ReadStatuses( + Path.Combine(temporary.Path, "failing-status.jsonl")), + value => value.GetProperty("e").GetString() == "pluginFailed") + .GetProperty("error").GetString(), + StringComparison.Ordinal); + failing.Dispose(); + Collect(failedContext); + + File.Delete(failureMarker); + string zeroMarker = Path.Combine(packageDirectory, "register-no-render-packs"); + File.WriteAllText(zeroMarker, string.Empty); + GraphicalPluginSession zeroRegistration = StartSession( + paths, + Path.Combine(temporary.Path, "zero-registration-status.jsonl"), + registry); + Assert.Equal(0, zeroRegistration.LoadedCount); + Assert.Empty(registry.Snapshot()); + WeakReference zeroContext = Assert.Single( + zeroRegistration.CaptureLoadContextWeakReferences()); + Assert.Contains( + "registered no packs", + Assert.Single(ReadStatuses( + Path.Combine(temporary.Path, "zero-registration-status.jsonl")), + value => value.GetProperty("e").GetString() == "pluginFailed") + .GetProperty("error").GetString(), + StringComparison.Ordinal); + zeroRegistration.Dispose(); + Collect(zeroContext); + + File.Delete(zeroMarker); + GraphicalPluginSession corrected = StartSession( + paths, + Path.Combine(temporary.Path, "corrected-status.jsonl"), + registry); + Assert.Equal(1, corrected.LoadedCount); + AssertCompatibleRegistration(registry); + WeakReference correctedAssets = CaptureAssetWeakReference(registry); + + WeakReference correctedContext = Assert.Single( + corrected.CaptureLoadContextWeakReferences()); + corrected.Dispose(); + Assert.Empty(registry.Snapshot()); + Collect(correctedAssets); + Collect(correctedContext); + } + + [Theory] + [InlineData( + "AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple", + "must contain exactly one IRenderPackPlugin implementation")] + [InlineData( + "AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal", + "must be public")] + public void InvalidRenderPackEntrypointShapeRollsBackCollectiblePackage( + string fixtureName, + string expectedFailure) + { + using var temporary = new TemporaryDirectory(); + ApplicationPathSet paths = Paths(temporary.Path); + string packageId = "acdream.test.invalid-render-pack"; + string source = FixtureAssemblyPath(fixtureName); + string packageDirectory = Path.Combine(paths.PluginsDirectory, packageId); + Directory.CreateDirectory(packageDirectory); + File.Copy(source, Path.Combine(packageDirectory, Path.GetFileName(source))); + File.WriteAllText( + Path.Combine(packageDirectory, "plugin.json"), + JsonSerializer.Serialize(new + { + id = packageId, + displayName = "Invalid render-pack entry fixture", + version = "1.0.0", + entryDll = Path.GetFileName(source), + apiVersion = 1, + kinds = new[] { "RenderPack" }, + })); + using var registry = new BufferedRenderPackRegistry(); + string statusPath = Path.Combine(temporary.Path, fixtureName + ".jsonl"); + + GraphicalPluginSession session = StartSession( + paths, + statusPath, + registry, + packageId); + + Assert.Equal(0, session.LoadedCount); + Assert.Empty(registry.Snapshot()); + Assert.Contains( + expectedFailure, + Assert.Single(ReadStatuses(statusPath), + value => value.GetProperty("e").GetString() == "pluginFailed") + .GetProperty("error").GetString(), + StringComparison.Ordinal); + WeakReference context = Assert.Single(session.CaptureLoadContextWeakReferences()); + session.Dispose(); + Collect(context); + } + + private static RenderPackController Controller( + BufferedRenderPackRegistry registry, + IRenderPackRuntimeFactory factory) + { + var source = new RenderPackCatalogSource( + registry, + RenderPackHostCapabilities.Conformance); + return new RenderPackController( + source.Snapshot, + factory, + preparationScheduler: InlineRenderPackPreparationScheduler.Instance, + catalogSource: source); + } + + private static GraphicalPluginSession StartSession( + ApplicationPathSet paths, + string statusPath, + BufferedRenderPackRegistry registry, + string packageId = PackageId) + { + var host = new AppPluginHost( + new NullLogger(), + new WorldGameState(), + new WorldEvents(), + new SelectionState(), + new BufferedUiRegistry(), + NoOpAutomationSurface.Instance); + var session = GraphicalPluginSession.Create( + paths, + [packageId], + "external-render-pack-test", + host, + new SessionStatusWriter(statusPath), + registry); + session.Start(); + return session; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void AssertRegisteredVersion( + BufferedRenderPackRegistry registry, + Version expected) + { + BufferedRenderPackRegistration registration = Assert.Single(registry.Snapshot()); + Assert.Equal(PackId, registration.Descriptor.Id); + Assert.Equal(expected, registration.Descriptor.PackVersion); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference CaptureAssetWeakReference( + BufferedRenderPackRegistry registry) => + new(Assert.Single(registry.Snapshot()).Assets); + + [MethodImpl(MethodImplOptions.NoInlining)] + private static long CaptureRegistrationId(BufferedRenderPackRegistry registry) => + Assert.Single(registry.Snapshot()).RegistrationId; + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void AssertCompatibleRegistration(BufferedRenderPackRegistry registry) + { + BufferedRenderPackRegistration registration = Assert.Single(registry.Snapshot()); + Assert.Equal(PackId, registration.Descriptor.Id); + Assert.True(RenderPackCatalog.Build( + registry.Snapshot(), + RenderPackHostCapabilities.Conformance).TryGet( + PackId, + out RenderPackCatalogEntry catalogEntry)); + Assert.True(catalogEntry.IsCompatible, catalogEntry.IncompatibilityReason); + } + + private static ApplicationPathSet Paths(string root) => new( + Path.Combine(root, "config"), + Path.Combine(root, "data"), + Path.Combine(root, "cache"), + LegacyConfigDirectory: null); + + private static string InstallPackage(string root, Version version) + { + string source = FixtureAssemblyPath(); + string packageDirectory = Path.Combine(root, PackageId); + Directory.CreateDirectory(packageDirectory); + File.Copy(source, Path.Combine(packageDirectory, Path.GetFileName(source))); + WritePackageVersion(packageDirectory, version); + WriteManifest(packageDirectory, version); + return packageDirectory; + } + + private static void WritePackageVersion(string directory, Version version) => + File.WriteAllText( + Path.Combine(directory, "render-pack-version.txt"), + version.ToString()); + + private static void WriteManifest(string directory, Version version) => + File.WriteAllText( + Path.Combine(directory, "plugin.json"), + JsonSerializer.Serialize(new + { + id = PackageId, + displayName = "External render-pack package fixture", + version = version.ToString(), + entryDll = Path.GetFileName(FixtureAssemblyPath()), + apiVersion = 1, + kinds = new[] { "RenderPack" }, + })); + + private static string FixtureAssemblyPath() + { + string configuration = new DirectoryInfo(AppContext.BaseDirectory) + .Parent!.Name; + return Path.Combine( + FindRepoRoot(), + "tests", + "AcDream.Plugin.Tests.Fixtures.HostPlugin", + "bin", + configuration, + "net10.0", + "AcDream.Plugin.Tests.Fixtures.HostPlugin.dll"); + } + + private static string FixtureAssemblyPath(string projectName) + { + string configuration = new DirectoryInfo(AppContext.BaseDirectory) + .Parent!.Name; + return Path.Combine( + FindRepoRoot(), + "tests", + projectName, + "bin", + configuration, + "net10.0", + projectName + ".dll"); + } + + private static JsonElement[] ReadStatuses(string path) => + File.ReadAllLines(path) + .Select(static line => JsonDocument.Parse(line).RootElement.Clone()) + .ToArray(); + + private static void AssertZeroGpuPackResources(RecordingGpuDevice device) + { + Assert.Empty(device.CreatedBuffers); + Assert.Empty(device.CreatedPipelines); + Assert.Empty(device.CreatedTextures); + Assert.Empty(device.CreatedRenderTargets); + Assert.Empty(device.CreatedDirectionalDepthTargets); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void Collect(WeakReference context) + { + for (int attempt = 0; attempt < 12 && context.IsAlive; attempt++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + Assert.False(context.IsAlive); + // On Windows the collectible context can become unreachable one GC + // before CoreCLR closes the mapped assembly file. One finalizer/GC + // turn makes the file-lifetime assertion in TemporaryDirectory exact. + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + + private static string FindRepoRoot() + { + string? configured = Environment.GetEnvironmentVariable("ACDREAM_REPO_ROOT"); + if (!string.IsNullOrWhiteSpace(configured) + && File.Exists(Path.Combine(configured, "AcDream.slnx"))) + { + return Path.GetFullPath(configured); + } + foreach (string start in new[] + { + AppContext.BaseDirectory, + Directory.GetCurrentDirectory(), + }) + { + DirectoryInfo? directory = new(start); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + } + throw new InvalidOperationException("Repository root not found."); + } + + private sealed class CountingFactory(IGpuDevice device) : IRenderPackRuntimeFactory + { + private readonly AtmosphericRenderPackRuntimeFactory _inner = new(device); + + internal int BuildCount { get; private set; } + + public IRenderPackRuntime Build( + RenderPackDescriptor descriptor, + ValidatedRenderPackShaderAssets assets, + RenderQualityPreset preset, + IReadOnlyDictionary userSettingOverrides) + { + BuildCount++; + return _inner.Build(descriptor, assets, preset, userSettingOverrides); + } + } + + private sealed class NullLogger : IPluginLogger + { + public void Info(string message) { } + public void Warn(string message) { } + public void Error(string message, Exception? exception = null) { } + } + + private readonly record struct LifetimeReferences( + WeakReference Context, + WeakReference Assets); + + private sealed class TemporaryDirectory : IDisposable + { + internal TemporaryDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"acdream-external-pack-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + internal string Path { get; } + + public void Dispose() + { + for (int attempt = 0; Directory.Exists(Path); attempt++) + { + try + { + Directory.Delete(Path, recursive: true); + return; + } + catch (Exception error) + when (error is IOException or UnauthorizedAccessException + && attempt < 249) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + Thread.Sleep(20); + } + } + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs b/tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs index 623265d9..83a6dc5e 100644 --- a/tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs @@ -1,6 +1,7 @@ using System.Numerics; using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Scene.Arch; +using AcDream.Core.World; namespace AcDream.App.Tests.Rendering; @@ -661,6 +662,429 @@ public sealed class ArchRenderSceneTests scene.OpenQuery().IndexRevision > registeredRevision); } + [Fact] + public void DirectionalShadowTopologyRevision_IgnoresDynamicPoseButTracksEligibilityGeometryAppearanceAndCasterIdentity() + { + RenderSceneGeneration generation = Generation(19); + using var scene = new ArchRenderScene(generation); + Matrix4x4 firstPart = Matrix4x4.CreateTranslation(1f, 2f, 3f); + RenderProjectionRecord original = Record( + 19, + 1, + RenderProjectionClass.LiveDynamicRoot) with + { + Source = new RenderSourceMetadata( + LocalEntityId: 19, + ServerGuid: 19, + SourceId: 19, + ParentCellId: 0, + EffectCellId: 0, + BuildingShellAnchorCellId: 0, + TransformFingerprint: new RenderSceneHash128(1, 1), + GeometryFingerprint: new RenderSceneHash128(2, 2), + AppearanceFingerprint: new RenderSceneHash128(3, 3), + DirectionalShadowTopologyFingerprint: + new RenderSceneHash128(4, 4)), + EntityPayload = new RenderEntityPayload( + [new MeshRef(0x01000001, firstPart)], + PaletteOverride: null, + IsBuildingShell: false), + }; + scene.Apply([RenderProjectionDelta.Register(generation, 1, original)]); + ulong registered = + scene.OpenQuery().DirectionalShadowTopologyRevision; + + Matrix4x4 movedRoot = Matrix4x4.CreateTranslation(20f, 21f, 22f); + Matrix4x4 movedPart = Matrix4x4.CreateTranslation(23f, 24f, 25f); + RenderProjectionRecord poseOnly = original with + { + Transform = new RenderTransform(movedRoot), + MeshSet = original.MeshSet with + { + Handle = Asset(999), + }, + Source = original.Source with + { + TransformFingerprint = new RenderSceneHash128(10, 10), + GeometryFingerprint = new RenderSceneHash128(20, 20), + }, + EntityPayload = original.EntityPayload with + { + MeshRefs = [new MeshRef(0x01000001, movedPart)], + }, + }; + scene.Apply( + [ + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.UpdateTransform, + generation, + 2, + poseOnly), + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.UpdateAppearance, + generation, + 3, + poseOnly), + ]); + Assert.Equal( + registered, + scene.OpenQuery().DirectionalShadowTopologyRevision); + + RenderProjectionRecord hidden = poseOnly with + { + Flags = poseOnly.Flags & ~RenderProjectionFlags.Draw, + }; + scene.Apply( + [ + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.UpdateFlags, + generation, + 4, + hidden), + ]); + ulong eligibilityChanged = + scene.OpenQuery().DirectionalShadowTopologyRevision; + Assert.True(eligibilityChanged > registered); + + RenderProjectionRecord geometryChanged = hidden with + { + Source = hidden.Source with + { + DirectionalShadowTopologyFingerprint = + new RenderSceneHash128(5, 5), + }, + EntityPayload = hidden.EntityPayload with + { + MeshRefs = + [ + new MeshRef(0x01000002, movedPart) + { + SurfaceOverrides = new Dictionary + { + [0x08000001] = 0x05000001, + }, + }, + ], + }, + }; + scene.Apply( + [ + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.UpdateAppearance, + generation, + 5, + geometryChanged), + ]); + ulong geometryRevision = + scene.OpenQuery().DirectionalShadowTopologyRevision; + Assert.True(geometryRevision > eligibilityChanged); + + var palette = new PaletteOverride( + 0x04000001, + [new PaletteOverride.SubPaletteRange(0x04000002, 1, 2)]); + RenderProjectionRecord appearanceChanged = geometryChanged with + { + Material = geometryChanged.Material with { PaletteKey = 1234 }, + Source = geometryChanged.Source with + { + AppearanceFingerprint = new RenderSceneHash128(6, 6), + }, + EntityPayload = geometryChanged.EntityPayload with + { + PaletteOverride = palette, + }, + }; + scene.Apply( + [ + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.UpdateAppearance, + generation, + 6, + appearanceChanged), + ]); + ulong appearanceRevision = + scene.OpenQuery().DirectionalShadowTopologyRevision; + Assert.True(appearanceRevision > geometryRevision); + + RenderProjectionRecord identityChanged = appearanceChanged with + { + EntityPayload = appearanceChanged.EntityPayload with + { + CasterIdentity = RenderCasterIdentityKind.RemotePlayer, + }, + }; + scene.Apply( + [ + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.UpdateAppearance, + generation, + 7, + identityChanged), + ]); + Assert.True( + scene.OpenQuery().DirectionalShadowTopologyRevision + > appearanceRevision); + } + + [Fact] + public void DirectionalShadowTopologyRevision_TracksRareStaticTransformChanges() + { + RenderSceneGeneration generation = Generation(20); + using var scene = new ArchRenderScene(generation); + RenderProjectionRecord original = Record( + 20, + 1, + RenderProjectionClass.OutdoorStatic); + scene.Apply([RenderProjectionDelta.Register(generation, 1, original)]); + ulong registered = + scene.OpenQuery().DirectionalShadowTopologyRevision; + RenderProjectionRecord moved = original with + { + Transform = new RenderTransform( + Matrix4x4.CreateTranslation(100f, 101f, 102f)), + }; + + scene.Apply( + [ + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.UpdateTransform, + generation, + 2, + moved), + ]); + + Assert.True( + scene.OpenQuery().DirectionalShadowTopologyRevision > registered); + } + + [Fact] + public void DirectionalShadowTransformJournal_TracksRootPartAndDynamicSync_IndependentlyOfDirtyDrain() + { + RenderSceneGeneration generation = Generation(21); + using var scene = new ArchRenderScene(generation); + Matrix4x4 firstPart = Matrix4x4.CreateTranslation(1f, 2f, 3f); + RenderProjectionRecord original = Record( + 21, + 1, + RenderProjectionClass.LiveDynamicRoot) with + { + EntityPayload = new RenderEntityPayload( + [new MeshRef(0x01000021, firstPart)], + PaletteOverride: null, + IsBuildingShell: false), + }; + scene.Apply([RenderProjectionDelta.Register(generation, 1, original)]); + RenderSceneQuery query = scene.OpenQuery(); + ulong initialRevision = query.DirectionalShadowTransformRevision; + + Matrix4x4 movedRoot = Matrix4x4.CreateTranslation( + BitConverter.Int32BitsToSingle(0x41234567), + 4f, + 5f); + RenderProjectionRecord moved = original with + { + Transform = new RenderTransform(movedRoot), + PreviousTransform = new PreviousRenderTransform( + original.Transform.LocalToWorld), + }; + scene.Apply( + [ + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.UpdateTransform, + generation, + 2, + moved), + ]); + Matrix4x4 movedPart = Matrix4x4.CreateTranslation( + 6f, + BitConverter.Int32BitsToSingle(0x40ABCDEF), + 8f); + RenderProjectionRecord posed = moved with + { + EntityPayload = moved.EntityPayload with + { + MeshRefs = [new MeshRef(0x01000021, movedPart)], + }, + }; + scene.Apply( + [ + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.UpdateAppearance, + generation, + 3, + posed), + ]); + scene.ClearDirty(); + var changes = new DirectionalShadowTransformSnapshot[3]; + DirectionalShadowTransformChanges firstChanges = + query.CopyDirectionalShadowTransformChanges( + initialRevision, + changes); + + Assert.False(firstChanges.RequiresFullRefresh); + Assert.Equal(2, firstChanges.Count); + Assert.Equal(1, firstChanges.UpdateTransformCount); + Assert.Equal(1, firstChanges.UpdateAppearanceCount); + Assert.Equal(0, firstChanges.DynamicSynchronizationCount); + Assert.Equal(2, firstChanges.LiveDynamicRootCount); + Assert.Equal(original.Id, changes[0].Id); + Assert.Equal(original.Id, changes[1].Id); + + var synchronized = new DynamicProjectionUpdate( + original.Id, + original.OwnerIncarnation, + new RenderTransform(Matrix4x4.CreateTranslation(9f, 10f, 11f)), + posed.Bounds); + scene.SynchronizeDynamicSources( + new DynamicProjectionSyncInput(generation, [synchronized])); + DirectionalShadowTransformChanges syncChanges = + query.CopyDirectionalShadowTransformChanges( + firstChanges.LatestRevision, + changes); + Assert.False(syncChanges.RequiresFullRefresh); + Assert.Equal(1, syncChanges.Count); + Assert.Equal(1, syncChanges.DynamicSynchronizationCount); + Assert.Equal(1, syncChanges.LiveDynamicRootCount); + Assert.Equal(original.Id, changes[0].Id); + + RenderProjectionRecord flagsOnly = posed with + { + Flags = posed.Flags ^ RenderProjectionFlags.Selectable, + }; + scene.Apply( + [ + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.UpdateFlags, + generation, + 4, + flagsOnly), + ]); + Assert.Equal( + syncChanges.LatestRevision, + query.DirectionalShadowTransformRevision); + } + + [Fact] + public void DirectionalShadowTransformJournal_OverflowAndGenerationResetFailSafe() + { + RenderSceneGeneration generation = Generation(22); + using var scene = new ArchRenderScene(generation); + RenderProjectionRecord original = Record( + 22, + 1, + RenderProjectionClass.LiveDynamicRoot); + scene.Apply([RenderProjectionDelta.Register(generation, 1, original)]); + RenderSceneQuery oldQuery = scene.OpenQuery(); + ulong initialRevision = oldQuery.DirectionalShadowTransformRevision; + for (int index = 0; + index < DirectionalShadowTransformChangeJournal.Capacity + 1; + index++) + { + var update = new DynamicProjectionUpdate( + original.Id, + original.OwnerIncarnation, + new RenderTransform(Matrix4x4.CreateTranslation(index + 1, 0f, 0f)), + original.Bounds); + scene.SynchronizeDynamicSources( + new DynamicProjectionSyncInput(generation, [update])); + } + + var changes = new DirectionalShadowTransformSnapshot[ + DirectionalShadowTransformChangeJournal.Capacity]; + DirectionalShadowTransformChanges overflow = + oldQuery.CopyDirectionalShadowTransformChanges( + initialRevision, + changes); + Assert.True(overflow.RequiresFullRefresh); + Assert.Equal(0, overflow.Count); + + RenderSceneGeneration replacement = Generation(23); + scene.Clear(replacement); + Assert.Throws( + () => _ = oldQuery.DirectionalShadowTransformRevision); + Assert.Equal(1ul, scene.OpenQuery().DirectionalShadowTransformRevision); + } + + [Fact] + public void DirectionalShadowTransformJournal_IgnoresIdenticalPoseAndBoundsOnlySynchronization() + { + RenderSceneGeneration generation = Generation(24); + using var scene = new ArchRenderScene(generation); + var meshes = new List + { + new(0x01000024, Matrix4x4.CreateTranslation(1f, 2f, 3f)), + }; + RenderProjectionRecord original = Record( + 24, + 1, + RenderProjectionClass.LiveDynamicRoot) with + { + EntityPayload = new RenderEntityPayload( + meshes, + PaletteOverride: null, + IsBuildingShell: false), + }; + scene.Apply([RenderProjectionDelta.Register(generation, 1, original)]); + RenderSceneQuery query = scene.OpenQuery(); + ulong initial = query.DirectionalShadowTransformRevision; + + scene.Apply( + [ + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.UpdateTransform, + generation, + 2, + original), + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.UpdateAppearance, + generation, + 3, + original), + ]); + var boundsOnly = new DynamicProjectionUpdate( + original.Id, + original.OwnerIncarnation, + original.Transform, + new RenderWorldBounds(Vector3.One, new Vector3(2f))); + scene.SynchronizeDynamicSources( + new DynamicProjectionSyncInput(generation, [boundsOnly])); + Assert.Equal(initial, query.DirectionalShadowTransformRevision); + + Matrix4x4 changedPart = Matrix4x4.CreateTranslation(4f, 5f, 6f); + meshes[0] = new MeshRef(0x01000024, changedPart); + scene.Apply( + [ + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.UpdateAppearance, + generation, + 4, + original), + ]); + var changes = new DirectionalShadowTransformSnapshot[1]; + DirectionalShadowTransformChanges changed = + query.CopyDirectionalShadowTransformChanges(initial, changes); + Assert.Equal(1, changed.Count); + Assert.Equal(0, changed.UpdateTransformCount); + Assert.Equal(1, changed.UpdateAppearanceCount); + Assert.Equal(0, changed.DynamicSynchronizationCount); + Assert.Equal(1, changed.LiveDynamicRootCount); + Assert.Equal(original.Id, changes[0].Id); + Assert.Equal( + BitConverter.SingleToInt32Bits(changedPart.M41), + BitConverter.SingleToInt32Bits( + changes[0].EntityPayload.MeshRefs[0].PartTransform.M41)); + + scene.Apply( + [ + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.UpdateAppearance, + generation, + 5, + original), + ]); + Assert.Equal(changed.LatestRevision, query.DirectionalShadowTransformRevision); + } + private static RenderProjectionRecord Record( ulong id, ulong incarnation, diff --git a/tests/AcDream.App.Tests/Rendering/DirectionalShadowCascadeFitterTests.cs b/tests/AcDream.App.Tests/Rendering/DirectionalShadowCascadeFitterTests.cs new file mode 100644 index 00000000..481909c9 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/DirectionalShadowCascadeFitterTests.cs @@ -0,0 +1,222 @@ +using System.Numerics; +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class DirectionalShadowCascadeFitterTests +{ + [Theory] + [InlineData(DirectionalShadowPreset.Low, 2, 72f)] + [InlineData(DirectionalShadowPreset.Medium, 3, 144f)] + [InlineData(DirectionalShadowPreset.High, 4, 240f)] + internal void Fit_UsesPracticalIncreasingSplitsAndExactPresetReach( + DirectionalShadowPreset preset, + int expectedCount, + float expectedReach) + { + DirectionalShadowQuality quality = DirectionalShadowQuality.For(preset); + DirectionalShadowCascadeFitInput input = CameraInput( + Vector3.Zero, + quality); + Span cascades = + stackalloc DirectionalShadowCascade[4]; + + int count = DirectionalShadowCascadeFitter.Fit(in input, cascades); + + Assert.Equal(expectedCount, count); + float previous = input.CameraNearMeters; + for (int i = 0; i < count; i++) + { + Assert.Equal(previous, cascades[i].SplitNearMeters); + Assert.True(cascades[i].SplitFarMeters > previous); + Assert.True(cascades[i].TexelWorldSize > 0f); + Assert.True(float.IsFinite(cascades[i].WorldToShadowClip.M11)); + previous = cascades[i].SplitFarMeters; + } + Assert.Equal(expectedReach, cascades[count - 1].SplitFarMeters, 3); + } + + [Fact] + public void TexelStabilization_SubTexelCameraTranslationKeepsSnappedCenter() + { + DirectionalShadowQuality quality = DirectionalShadowQuality.For( + DirectionalShadowPreset.Medium); + DirectionalShadowCascadeFitInput firstInput = CameraInput( + new Vector3(100f, 200f, 30f), + quality); + Span first = + stackalloc DirectionalShadowCascade[4]; + DirectionalShadowCascadeFitter.Fit(in firstInput, first); + + // Translation along the light-space X axis by less than half a map + // texel must not move the stabilized projection centre. + Vector3 light = Vector3.Normalize(firstInput.SurfaceToLightDirection); + Vector3 lightX = Vector3.Normalize(Vector3.Cross( + DirectionalShadowCascadeFitter.StableLightUp(light), + light)); + Vector3 movement = lightX * (first[0].TexelWorldSize * 0.2f); + DirectionalShadowCascadeFitInput secondInput = CameraInput( + new Vector3(100f, 200f, 30f) + movement, + quality); + Span second = + stackalloc DirectionalShadowCascade[4]; + DirectionalShadowCascadeFitter.Fit(in secondInput, second); + + Assert.Equal( + first[0].StabilizedLightSpaceCenter.X, + second[0].StabilizedLightSpaceCenter.X); + Assert.Equal( + first[0].StabilizedLightSpaceCenter.Y, + second[0].StabilizedLightSpaceCenter.Y); + Assert.Equal(first[0].HalfExtentMeters, second[0].HalfExtentMeters); + } + + [Fact] + public void StableLightUp_DoesNotRotateAtTheFormerHighLightThreshold() + { + Vector3 below = Vector3.Normalize(new Vector3(0.3125f, 0.02f, 0.9498f)); + Vector3 above = Vector3.Normalize(new Vector3(0.3110f, 0.02f, 0.9503f)); + + Vector3 belowUp = DirectionalShadowCascadeFitter.StableLightUp(below); + Vector3 aboveUp = DirectionalShadowCascadeFitter.StableLightUp(above); + + Assert.InRange(MathF.Abs(Vector3.Dot(below, belowUp)), 0f, 1e-5f); + Assert.InRange(MathF.Abs(Vector3.Dot(above, aboveUp)), 0f, 1e-5f); + Assert.True(Vector3.Dot(belowUp, aboveUp) > 0.999f); + } + + [Fact] + public void StableLightUp_TrueZenithIsFiniteAndOrthogonal() + { + Vector3 up = DirectionalShadowCascadeFitter.StableLightUp(Vector3.UnitZ); + + Assert.True(float.IsFinite(up.X) && float.IsFinite(up.Y) && float.IsFinite(up.Z)); + Assert.Equal(1f, up.Length(), 5); + Assert.InRange(MathF.Abs(Vector3.Dot(Vector3.UnitZ, up)), 0f, 1e-5f); + } + + [Fact] + public void StableLightUp_RemainsContinuousThroughCelestialZenith() + { + Vector3 beforeZenith = Vector3.Normalize(new Vector3(0.001f, 0.002f, 1f)); + Vector3 zenith = Vector3.UnitZ; + Vector3 afterZenith = Vector3.Normalize(new Vector3(-0.001f, -0.002f, 1f)); + + Vector3 beforeUp = DirectionalShadowCascadeFitter.StableLightUp(beforeZenith); + Vector3 zenithUp = DirectionalShadowCascadeFitter.StableLightUp(zenith); + Vector3 afterUp = DirectionalShadowCascadeFitter.StableLightUp(afterZenith); + + Assert.True(Vector3.Dot(beforeUp, zenithUp) > 0.99999f); + Assert.True(Vector3.Dot(zenithUp, afterUp) > 0.99999f); + Assert.InRange(MathF.Abs(Vector3.Dot(beforeZenith, beforeUp)), 0f, 1e-5f); + Assert.InRange(MathF.Abs(Vector3.Dot(afterZenith, afterUp)), 0f, 1e-5f); + } + + [Fact] + public void ClipDensityRatio_MatchesCascadeTexelFootprintRatio() + { + DirectionalShadowCascadeFitInput input = CameraInput( + new Vector3(40f, -15f, 8f), + DirectionalShadowQuality.For(DirectionalShadowPreset.High)); + Span cascades = + stackalloc DirectionalShadowCascade[4]; + int count = DirectionalShadowCascadeFitter.Fit(in input, cascades); + + float nearDensity = ClipXyDensity(cascades[0].WorldToShadowClip); + float farDensity = ClipXyDensity(cascades[count - 1].WorldToShadowClip); + float shaderScale = farDensity / nearDensity; + float expectedScale = cascades[0].TexelWorldSize + / cascades[count - 1].TexelWorldSize; + + Assert.Equal(expectedScale, shaderScale, 4); + Assert.InRange(shaderScale, 0f, 0.999f); + } + + [Fact] + public void Fit_DoesNotAllocateOrInvokeSceneVisibility() + { + DirectionalShadowCascadeFitInput input = CameraInput( + Vector3.Zero, + DirectionalShadowQuality.For(DirectionalShadowPreset.High)); + Span cascades = + stackalloc DirectionalShadowCascade[4]; + + // Cross the tiered-JIT promotion threshold before taking the thread's + // allocation counter; measuring immediately after one call makes the + // runtime's compilation bookkeeping look like renderer allocation. + for (int i = 0; i < 128; i++) + DirectionalShadowCascadeFitter.Fit(in input, cascades); + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 100; i++) + DirectionalShadowCascadeFitter.Fit(in input, cascades); + long after = GC.GetAllocatedBytesForCurrentThread(); + + Assert.Equal(0, after - before); + } + + [Fact] + public void ResidentWindowClampsOnlyTheFinalCascadeReach() + { + DirectionalShadowQuality quality = DirectionalShadowQuality.For( + DirectionalShadowPreset.High); + DirectionalShadowCascadeFitInput input = CameraInput( + Vector3.Zero, + quality) with + { + ResidentMaximumReachMeters = 96f, + }; + Span cascades = + stackalloc DirectionalShadowCascade[4]; + + int count = DirectionalShadowCascadeFitter.Fit(in input, cascades); + + Assert.Equal(quality.CascadeCount, count); + Assert.Equal(96f, cascades[count - 1].SplitFarMeters, 3); + Assert.All( + cascades[..count].ToArray(), + cascade => Assert.InRange(cascade.SplitFarMeters, 0f, 96f)); + } + + [Fact] + public void UnavailableResidentWindowDisablesFittingWithoutAllocating() + { + DirectionalShadowCascadeFitInput input = CameraInput( + Vector3.Zero, + DirectionalShadowQuality.For(DirectionalShadowPreset.High)) with + { + ResidentMaximumReachMeters = 0f, + }; + Span cascades = + stackalloc DirectionalShadowCascade[4]; + + Assert.Equal(0, DirectionalShadowCascadeFitter.Fit(in input, cascades)); + } + + private static DirectionalShadowCascadeFitInput CameraInput( + Vector3 position, + DirectionalShadowQuality quality) + { + Vector3 target = position + Vector3.Normalize(new Vector3(1f, 2f, -0.2f)); + Matrix4x4 view = Matrix4x4.CreateLookAt(position, target, Vector3.UnitZ); + Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView( + 70f * MathF.PI / 180f, + 16f / 9f, + 0.1f, + 5000f); + return new DirectionalShadowCascadeFitInput( + view, + projection, + Vector3.Normalize(new Vector3(0.4f, 0.7f, 0.55f)), + quality); + } + + private static float ClipXyDensity(Matrix4x4 matrix) + { + // System.Numerics row-vector storage is read as the transposed + // column-major matrix in GLSL. These are the same two clip gradients + // evaluated by acdreamShadowBiasScale. + float x = new Vector3(matrix.M11, matrix.M21, matrix.M31).Length(); + float y = new Vector3(matrix.M12, matrix.M22, matrix.M32).Length(); + return 0.5f * (x + y); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/DirectionalShadowCasterFrameTests.cs b/tests/AcDream.App.Tests/Rendering/DirectionalShadowCasterFrameTests.cs new file mode 100644 index 00000000..1156438c --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/DirectionalShadowCasterFrameTests.cs @@ -0,0 +1,896 @@ +using System.Numerics; +using AcDream.App.Rendering.Scene; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Rendering; + +public sealed class DirectionalShadowCasterFrameTests +{ + [Fact] + public void Build_IncludesEveryHeadlineProjectionClassAndExcludesTrueTransparency() + { + RenderProjectionRecord[] statics = + [ + Record(1, RenderProjectionClass.OutdoorStatic), + Record(2, RenderProjectionClass.OutdoorStatic, building: true), + Record(3, RenderProjectionClass.ActiveAnimatedStatic), + Record(4, RenderProjectionClass.OutdoorStatic, + extraFlags: RenderProjectionFlags.Translucent), + Record(5, RenderProjectionClass.OutdoorStatic, parentCell: 0x12340101), + ]; + RenderProjectionRecord[] dynamics = + [ + Record(6, RenderProjectionClass.LiveDynamicRoot), + Record(7, RenderProjectionClass.EquippedChild), + ]; + var source = new QuerySource(statics, dynamics); + var frame = new DirectionalShadowCasterFrame(); + + frame.Build(new RenderSceneQuery(source, Generation)); + + DirectionalShadowCasterKind[] kinds = frame.Casters + .ToArray() + .Select(static value => value.Kind) + .ToArray(); + Assert.Contains(DirectionalShadowCasterKind.OutdoorStatic, kinds); + Assert.Contains(DirectionalShadowCasterKind.Building, kinds); + Assert.Contains(DirectionalShadowCasterKind.AnimatedStatic, kinds); + Assert.Contains(DirectionalShadowCasterKind.LiveDynamic, kinds); + Assert.Contains(DirectionalShadowCasterKind.EquippedChild, kinds); + Assert.Equal(5, frame.Casters.Length); + Assert.Equal(1, frame.Stats.RejectedTransparent); + Assert.Equal(1, frame.Stats.RejectedIndoor); + Assert.True(frame.Casters[2].UsesCurrentAnimatedTransforms); + } + + [Fact] + public void Build_ReportsEveryAuthoritativeCasterClassAndPreservesCountsOnStableFrame() + { + RenderProjectionRecord[] statics = + [ + Record(101, RenderProjectionClass.OutdoorStatic), + Record(102, RenderProjectionClass.OutdoorStatic, building: true), + Record(103, RenderProjectionClass.ActiveAnimatedStatic), + ]; + RenderProjectionRecord[] dynamics = + [ + Record(104, RenderProjectionClass.LiveDynamicRoot, + casterIdentity: RenderCasterIdentityKind.LocalPlayer), + Record(105, RenderProjectionClass.LiveDynamicRoot, + casterIdentity: RenderCasterIdentityKind.RemotePlayer), + Record(106, RenderProjectionClass.LiveDynamicRoot, + casterIdentity: RenderCasterIdentityKind.NonPlayerCreature), + Record(107, RenderProjectionClass.LiveDynamicRoot, + casterIdentity: RenderCasterIdentityKind.OtherLiveDynamic), + Record(108, RenderProjectionClass.EquippedChild, + casterIdentity: RenderCasterIdentityKind.EquippedChild), + ]; + var source = new QuerySource(statics, dynamics); + var frame = new DirectionalShadowCasterFrame(); + + frame.Build(new RenderSceneQuery(source, Generation)); + DirectionalShadowCasterClassDiagnostics first = frame.Stats.CasterClasses; + + Assert.Equal(0, first.TerrainCommands); + Assert.Equal(1, first.OutdoorStatics); + Assert.Equal(1, first.Buildings); + Assert.Equal(1, first.AnimatedStatics); + Assert.Equal(1, first.LocalPlayers); + Assert.Equal(1, first.RemotePlayers); + Assert.Equal(1, first.NonPlayerCreatures); + Assert.Equal(1, first.OtherLiveDynamics); + Assert.Equal(1, first.EquippedChildren); + + frame.Build(new RenderSceneQuery(source, Generation)); + + Assert.Equal(first, frame.Stats.CasterClasses); + Assert.False(frame.Stats.TopologyRebuilt); + Assert.Equal(0, frame.Stats.Classifications); + } + + [Fact] + public void Build_ReadsOnlyTwoResidentIndicesOnce_NoPViewCellOrCascadeRecull() + { + var source = new QuerySource( + [Record(20, RenderProjectionClass.OutdoorStatic)], + [Record(21, RenderProjectionClass.LiveDynamicRoot)]); + var frame = new DirectionalShadowCasterFrame(); + + frame.Build(new RenderSceneQuery(source, Generation)); + + Assert.Equal(1, source.IndexCountReads); + Assert.Equal(2, source.IndexCopies); + Assert.Equal( + [RenderSceneIndex.OutdoorStatic, RenderSceneIndex.OutdoorDynamic], + source.CopiedIndices); + Assert.Equal(0, source.CellQueries); + Assert.Equal(2, frame.Stats.IndexCopies); + } + + [Fact] + public void UnchangedSecondFrame_ReusesSortedTopologyWithoutIndexCopiesOrClassification() + { + var source = new QuerySource( + [ + Record(30, RenderProjectionClass.OutdoorStatic, sortKey: 30), + Record(31, RenderProjectionClass.ActiveAnimatedStatic, sortKey: 10), + ], + [Record(32, RenderProjectionClass.LiveDynamicRoot, sortKey: 20)]); + var frame = new DirectionalShadowCasterFrame(); + RenderSceneQuery query = new(source, Generation); + + frame.Build(in query); + long retained = frame.RetainedScratchBytes; + ulong[] firstOrder = frame.Casters + .ToArray() + .Select(static value => value.Projection.Id.RawValue) + .ToArray(); + int indexReads = source.IndexCountReads; + int indexCopies = source.IndexCopies; + frame.Build(in query); + + Assert.Equal([31ul, 32ul, 30ul], firstOrder); + Assert.Equal([0, 1], frame.RefreshCasterSlots.ToArray()); + Assert.Equal(retained, frame.RetainedScratchBytes); + Assert.Equal(1ul, frame.BuildSequence); + Assert.Equal(indexReads, source.IndexCountReads); + Assert.Equal(indexCopies, source.IndexCopies); + Assert.False(frame.Stats.TopologyRebuilt); + Assert.Equal(0, frame.Stats.IndexCopies); + Assert.Equal(0, frame.Stats.Classifications); + Assert.Equal(0, frame.Stats.DynamicTransformRefreshes); + Assert.Empty(frame.ChangedCasterPoses.ToArray()); + } + + [Fact] + public void TopologyRebuild_ReplacesRefreshSlotsAndRetainedAccountingIncludesThem() + { + RenderProjectionRecord[] statics = + [ + Record(33, RenderProjectionClass.OutdoorStatic), + Record(34, RenderProjectionClass.OutdoorStatic), + Record(35, RenderProjectionClass.OutdoorStatic), + Record(36, RenderProjectionClass.OutdoorStatic), + ]; + var source = new QuerySource(statics, []); + var frame = new DirectionalShadowCasterFrame(); + RenderSceneQuery query = new(source, Generation); + + frame.Build(in query); + long staticRetainedBytes = frame.RetainedScratchBytes; + + Assert.Empty(frame.RefreshCasterSlots.ToArray()); + + source.ReplaceStatics( + [ + Record(33, RenderProjectionClass.ActiveAnimatedStatic), + Record(34, RenderProjectionClass.ActiveAnimatedStatic), + Record(35, RenderProjectionClass.ActiveAnimatedStatic), + Record(36, RenderProjectionClass.ActiveAnimatedStatic), + ], + topologyChanged: true); + frame.Build(in query); + long animatedRetainedBytes = frame.RetainedScratchBytes; + + Assert.True(animatedRetainedBytes > staticRetainedBytes); + Assert.Equal([0, 1, 2, 3], frame.RefreshCasterSlots.ToArray()); + + source.ReplaceStatics(statics, topologyChanged: true); + frame.Build(in query); + int projectionReads = source.ProjectionReads; + frame.Build(in query); + + Assert.Empty(frame.RefreshCasterSlots.ToArray()); + Assert.Equal(animatedRetainedBytes, frame.RetainedScratchBytes); + Assert.Equal(projectionReads, source.ProjectionReads); + Assert.Equal(0, frame.Stats.DynamicTransformRefreshes); + Assert.False(frame.Stats.TopologyRebuilt); + } + + [Fact] + public void StableTopology_EmitsExactDynamicPoseWithoutOverwritingTopologyCaster() + { + RenderProjectionRecord original = + Record(40, RenderProjectionClass.LiveDynamicRoot); + var source = new QuerySource([], [original]); + var frame = new DirectionalShadowCasterFrame(); + RenderSceneQuery query = new(source, Generation); + frame.Build(in query); + + float rootX = BitConverter.Int32BitsToSingle(0x41234567); + float partY = BitConverter.Int32BitsToSingle(0x40ABCDEF); + Matrix4x4 root = Matrix4x4.CreateTranslation(rootX, 2f, 3f); + Matrix4x4 part = Matrix4x4.CreateTranslation(4f, partY, 6f); + RenderProjectionRecord moved = original with + { + Transform = new RenderTransform(root), + EntityPayload = original.EntityPayload with + { + MeshRefs = [new MeshRef(40, part)], + }, + }; + source.ReplaceDynamics([moved], topologyChanged: false); + + frame.Build(in query); + + RenderProjectionRecord retained = frame.Casters[0].Projection; + Assert.Equal(original.Transform, retained.Transform); + Assert.Same( + original.EntityPayload.MeshRefs, + retained.EntityPayload.MeshRefs); + DirectionalShadowChangedPose changed = + Assert.Single(frame.ChangedCasterPoses.ToArray()); + Assert.Equal(0, changed.CasterIndex); + Assert.Equal( + BitConverter.SingleToInt32Bits(rootX), + BitConverter.SingleToInt32Bits( + changed.Snapshot.Transform.LocalToWorld.M41)); + Assert.Equal( + BitConverter.SingleToInt32Bits(partY), + BitConverter.SingleToInt32Bits( + changed.Snapshot.EntityPayload.MeshRefs[0].PartTransform.M42)); + Assert.Equal(1ul, frame.BuildSequence); + Assert.Equal(0, source.ProjectionReads); + } + + [Fact] + public void StableTopology_RefreshesOnlyChangedCasterSlots() + { + RenderProjectionRecord first = + Record(41, RenderProjectionClass.LiveDynamicRoot); + RenderProjectionRecord second = + Record(42, RenderProjectionClass.EquippedChild); + var source = new QuerySource([], [first, second]); + var frame = new DirectionalShadowCasterFrame(); + RenderSceneQuery query = new(source, Generation); + frame.Build(in query); + RenderProjectionRecord moved = second with + { + Transform = new RenderTransform( + Matrix4x4.CreateTranslation(420f, 2f, 3f)), + }; + + source.ReplaceDynamics([first, moved], topologyChanged: false); + frame.Build(in query); + + Assert.Equal(0, source.ProjectionReads); + Assert.Equal( + [1], + frame.ChangedCasterPoses.ToArray() + .Select(static changed => changed.CasterIndex)); + Assert.Equal(first.Transform, frame.Casters[0].Projection.Transform); + Assert.Equal(second.Transform, frame.Casters[1].Projection.Transform); + Assert.Equal( + moved.Transform, + frame.ChangedCasterPoses[0].Snapshot.Transform); + } + + [Fact] + public void RepeatedSameId_ConsumesLatestJournalRecordWithoutSceneRead() + { + RenderProjectionRecord original = + Record(46, RenderProjectionClass.LiveDynamicRoot); + var source = new QuerySource([], [original]); + var frame = new DirectionalShadowCasterFrame(); + RenderSceneQuery query = new(source, Generation); + frame.Build(in query); + RenderProjectionRecord intermediate = original with + { + Transform = new RenderTransform( + Matrix4x4.CreateTranslation(100f, 101f, 102f)), + EntityPayload = original.EntityPayload with + { + MeshRefs = + [ + new MeshRef( + 46, + Matrix4x4.CreateTranslation(103f, 104f, 105f)), + ], + }, + }; + float rootX = BitConverter.Int32BitsToSingle(0x41234567); + float partY = BitConverter.Int32BitsToSingle(0x40ABCDEF); + RenderProjectionRecord latest = intermediate with + { + Transform = new RenderTransform( + Matrix4x4.CreateTranslation(rootX, 201f, 202f)), + EntityPayload = intermediate.EntityPayload with + { + MeshRefs = + [ + new MeshRef( + 46, + Matrix4x4.CreateTranslation(203f, partY, 205f)), + ], + }, + }; + source.PublishTransformRecord(in intermediate); + source.PublishTransformRecord(in latest); + + frame.Build(in query); + + Assert.Equal(2, frame.Stats.CopiedTransformChanges); + Assert.Equal(1, frame.Stats.DedupedChangedCasterSlots); + Assert.Equal(0, source.ProjectionReads); + Assert.Equal(0, source.BatchedProjectionCopies); + Assert.Equal(original.Transform, frame.Casters[0].Projection.Transform); + DirectionalShadowTransformSnapshot current = + frame.ChangedCasterPoses[0].Snapshot; + Assert.Equal( + BitConverter.SingleToInt32Bits(rootX), + BitConverter.SingleToInt32Bits(current.Transform.LocalToWorld.M41)); + Assert.Equal( + BitConverter.SingleToInt32Bits(partY), + BitConverter.SingleToInt32Bits( + current.EntityPayload.MeshRefs[0].PartTransform.M42)); + } + + [Fact] + public void TransformJournalOverflow_FallsBackToExactFullDynamicRefresh() + { + RenderProjectionRecord first = + Record(43, RenderProjectionClass.LiveDynamicRoot); + RenderProjectionRecord second = + Record(44, RenderProjectionClass.EquippedChild); + var source = new QuerySource([], [first, second]); + var frame = new DirectionalShadowCasterFrame(); + RenderSceneQuery query = new(source, Generation); + frame.Build(in query); + source.PublishTransformChanges( + first.Id, + DirectionalShadowTransformChangeJournal.Capacity + 1); + + frame.Build(in query); + + Assert.Equal(0, source.ProjectionReads); + Assert.Equal(1, source.BatchedProjectionCopies); + Assert.Equal( + [0, 1], + frame.ChangedCasterPoses.ToArray() + .Select(static changed => changed.CasterIndex)); + Assert.Equal(2, frame.Stats.DynamicTransformRefreshes); + } + + [Fact] + public void DenseChangedSet_UsesExactBulkRefreshInsteadOfSparseDictionaryWalk() + { + var dynamics = new RenderProjectionRecord[100]; + for (int index = 0; index < dynamics.Length; index++) + { + dynamics[index] = Record( + checked((ulong)(4_500 + index)), + RenderProjectionClass.ActiveAnimatedStatic); + } + var source = new QuerySource(dynamics, []); + var frame = new DirectionalShadowCasterFrame(); + RenderSceneQuery query = new(source, Generation); + frame.Build(in query); + for (int index = 0; index < 75; index++) + source.PublishTransformChanges(dynamics[index].Id, 1); + + frame.Build(in query); + + Assert.True(frame.Stats.DensityBulkRefresh); + Assert.Equal(75, frame.Stats.CopiedTransformChanges); + Assert.Equal(75, frame.Stats.DynamicTransformRefreshes); + Assert.Equal(0, frame.Stats.BatchedProjectionCopyCalls); + Assert.Equal(0, source.BatchedProjectionCopies); + Assert.Equal(0, source.ProjectionReads); + } + + [Fact] + public void RemovalAndRevisit_RebuildTopologyAndDiscardPriorTransformCursor() + { + RenderProjectionRecord original = + Record(45, RenderProjectionClass.LiveDynamicRoot); + var source = new QuerySource([], [original]); + var frame = new DirectionalShadowCasterFrame(); + RenderSceneQuery query = new(source, Generation); + frame.Build(in query); + source.ReplaceDynamics([], topologyChanged: true); + + frame.Build(in query); + + Assert.Empty(frame.Casters.ToArray()); + Assert.Equal(2ul, frame.BuildSequence); + RenderProjectionRecord revisited = original with + { + Transform = new RenderTransform( + Matrix4x4.CreateTranslation(450f, 2f, 3f)), + }; + source.ReplaceDynamics([revisited], topologyChanged: true); + frame.Build(in query); + int projectionReads = source.ProjectionReads; + frame.Build(in query); + + Assert.Equal(3ul, frame.BuildSequence); + Assert.Equal(revisited.Transform, frame.Casters[0].Projection.Transform); + Assert.Equal(projectionReads, source.ProjectionReads); + Assert.Empty(frame.ChangedCasterPoses.ToArray()); + } + + [Fact] + public void MembershipOrAppearanceRevision_RebuildsTopology() + { + RenderProjectionRecord original = + Record(50, RenderProjectionClass.OutdoorStatic); + var source = new QuerySource([original], []); + var frame = new DirectionalShadowCasterFrame(); + RenderSceneQuery query = new(source, Generation); + frame.Build(in query); + + RenderProjectionRecord changed = original with + { + EntityPayload = original.EntityPayload with + { + MeshRefs = + [ + new MeshRef(51, original.EntityPayload.MeshRefs[0].PartTransform) + { + SurfaceOverrides = new Dictionary + { + [0x08000001] = 0x05000001, + }, + }, + ], + }, + }; + source.ReplaceStatics([changed], topologyChanged: true); + + frame.Build(in query); + + Assert.Equal(2ul, frame.BuildSequence); + Assert.True(frame.Stats.TopologyRebuilt); + Assert.Equal(2, frame.Stats.IndexCopies); + Assert.Equal(1, frame.Stats.Classifications); + Assert.Equal((uint)51, frame.Casters[0].Projection.EntityPayload.MeshRefs[0].GfxObjId); + } + + [Fact] + public void WarmStableFrame_AllocatesZero() + { + var statics = new RenderProjectionRecord[9_500]; + for (int index = 0; index < statics.Length; index++) + { + statics[index] = Record( + checked((ulong)(index + 60)), + RenderProjectionClass.OutdoorStatic); + } + var source = new QuerySource( + statics, + [Record(10_000, RenderProjectionClass.LiveDynamicRoot)]); + var frame = new DirectionalShadowCasterFrame(); + RenderSceneQuery query = new(source, Generation); + frame.Build(in query); + frame.Build(in query); + int copies = source.IndexCopies; + + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int iteration = 0; iteration < 256; iteration++) + frame.Build(in query); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(0, allocated); + Assert.Equal(1ul, frame.BuildSequence); + Assert.Equal(copies, source.IndexCopies); + Assert.Equal(0, frame.Stats.Classifications); + Assert.Equal(0, frame.Stats.DynamicTransformRefreshes); + Assert.False(frame.Stats.TopologyRebuilt); + } + + [Fact] + public void WarmDenseChangedFrames_AllocateZeroAndReadNoSceneRecords() + { + var dynamics = new RenderProjectionRecord[100]; + for (int index = 0; index < dynamics.Length; index++) + { + dynamics[index] = Record( + checked((ulong)(20_000 + index)), + RenderProjectionClass.ActiveAnimatedStatic); + } + var source = new QuerySource(dynamics, []); + var frame = new DirectionalShadowCasterFrame(); + RenderSceneQuery query = new(source, Generation); + frame.Build(in query); + source.EnsureTransformChangeCapacity(5_000); + for (int index = 0; index < 75; index++) + source.PublishTransformChanges(dynamics[index].Id, 1); + frame.Build(in query); + + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int iteration = 0; iteration < 64; iteration++) + { + for (int index = 0; index < 75; index++) + source.PublishTransformChanges(dynamics[index].Id, 1); + frame.Build(in query); + } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(0, allocated); + Assert.True(frame.Stats.DensityBulkRefresh); + Assert.Equal(75, frame.Stats.DynamicTransformRefreshes); + Assert.Equal(0, source.ProjectionReads); + Assert.Equal(0, source.BatchedProjectionCopies); + } + + [Fact] + public void ProductionTopologyFingerprint_ExcludesPoseButIncludesGeometryAndSurfaceOverrides() + { + var entity = new WorldEntity + { + Id = 1, + SourceGfxObjOrSetupId = 0x02000001, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = + [ + new MeshRef( + 0x01000001, + Matrix4x4.CreateTranslation(1f, 2f, 3f)), + ], + }; + RenderSceneHash128 original = CurrentRenderSceneOracle + .CreateDirectionalShadowTopologyFingerprint(entity); + entity.MeshRefs = + [ + new MeshRef( + 0x01000001, + Matrix4x4.CreateTranslation(10f, 20f, 30f)), + ]; + RenderSceneHash128 poseOnly = CurrentRenderSceneOracle + .CreateDirectionalShadowTopologyFingerprint(entity); + entity.MeshRefs = + [ + new MeshRef( + 0x01000002, + Matrix4x4.CreateTranslation(10f, 20f, 30f)) + { + SurfaceOverrides = new Dictionary + { + [0x08000001] = 0x05000001, + }, + }, + ]; + RenderSceneHash128 changed = CurrentRenderSceneOracle + .CreateDirectionalShadowTopologyFingerprint(entity); + + Assert.Equal(original, poseOnly); + Assert.NotEqual(original, changed); + } + + private static readonly RenderSceneGeneration Generation = + RenderSceneGeneration.FromRaw(9); + + private static RenderProjectionRecord Record( + ulong id, + RenderProjectionClass projectionClass, + bool building = false, + RenderProjectionFlags extraFlags = RenderProjectionFlags.None, + uint parentCell = 0, + ulong? sortKey = null, + RenderCasterIdentityKind casterIdentity = + RenderCasterIdentityKind.Unclassified) + { + Matrix4x4 transform = Matrix4x4.CreateTranslation((float)id, 0f, 0f); + return new RenderProjectionRecord() with + { + Id = RenderProjectionId.FromRaw(id), + ProjectionClass = projectionClass, + OwnerIncarnation = RenderOwnerIncarnation.FromRaw(1), + Transform = new RenderTransform(transform), + PreviousTransform = new PreviousRenderTransform(transform), + MeshSet = new RenderMeshSet(RenderAssetHandle.FromRaw(id), 1, 1), + Material = new RenderMaterialVariant(0, 0, 1), + Residency = new RenderSpatialResidency( + RenderSpatialBucket.FromRaw(id), + 0x1234FFFF, + parentCell), + Bounds = new RenderWorldBounds(Vector3.Zero, Vector3.One), + Flags = RenderProjectionFlags.Draw + | RenderProjectionFlags.SpatiallyResident + | extraFlags, + SortKey = new RenderSortKey(sortKey ?? id), + Source = new RenderSourceMetadata( + LocalEntityId: (uint)id, + ServerGuid: projectionClass is RenderProjectionClass.LiveDynamicRoot + or RenderProjectionClass.EquippedChild + ? (uint)id + : 0, + SourceId: (uint)id, + ParentCellId: parentCell, + EffectCellId: 0, + BuildingShellAnchorCellId: 0, + TransformFingerprint: default, + GeometryFingerprint: default, + AppearanceFingerprint: default), + EntityPayload = new RenderEntityPayload( + [new MeshRef((uint)id, transform)], + PaletteOverride: null, + IsBuildingShell: building, + CasterIdentity: casterIdentity), + }; + } + + private sealed class QuerySource : IRenderSceneQuerySource + { + private RenderProjectionRecord[] _statics; + private RenderProjectionRecord[] _dynamics; + + public QuerySource( + RenderProjectionRecord[] statics, + RenderProjectionRecord[] dynamics) + { + _statics = statics; + _dynamics = dynamics; + } + + public int IndexCountReads { get; private set; } + public int IndexCopies { get; private set; } + public int CellQueries { get; private set; } + public int ProjectionReads { get; private set; } + public int BatchedProjectionCopies { get; private set; } + public ulong TopologyRevision { get; private set; } = 1; + public ulong TransformRevision { get; private set; } = 1; + public List CopiedIndices { get; } = []; + private readonly List + _transformChanges = []; + + public RenderProjectionCounts GetCounts(RenderSceneGeneration generation) => + throw new InvalidOperationException("The caster product must not enumerate the whole scene."); + + public RenderSceneIndexCounts GetIndexCounts(RenderSceneGeneration generation) + { + IndexCountReads++; + return new RenderSceneIndexCounts( + OutdoorStatic: _statics.Length, + IndoorCellStatic: 0, + Dynamic: _dynamics.Length, + OutdoorDynamic: _dynamics.Length, + PortalStraddlingDynamic: 0, + Translucent: 0, + Selectable: 0, + LightCandidate: 0, + Dirty: 0); + } + + public ulong GetIndexRevision(RenderSceneGeneration generation) => 1; + + public ulong GetDirectionalShadowTopologyRevision( + RenderSceneGeneration generation) => TopologyRevision; + + public ulong GetDirectionalShadowTransformRevision( + RenderSceneGeneration generation) => TransformRevision; + + public DirectionalShadowTransformChanges + CopyDirectionalShadowTransformChanges( + RenderSceneGeneration generation, + ulong afterRevision, + Span destination) + { + if (afterRevision == TransformRevision) + { + return new DirectionalShadowTransformChanges( + TransformRevision, + 0, + false); + } + ulong delta = TransformRevision - afterRevision; + if (afterRevision == 0 + || afterRevision > TransformRevision + || delta > (ulong)_transformChanges.Count + || delta > (ulong)destination.Length) + { + return new DirectionalShadowTransformChanges( + TransformRevision, + 0, + true); + } + int count = checked((int)delta); + int start = _transformChanges.Count - count; + for (int index = 0; index < count; index++) + destination[index] = _transformChanges[start + index]; + return new DirectionalShadowTransformChanges( + TransformRevision, + count, + false); + } + + public bool TryGet( + RenderSceneGeneration generation, + RenderProjectionId id, + out RenderProjectionRecord record) + { + ProjectionReads++; + for (int index = 0; index < _statics.Length; index++) + { + if (_statics[index].Id == id) + { + record = _statics[index]; + return true; + } + } + for (int index = 0; index < _dynamics.Length; index++) + { + if (_dynamics[index].Id == id) + { + record = _dynamics[index]; + return true; + } + } + + record = default; + return false; + } + + public int CopyById( + RenderSceneGeneration generation, + ReadOnlySpan ids, + Span destination) + { + BatchedProjectionCopies++; + if (destination.Length < ids.Length) + throw new ArgumentException("Destination is too small.", nameof(destination)); + for (int outputIndex = 0; outputIndex < ids.Length; outputIndex++) + { + bool found = false; + for (int index = 0; index < _statics.Length; index++) + { + if (_statics[index].Id != ids[outputIndex]) + continue; + destination[outputIndex] = _statics[index]; + found = true; + break; + } + if (!found) + { + for (int index = 0; index < _dynamics.Length; index++) + { + if (_dynamics[index].Id != ids[outputIndex]) + continue; + destination[outputIndex] = _dynamics[index]; + found = true; + break; + } + } + if (!found) + throw new InvalidOperationException("Missing projection."); + } + return ids.Length; + } + + public int CopyTo( + RenderSceneGeneration generation, + RenderProjectionClass? projectionClass, + Span destination) => + throw new InvalidOperationException("The caster product must not enumerate the whole scene."); + + public int CopyIndexTo( + RenderSceneGeneration generation, + RenderSceneIndex index, + Span destination) + { + IndexCopies++; + CopiedIndices.Add(index); + RenderProjectionRecord[] values = index switch + { + RenderSceneIndex.OutdoorStatic => _statics, + RenderSceneIndex.OutdoorDynamic => _dynamics, + _ => throw new InvalidOperationException( + $"Unexpected directional-shadow source index {index}."), + }; + values.CopyTo(destination); + return values.Length; + } + + public int GetCellCount( + RenderSceneGeneration generation, + uint fullCellId, + bool dynamic) + { + CellQueries++; + throw new InvalidOperationException("Directional shadows do not query PView cells."); + } + + public int CopyCellTo( + RenderSceneGeneration generation, + uint fullCellId, + bool dynamic, + Span destination) + { + CellQueries++; + throw new InvalidOperationException("Directional shadows do not query PView cells."); + } + + + public void ReplaceStatics( + RenderProjectionRecord[] values, + bool topologyChanged) + { + _statics = values; + if (topologyChanged) + TopologyRevision++; + } + + public void ReplaceDynamics( + RenderProjectionRecord[] values, + bool topologyChanged) + { + RenderProjectionRecord[] previous = _dynamics; + _dynamics = values; + if (topologyChanged) + TopologyRevision++; + else + { + for (int index = 0; index < values.Length; index++) + { + RenderProjectionRecord current = values[index]; + RenderProjectionRecord prior = previous.FirstOrDefault( + value => value.Id == current.Id); + if (prior.Id == current.Id + && prior.Transform == current.Transform + && PartTransformsEqual(in prior, in current)) + { + continue; + } + PublishTransformChanges(current.Id, 1); + } + } + } + + public void PublishTransformChanges(RenderProjectionId id, int count) + { + ArgumentOutOfRangeException.ThrowIfNegative(count); + RenderProjectionRecord record = Find(id); + for (int index = 0; index < count; index++) + { + _transformChanges.Add( + DirectionalShadowTransformSnapshot.Capture(in record)); + TransformRevision++; + } + } + + public void PublishTransformRecord(in RenderProjectionRecord record) + { + _transformChanges.Add( + DirectionalShadowTransformSnapshot.Capture(in record)); + TransformRevision++; + } + + public void EnsureTransformChangeCapacity(int capacity) => + _transformChanges.EnsureCapacity(capacity); + + private RenderProjectionRecord Find(RenderProjectionId id) + { + for (int index = 0; index < _statics.Length; index++) + { + if (_statics[index].Id == id) + return _statics[index]; + } + for (int index = 0; index < _dynamics.Length; index++) + { + if (_dynamics[index].Id == id) + return _dynamics[index]; + } + throw new InvalidOperationException("Missing projection."); + } + + private static bool PartTransformsEqual( + in RenderProjectionRecord left, + in RenderProjectionRecord right) + { + IReadOnlyList leftMeshes = left.EntityPayload.MeshRefs; + IReadOnlyList rightMeshes = right.EntityPayload.MeshRefs; + if (leftMeshes.Count != rightMeshes.Count) + return false; + for (int index = 0; index < leftMeshes.Count; index++) + { + if (leftMeshes[index].PartTransform + != rightMeshes[index].PartTransform) + { + return false; + } + } + return true; + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/DirectionalShadowEnvironmentGateTests.cs b/tests/AcDream.App.Tests/Rendering/DirectionalShadowEnvironmentGateTests.cs new file mode 100644 index 00000000..9b986c47 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/DirectionalShadowEnvironmentGateTests.cs @@ -0,0 +1,214 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Packs; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Rendering; + +public sealed class DirectionalShadowEnvironmentGateTests +{ + [Theory] + [InlineData(false, false, false, DirectionalShadowGateReason.PackDisabled)] + [InlineData(true, true, false, DirectionalShadowGateReason.PortalOrLoginCover)] + [InlineData(true, false, true, DirectionalShadowGateReason.Indoor)] + internal void NonWorldGates_DisableWithoutCreatingCelestialWork( + bool enabled, + bool cover, + bool indoor, + DirectionalShadowGateReason expected) + { + DirectionalShadowEnvironmentInput input = new( + enabled, + cover, + indoor, + Celestial(AuthoredCelestialShadowSourceKind.SecondaryMoon, 35f), + Atmosphere(WeatherKind.Clear)); + + DirectionalShadowEnvironmentState result = + DirectionalShadowEnvironmentGate.Evaluate( + in input, + DirectionalShadowAtmospherePolicy.BuiltIn); + + Assert.False(result.ShouldRender); + Assert.Equal(expected, result.Reason); + Assert.Equal(0f, result.Strength); + } + + [Fact] + public void SelectedCelestialBelowHorizon_DisablesAndPreservesSourceIdentity() + { + AuthoredCelestialShadowSource source = Celestial( + AuthoredCelestialShadowSourceKind.SecondaryMoon, + -2f); + DirectionalShadowEnvironmentInput input = new( + true, + false, + false, + source, + Atmosphere(WeatherKind.Clear)); + + DirectionalShadowEnvironmentState result = + DirectionalShadowEnvironmentGate.Evaluate( + in input, + DirectionalShadowAtmospherePolicy.BuiltIn); + + Assert.False(result.ShouldRender); + Assert.Equal( + DirectionalShadowGateReason.SelectedLightBelowHorizon, + result.Reason); + Assert.Equal(source.SurfaceToLightDirection, result.SurfaceToLightDirection); + Assert.Equal(source.Kind, result.SourceKind); + Assert.Equal(source.ObjectIndex, result.SourceObjectIndex); + Assert.Equal(source.GfxObjId, result.SourceGfxObjId); + } + + [Fact] + public void NoVisibleCelestial_DisablesBeforeAtmosphereMapping() + { + DirectionalShadowEnvironmentInput input = new( + true, + false, + false, + AuthoredCelestialShadowSource.None(authoredEnergy: 1f), + Atmosphere(WeatherKind.Clear)); + + DirectionalShadowEnvironmentState result = + DirectionalShadowEnvironmentGate.Evaluate( + in input, + DirectionalShadowAtmospherePolicy.BuiltIn); + + Assert.False(result.ShouldRender); + Assert.Equal(DirectionalShadowGateReason.NoVisibleCelestial, result.Reason); + Assert.Equal(AuthoredCelestialShadowSourceKind.None, result.SourceKind); + } + + [Fact] + public void SelectedCelestialWithoutAuthoredEnergy_DisablesAndPreservesSourceIdentity() + { + AuthoredCelestialShadowSource source = Celestial( + AuthoredCelestialShadowSourceKind.DominantMoon, + 35f, + energy: 0f); + DirectionalShadowEnvironmentInput input = new( + true, + false, + false, + source, + Atmosphere(WeatherKind.Clear)); + + DirectionalShadowEnvironmentState result = + DirectionalShadowEnvironmentGate.Evaluate( + in input, + DirectionalShadowAtmospherePolicy.BuiltIn); + + Assert.False(result.ShouldRender); + Assert.Equal( + DirectionalShadowGateReason.SelectedLightHasNoEnergy, + result.Reason); + Assert.Equal(source.SurfaceToLightDirection, result.SurfaceToLightDirection); + Assert.Equal(source.Kind, result.SourceKind); + Assert.Equal(source.ObjectIndex, result.SourceObjectIndex); + Assert.Equal(source.GfxObjId, result.SourceGfxObjId); + } + + [Fact] + public void AuthoredWeatherAndDayGroup_SoftenAndReduceButDoNotReplaceSelectedCelestial() + { + AuthoredCelestialShadowSource source = Celestial( + AuthoredCelestialShadowSourceKind.DominantMoon, + 35f); + DirectionalShadowEnvironmentInput clearInput = new( + true, + false, + false, + source, + Atmosphere(WeatherKind.Clear), + ActiveDayGroupMultiplier: 1f); + DirectionalShadowEnvironmentInput rainInput = clearInput with + { + Atmosphere = Atmosphere(WeatherKind.Rain), + ActiveDayGroupMultiplier = 0.8f, + }; + + DirectionalShadowEnvironmentState clear = + DirectionalShadowEnvironmentGate.Evaluate( + in clearInput, + DirectionalShadowAtmospherePolicy.BuiltIn); + DirectionalShadowEnvironmentState rain = + DirectionalShadowEnvironmentGate.Evaluate( + in rainInput, + DirectionalShadowAtmospherePolicy.BuiltIn); + + Assert.True(clear.ShouldRender); + Assert.True(rain.ShouldRender); + Assert.Equal(source.SurfaceToLightDirection, clear.SurfaceToLightDirection); + Assert.Equal(clear.SurfaceToLightDirection, rain.SurfaceToLightDirection); + Assert.Equal(AuthoredCelestialShadowSourceKind.DominantMoon, clear.SourceKind); + Assert.Equal(clear.SourceKind, rain.SourceKind); + Assert.Equal(source.ObjectIndex, rain.SourceObjectIndex); + Assert.Equal(source.GfxObjId, rain.SourceGfxObjId); + Assert.True(rain.Strength < clear.Strength); + Assert.True(rain.SoftnessMultiplier > clear.SoftnessMultiplier); + } + + [Fact] + public void ZeroDayGroupPolicy_DisablesThroughExplicitAtmosphereMapping() + { + DirectionalShadowEnvironmentInput input = new( + true, + false, + false, + Celestial(AuthoredCelestialShadowSourceKind.Sun, 35f), + Atmosphere(WeatherKind.Clear), + ActiveDayGroupMultiplier: 0f); + + DirectionalShadowEnvironmentState result = + DirectionalShadowEnvironmentGate.Evaluate( + in input, + DirectionalShadowAtmospherePolicy.BuiltIn); + + Assert.Equal(DirectionalShadowGateReason.AtmosphereSuppressed, result.Reason); + Assert.False(result.ShouldRender); + } + + private static AuthoredCelestialShadowSource Celestial( + AuthoredCelestialShadowSourceKind kind, + float elevationDegrees, + float energy = 1f) + { + float elevation = elevationDegrees * MathF.PI / 180f; + const float heading = 120f * MathF.PI / 180f; + float horizontal = MathF.Cos(elevation); + Vector3 direction = new( + horizontal * MathF.Cos(heading), + horizontal * MathF.Sin(heading), + MathF.Sin(elevation)); + uint gfxObjId = kind switch + { + AuthoredCelestialShadowSourceKind.Sun => + AuthoredCelestialShadowSourceResolver.SunGfxObjId, + AuthoredCelestialShadowSourceKind.DominantMoon => + AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId, + AuthoredCelestialShadowSourceKind.SecondaryMoon => + AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId, + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null), + }; + return new AuthoredCelestialShadowSource( + Kind: kind, + ObjectIndex: 4, + GfxObjId: gfxObjId, + SurfaceToLightDirection: direction, + ElevationSin: direction.Z, + AuthoredEnergy: energy); + } + + private static AtmosphereSnapshot Atmosphere(WeatherKind weather) => new( + weather, + Intensity: 1f, + FogColor: new Vector3(0.4f), + FogStart: 80f, + FogEnd: 350f, + FogMode.Linear, + LightningFlash: 0f, + EnvironOverride.None); +} diff --git a/tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs b/tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs new file mode 100644 index 00000000..2dec7c24 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs @@ -0,0 +1,795 @@ +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Packs; +using AcDream.App.Rendering.Scene; +using AcDream.App.Rendering.Wb; +using AcDream.App.Tests.Rendering.Gpu; +using DatReaderWriter.Enums; + +namespace AcDream.App.Tests.Rendering; + +public sealed class DirectionalShadowGpuTests +{ + [Fact] + public void CompleteCasterClassDiagnostics_AddsExactTerrainCommandCount() + { + DirectionalShadowCasterBuildStats stats = default; + stats = stats with + { + CasterClasses = new DirectionalShadowCasterClassDiagnostics( + TerrainCommands: 0, + OutdoorStatics: 2, + Buildings: 3, + AnimatedStatics: 4, + LocalPlayers: 5, + RemotePlayers: 6, + NonPlayerCreatures: 7, + OtherLiveDynamics: 8, + EquippedChildren: 9), + }; + + DirectionalShadowCasterClassDiagnostics completed = + DirectionalSunShadowRenderer.CompleteCasterClassDiagnostics( + in stats, + terrainCommandCount: 11); + + Assert.Equal(11, completed.TerrainCommands); + Assert.Equal(stats.CasterClasses with { TerrainCommands = 11 }, completed); + } + + [Fact] + public void Binding6HostLayout_MatchesCheckedInStd140Block() + { + Assert.Equal(336, DirectionalShadowUniforms.SizeInBytes); + Assert.Equal(DirectionalShadowUniforms.SizeInBytes, Marshal.SizeOf()); + Assert.Equal(0, Offset(nameof(DirectionalShadowUniforms.WorldToClip0))); + Assert.Equal(64, Offset(nameof(DirectionalShadowUniforms.WorldToClip1))); + Assert.Equal(128, Offset(nameof(DirectionalShadowUniforms.WorldToClip2))); + Assert.Equal(192, Offset(nameof(DirectionalShadowUniforms.WorldToClip3))); + Assert.Equal(256, Offset(nameof(DirectionalShadowUniforms.SplitFarMeters))); + Assert.Equal(272, Offset(nameof(DirectionalShadowUniforms.Control))); + Assert.Equal(288, Offset(nameof(DirectionalShadowUniforms.BiasMeters))); + Assert.Equal(304, Offset(nameof(DirectionalShadowUniforms.TextureAndFlags))); + Assert.Equal(320, Offset(nameof(DirectionalShadowUniforms.LightDirectionAndSource))); + Assert.Equal(16, Marshal.SizeOf()); + + string common = File.ReadAllText(Path.Combine( + RepositoryRoot(), + "src", "AcDream.App", "Rendering", "Shaders", + "directional_shadow_common.glsl")); + Assert.Contains("ACDREAM_PACK_UBO_SET binding = 6", common, StringComparison.Ordinal); + Assert.Contains("mat4 uShadowWorldToClip[4]", common, StringComparison.Ordinal); + Assert.Contains("uvec4 uShadowTextureAndFlags", common, StringComparison.Ordinal); + Assert.Contains("vec4 uShadowLightDirectionAndSource", common, StringComparison.Ordinal); + } + + [Fact] + public void ConservativeReceiverBias_UsesFarthestCascadeAndShaderScalesInnerMaps() + { + DirectionalShadowQuality quality = DirectionalShadowQuality.For( + DirectionalShadowPreset.Low); + var nearBias = new DirectionalShadowWorldBias(0.01f, 0.02f, 0.03f); + var farBias = new DirectionalShadowWorldBias(0.11f, 0.12f, 0.13f); + DirectionalShadowCascade[] cascades = + [ + Cascade(0, 20f, nearBias), + Cascade(1, quality.MaximumReachMeters, farBias), + ]; + var environment = new DirectionalShadowEnvironmentState( + DirectionalShadowGateReason.Enabled, + Vector3.Normalize(new Vector3(0.3f, 0.4f, 0.8f)), + 1f, + 1f, + 1f, + AuthoredCelestialShadowSourceKind.DominantMoon, + SourceObjectIndex: 2, + SourceGfxObjId: AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId); + + DirectionalShadowUniforms uniforms = DirectionalShadowUniforms.Create( + cascades, + environment, + quality, + new GpuTextureSlot(7)); + + Assert.Equal(farBias.ConstantDepthMeters, uniforms.BiasMeters.X); + Assert.Equal(farBias.SlopeDepthMeters, uniforms.BiasMeters.Y); + Assert.Equal(farBias.NormalOffsetMeters, uniforms.BiasMeters.Z); + + string receiver = File.ReadAllText(Path.Combine( + RepositoryRoot(), + "src", "AcDream.App", "Rendering", "Shaders", + "directional_shadow_receiver.glsl")); + Assert.Contains("farDensity / max(cascadeDensity, 1e-7)", receiver, + StringComparison.Ordinal); + Assert.Contains("uShadowBiasMeters.xyz * acdreamShadowBiasScale(cascade)", receiver, + StringComparison.Ordinal); + } + + [Fact] + public void Uniforms_CarrySelectedCelestialDirectionAndSourceKind() + { + DirectionalShadowQuality quality = DirectionalShadowQuality.For( + DirectionalShadowPreset.Low); + DirectionalShadowCascade[] cascades = + [ + Cascade(0, 20f, new DirectionalShadowWorldBias(0.01f, 0.02f, 0.03f)), + Cascade(1, quality.MaximumReachMeters, + new DirectionalShadowWorldBias(0.11f, 0.12f, 0.13f)), + ]; + Vector3 direction = Vector3.Normalize(new Vector3(0.3f, 0.4f, 0.8f)); + var environment = new DirectionalShadowEnvironmentState( + DirectionalShadowGateReason.Enabled, + direction, + 1f, + 1f, + 1f, + AuthoredCelestialShadowSourceKind.DominantMoon, + SourceObjectIndex: 2, + SourceGfxObjId: AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId); + + DirectionalShadowUniforms uniforms = DirectionalShadowUniforms.Create( + cascades, + environment, + quality, + new GpuTextureSlot(7)); + + Assert.Equal( + direction, + new Vector3( + uniforms.LightDirectionAndSource.X, + uniforms.LightDirectionAndSource.Y, + uniforms.LightDirectionAndSource.Z)); + Assert.Equal( + (float)AuthoredCelestialShadowSourceKind.DominantMoon, + uniforms.LightDirectionAndSource.W); + } + + [Fact] + public void UniformReachAndTerminalFadeUseResidentClampedFinalSplit() + { + DirectionalShadowQuality quality = DirectionalShadowQuality.For( + DirectionalShadowPreset.Low); + DirectionalShadowCascade[] cascades = + [ + Cascade(0, 18f, new DirectionalShadowWorldBias(0.01f, 0.02f, 0.03f)), + Cascade(1, 48f, new DirectionalShadowWorldBias(0.04f, 0.05f, 0.06f)), + ]; + + DirectionalShadowUniforms uniforms = DirectionalShadowUniforms.Create( + cascades, + EnabledEnvironment(), + quality, + new GpuTextureSlot(7)); + + Assert.Equal(48f, uniforms.Control.Z); + Assert.Equal(1f, uniforms.Control.W); + } + + [Fact] + public void MultiviewShadersSelectExactViewMatrixAndPreserveCutout() + { + string shaderRoot = Path.Combine( + RepositoryRoot(), + "src", "AcDream.App", "Rendering", "Shaders"); + string vertex = File.ReadAllText(Path.Combine( + shaderRoot, + "directional_shadow_world_cutout_multiview.vert")); + string fragment = File.ReadAllText(Path.Combine( + shaderRoot, + "directional_shadow_world_cutout_multiview.frag")); + Assert.Contains("GL_EXT_multiview", vertex, StringComparison.Ordinal); + Assert.Contains("uShadowWorldToClip[int(gl_ViewIndex)]", vertex, + StringComparison.Ordinal); + Assert.Contains("Instances[instanceIndex].transform", vertex, + StringComparison.Ordinal); + Assert.Contains("texel.a < 0.05", fragment, StringComparison.Ordinal); + } + + [Theory] + [InlineData(1)] + [InlineData(5)] + public void DirectionalDepthTarget_RejectsLayerCountsOutsideTwoThroughFour(int layers) + { + using var device = new RecordingGpuDevice(); + Assert.Throws(() => + device.CreateDirectionalDepthTarget( + new GpuDirectionalDepthTargetDescription("bad", 1024, layers))); + } + + [Fact] + public void DirectionalDepthTarget_ExposesOneSampleableArrayAndLayerPasses() + { + using var device = new RecordingGpuDevice(); + using IGpuDirectionalDepthTarget target = device.CreateDirectionalDepthTarget( + new GpuDirectionalDepthTargetDescription("shadow", 1536, 3)); + + Assert.Equal(GpuTextureKind.Texture2DArray, target.DepthTexture.Kind); + Assert.Equal(3, target.DepthTexture.LayerCount); + Assert.Equal(1536, target.DepthTexture.Width); + using IGpuFrame frame = device.BeginFrame(); + using (frame.BeginPass(GpuPassDescription.DirectionalDepth("cascade-2", target, 2))) + { + } + Assert.Throws(() => + frame.BeginPass(GpuPassDescription.DirectionalDepth("cascade-3", target, 3))); + } + + [Fact] + public void DirectionalDepthMultiview_RequiresExactFullMaskAndDeviceCapability() + { + using var device = new RecordingGpuDevice(); + using IGpuDirectionalDepthTarget target = device.CreateDirectionalDepthTarget( + new GpuDirectionalDepthTargetDescription("shadow", 1024, 2)); + using IGpuFrame frame = device.BeginFrame(); + using (frame.BeginPass(GpuPassDescription.DirectionalDepthMultiview( + "both-cascades", target, 0b11))) + { + } + Assert.Equal(0b11u, Assert.Single(device.OfKind()).ViewMask); + Assert.Throws(() => frame.BeginPass( + GpuPassDescription.DirectionalDepthMultiview("partial", target, 0b01))); + + using var unsupported = new RecordingGpuDevice + { + Capabilities = device.Capabilities with { SupportsMultiview = false }, + }; + using IGpuDirectionalDepthTarget unsupportedTarget = unsupported.CreateDirectionalDepthTarget( + new GpuDirectionalDepthTargetDescription("shadow", 1024, 2)); + using IGpuFrame unsupportedFrame = unsupported.BeginFrame(); + Assert.Throws(() => unsupportedFrame.BeginPass( + GpuPassDescription.DirectionalDepthMultiview( + "unsupported", unsupportedTarget, 0b11))); + } + + [Theory] + [InlineData(DirectionalShadowPreset.Low, 2, 768, true)] + [InlineData(DirectionalShadowPreset.Low, 2, 768, false)] + [InlineData(DirectionalShadowPreset.Medium, 3, 1536, false)] + [InlineData(DirectionalShadowPreset.High, 4, 2048, false)] + internal void Renderer_ReplaysOnePreparedProductAcrossEveryQualityCascade( + DirectionalShadowPreset preset, + int expectedCascades, + int expectedResolution, + bool multiviewCascades) + { + using var device = new RecordingGpuDevice(); + int baselineSlots = device.LiveTextureSlotCount; + using var renderer = new DirectionalSunShadowRenderer( + device, + preset, + multiviewCascades: multiviewCascades); + Assert.Equal(baselineSlots + 1, device.LiveTextureSlotCount); + RecordingGpuDirectionalDepthTarget target = Assert.Single(device.CreatedDirectionalDepthTargets); + Assert.Equal(expectedCascades, target.Description.LayerCount); + Assert.Equal(expectedResolution, target.Description.Resolution); + Assert.All(device.CreatedPipelines, pipeline => Assert.False(pipeline.Description.HasColorAttachment)); + + DirectionalShadowPreparedDraws world = CreateWorldDraws(device.DefaultTextureSlot); + DirectionalShadowTerrainPreparedDraws terrain = CreateTerrainDraws(); + using IGpuBuffer worldVertices = Buffer(device, "world-v", GpuBufferUsage.Vertex); + using IGpuBuffer worldIndices = Buffer(device, "world-i", GpuBufferUsage.Index); + using IGpuBuffer terrainVertices = Buffer(device, "terrain-v", GpuBufferUsage.Vertex); + using IGpuBuffer terrainIndices = Buffer(device, "terrain-i", GpuBufferUsage.Index); + var worldGeometry = new DirectionalShadowMeshGeometry(worldVertices, worldIndices); + var terrainGeometry = new DirectionalShadowTerrainGeometry(terrainVertices, terrainIndices); + var environment = new DirectionalShadowEnvironmentState( + DirectionalShadowGateReason.Enabled, + Vector3.Normalize(new Vector3(0.2f, 0.3f, 1f)), + 0.94f, + 0.8f, + 1.25f, + AuthoredCelestialShadowSourceKind.SecondaryMoon, + SourceObjectIndex: 7, + SourceGfxObjId: AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId); + + device.Clear(); + using IGpuFrame frame = device.BeginFrame(); + WorldTransformFrameSlice sharedTransforms = PublishSharedTransforms( + frame, + world.Transforms); + DirectionalSunShadowDiagnostics diagnostics = renderer.RenderPrepared( + frame, + environment, + Matrix4x4.Identity, + Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 0.1f, 500f), + cameraNearMeters: 0.1f, + casterDepthPaddingMeters: 48f, + world, + terrain, + worldGeometry, + terrainGeometry, + sharedTransforms); + + int expectedDraws = (multiviewCascades ? 1 : expectedCascades) * 3; + Assert.Equal(expectedCascades, diagnostics.CascadeCount); + Assert.Equal(expectedDraws, diagnostics.DrawCalls); + Assert.Equal(environment.Strength, diagnostics.Strength); + Assert.Equal(1, diagnostics.WorldOpaqueCommands); + Assert.Equal(1, diagnostics.WorldAlphaCutoutCommands); + Assert.Equal(1, diagnostics.TerrainCommands); + Assert.Equal(1ul, diagnostics.WorldPreparationSequence); + Assert.Equal(1ul, diagnostics.TerrainPreparationSequence); + int expectedPasses = multiviewCascades ? 1 : expectedCascades; + Assert.Equal(expectedPasses, device.OfKind().Count()); + Assert.Equal(expectedPasses, device.OfKind().Count()); + Assert.Equal(expectedPasses, device.OfKind() + .Count(call => call.Binding == GpuBindingModel.UniformDirectionalShadow)); + Assert.Equal(expectedDraws, device.OfKind().Count()); + Assert.Equal(2, device.OfKind().Count()); + GpuRecordedRingAllocation transformAllocation = Assert.Single( + device.OfKind(), + call => call.Usage == GpuRingUsage.Storage + && call.ByteCount == WorldTransformCapacityPolicy.InitialBindingSizeBytes); + RecordingGpuBuffer batchBuffer = Assert.Single( + device.CreatedBuffers, + buffer => buffer.Name == "directional-shadow-world-batches-1" + && buffer.Usage.HasFlag(GpuBufferUsage.Storage) + && buffer.Residency == GpuMemoryResidency.DeviceLocal); + Span batchBytes = stackalloc byte[32]; + batchBuffer.Read(0, batchBytes); + ReadOnlySpan batchWords = MemoryMarshal.Cast(batchBytes); + Assert.Equal( + 0u, + batchWords[3]); + Assert.Equal( + DirectionalShadowBatchFlags.AlphaCutout, + batchWords[7]); + Assert.Contains( + device.CreatedBuffers, + buffer => buffer.Name == "directional-shadow-world-commands-1" + && buffer.Usage.HasFlag(GpuBufferUsage.Indirect) + && buffer.Residency == GpuMemoryResidency.DeviceLocal); + Assert.Contains( + device.CreatedBuffers, + buffer => buffer.Name == "directional-shadow-terrain-commands-1" + && buffer.Usage.HasFlag(GpuBufferUsage.Indirect) + && buffer.Residency == GpuMemoryResidency.DeviceLocal); + Assert.DoesNotContain( + device.OfKind(), + call => call.Usage == GpuRingUsage.Storage + && call.ByteCount == world.Transforms.Length * Marshal.SizeOf()); + Assert.All( + device.OfKind() + .Where(call => call.Binding == GpuBindingModel.StorageInstances), + call => + { + Assert.Equal(transformAllocation.OffsetBytes, call.OffsetBytes); + Assert.Equal(WorldTransformCapacityPolicy.InitialBindingSizeBytes, call.SizeBytes); + }); + for (int cascade = 0; cascade < (multiviewCascades ? 1 : expectedCascades); cascade++) + { + Assert.Contains( + device.OfKind(), + call => call.Constants.RenderPass == cascade); + } + if (multiviewCascades) + { + GpuRecordedPassBegin pass = Assert.Single(device.OfKind()); + Assert.Equal(0b11u, pass.ViewMask); + Assert.Equal( + 1, + device.OfKind().Count(call => + call.PipelineName == "directional-shadow-world-cutout-multiview")); + Assert.Contains(device.OfKind(), call => + call.PipelineName == "directional-shadow-world-opaque-multiview"); + Assert.Contains(device.OfKind(), call => + call.PipelineName == "directional-shadow-terrain-multiview"); + } + } + + [Fact] + public void StableTopology_ReusesRetainedCommandBuffersWithoutFrameRingCopies() + { + using var device = new RecordingGpuDevice(); + using var renderer = new DirectionalSunShadowRenderer( + device, + DirectionalShadowPreset.Low, + multiviewCascades: true); + DirectionalShadowPreparedDraws world = CreateWorldDraws( + device.DefaultTextureSlot); + DirectionalShadowTerrainPreparedDraws terrain = CreateTerrainDraws(); + using IGpuBuffer worldVertices = Buffer(device, "world-v", GpuBufferUsage.Vertex); + using IGpuBuffer worldIndices = Buffer(device, "world-i", GpuBufferUsage.Index); + using IGpuBuffer terrainVertices = Buffer(device, "terrain-v", GpuBufferUsage.Vertex); + using IGpuBuffer terrainIndices = Buffer(device, "terrain-i", GpuBufferUsage.Index); + var worldGeometry = new DirectionalShadowMeshGeometry( + worldVertices, + worldIndices); + var terrainGeometry = new DirectionalShadowTerrainGeometry( + terrainVertices, + terrainIndices); + DirectionalShadowEnvironmentState environment = EnabledEnvironment(); + + using (IGpuFrame frame = device.BeginFrame()) + { + renderer.RenderPrepared( + frame, + environment, + Matrix4x4.Identity, + Matrix4x4.CreatePerspectiveFieldOfView(1f, 1f, 0.1f, 500f), + 0.1f, + 48f, + world, + terrain, + worldGeometry, + terrainGeometry, + PublishSharedTransforms(frame, world.Transforms)); + } + + RecordingGpuBuffer[] retained = device.CreatedBuffers + .Where(buffer => buffer.Name.StartsWith( + "directional-shadow-", + StringComparison.Ordinal)) + .ToArray(); + Assert.Equal(3, retained.Length); + Assert.Equal(3, renderer.RetainedCommandBufferCount); + Assert.Equal(retained.Sum(buffer => buffer.SizeBytes), + renderer.RetainedCommandBufferBytes); + + device.Clear(); + int createdBefore = device.CreatedBuffers.Count; + using (IGpuFrame frame = device.BeginFrame()) + { + renderer.RenderPrepared( + frame, + environment, + Matrix4x4.Identity, + Matrix4x4.CreatePerspectiveFieldOfView(1f, 1f, 0.1f, 500f), + 0.1f, + 48f, + world, + terrain, + worldGeometry, + terrainGeometry, + PublishSharedTransforms(frame, world.Transforms)); + } + + Assert.Equal(createdBefore, device.CreatedBuffers.Count); + Assert.All(retained, buffer => Assert.False(buffer.IsDisposed)); + Assert.Equal(2, device.OfKind().Count()); + Assert.DoesNotContain( + device.OfKind(), + allocation => allocation.Usage == GpuRingUsage.Indirect); + } + + [Fact] + public void TopologyRebuild_SwapsRetainedBuffersAndDisposalReleasesTheCurrentSet() + { + using var device = new RecordingGpuDevice(); + var renderer = new DirectionalSunShadowRenderer( + device, + DirectionalShadowPreset.Low); + DirectionalShadowPreparedDraws world = CreateWorldDraws( + device.DefaultTextureSlot); + DirectionalShadowTerrainPreparedDraws terrain = CreateTerrainDraws(); + using IGpuBuffer worldVertices = Buffer(device, "world-v", GpuBufferUsage.Vertex); + using IGpuBuffer worldIndices = Buffer(device, "world-i", GpuBufferUsage.Index); + using IGpuBuffer terrainVertices = Buffer(device, "terrain-v", GpuBufferUsage.Vertex); + using IGpuBuffer terrainIndices = Buffer(device, "terrain-i", GpuBufferUsage.Index); + var worldGeometry = new DirectionalShadowMeshGeometry( + worldVertices, + worldIndices); + var terrainGeometry = new DirectionalShadowTerrainGeometry( + terrainVertices, + terrainIndices); + DirectionalShadowEnvironmentState environment = EnabledEnvironment(); + + using (IGpuFrame frame = device.BeginFrame()) + { + renderer.RenderPrepared( + frame, + environment, + Matrix4x4.Identity, + Matrix4x4.CreatePerspectiveFieldOfView(1f, 1f, 0.1f, 500f), + 0.1f, + 48f, + world, + terrain, + worldGeometry, + terrainGeometry, + PublishSharedTransforms(frame, world.Transforms)); + } + RecordingGpuBuffer[] firstSet = device.CreatedBuffers + .Where(buffer => buffer.Name.StartsWith( + "directional-shadow-", + StringComparison.Ordinal)) + .ToArray(); + + RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(3); + Assert.True(world.TryBegin(generation, 8, 1)); + Matrix4x4 moved = Matrix4x4.CreateTranslation(20f, 30f, 40f); + world.Add( + 30, + 2, + 9, + GpuTextureSlot.Unassigned, + 0, + CullMode.Clockwise, + DirectionalShadowCasterMaterial.Opaque, + in moved); + DirectionalShadowPreparationStats stats = default; + world.Complete(generation, 8, in stats); + + Assert.True(terrain.TryBegin(2, 1)); + var terrainRange = new DirectionalShadowTerrainRange(80, 90); + terrain.Add(in terrainRange); + terrain.Complete(2); + using (IGpuFrame frame = device.BeginFrame()) + { + renderer.RenderPrepared( + frame, + environment, + Matrix4x4.Identity, + Matrix4x4.CreatePerspectiveFieldOfView(1f, 1f, 0.1f, 500f), + 0.1f, + 48f, + world, + terrain, + worldGeometry, + terrainGeometry, + PublishSharedTransforms(frame, world.Transforms)); + } + + Assert.All(firstSet, buffer => Assert.True(buffer.IsDisposed)); + RecordingGpuBuffer[] currentSet = device.CreatedBuffers + .Where(buffer => buffer.Name.EndsWith("-2", StringComparison.Ordinal)) + .ToArray(); + Assert.Equal(3, currentSet.Length); + Assert.All(currentSet, buffer => Assert.False(buffer.IsDisposed)); + + renderer.Dispose(); + + Assert.All(currentSet, buffer => Assert.True(buffer.IsDisposed)); + } + + [Theory] + [InlineData(DirectionalShadowGateReason.Indoor)] + [InlineData(DirectionalShadowGateReason.SelectedLightBelowHorizon)] + [InlineData(DirectionalShadowGateReason.SelectedLightHasNoEnergy)] + [InlineData(DirectionalShadowGateReason.NoVisibleCelestial)] + [InlineData(DirectionalShadowGateReason.PackDisabled)] + internal void DisabledEnvironment_RecordsNoPassOrUpload(DirectionalShadowGateReason reason) + { + using var device = new RecordingGpuDevice(); + using var renderer = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low); + device.Clear(); + using IGpuFrame frame = device.BeginFrame(); + + DirectionalSunShadowDiagnostics diagnostics = renderer.RenderPrepared( + frame, + new DirectionalShadowEnvironmentState(reason, Vector3.UnitZ, 0f, 0f, 1f), + Matrix4x4.Identity, + Matrix4x4.Identity, + 0.1f, + 48f, + new DirectionalShadowPreparedDraws(), + new DirectionalShadowTerrainPreparedDraws(), + null, + null, + default); + + Assert.Equal(reason, diagnostics.GateReason); + Assert.Empty(device.OfKind()); + Assert.Empty(device.OfKind()); + } + + [Fact] + public void Disposal_ReleasesTextureSlotAndEveryOwnedResource() + { + using var device = new RecordingGpuDevice(); + int baselineSlots = device.LiveTextureSlotCount; + var renderer = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.High); + RecordingGpuDirectionalDepthTarget target = Assert.Single(device.CreatedDirectionalDepthTargets); + RecordingGpuPipeline[] pipelines = device.CreatedPipelines.ToArray(); + RecordingGpuSampler sampler = device.CreatedSamplers[^1]; + + Assert.Equal(GpuSamplerDescription.ShadowNearestClamp, sampler.Description); + Assert.Equal(GpuFilter.Nearest, sampler.Description.MinFilter); + Assert.Equal(GpuFilter.Nearest, sampler.Description.MagFilter); + Assert.Equal(GpuAddressMode.ClampToEdge, sampler.Description.AddressU); + Assert.Equal(GpuAddressMode.ClampToEdge, sampler.Description.AddressV); + + renderer.Dispose(); + + Assert.Equal(baselineSlots, device.LiveTextureSlotCount); + Assert.True(target.IsDisposed); + Assert.True(sampler.IsDisposed); + Assert.All(pipelines, pipeline => Assert.True(pipeline.IsDisposed)); + } + + [Fact] + public void ConstructionFailure_RollsBackTargetSlotSamplerAndEarlierPipelines() + { + using var device = new RecordingGpuDevice(); + int baselineSlots = device.LiveTextureSlotCount; + device.PipelineFailure = description => + description.Name == "directional-shadow-world-opaque" + ? new InvalidOperationException("injected pipeline failure") + : null; + + InvalidOperationException failure = Assert.Throws(() => + new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Medium)); + + Assert.Equal("injected pipeline failure", failure.Message); + Assert.Equal(baselineSlots, device.LiveTextureSlotCount); + Assert.True(Assert.Single(device.CreatedDirectionalDepthTargets).IsDisposed); + Assert.True(device.CreatedSamplers[^1].IsDisposed); + Assert.True(Assert.Single(device.CreatedPipelines).IsDisposed); + } + + [Fact] + public void MultiviewConstructionFailure_RetiresAllOrdinaryAndLayeredCandidates() + { + using var device = new RecordingGpuDevice(); + int baselineSlots = device.LiveTextureSlotCount; + device.PipelineFailure = description => + description.Name == "directional-shadow-world-cutout-multiview" + ? new InvalidOperationException("injected multiview failure") + : null; + + Assert.Throws(() => + new DirectionalSunShadowRenderer( + device, + DirectionalShadowPreset.Low, + multiviewCascades: true)); + + Assert.Equal(baselineSlots, device.LiveTextureSlotCount); + Assert.True(Assert.Single(device.CreatedDirectionalDepthTargets).IsDisposed); + Assert.True(device.CreatedSamplers[^1].IsDisposed); + Assert.Equal(5, device.CreatedPipelines.Count); + Assert.All(device.CreatedPipelines, pipeline => Assert.True(pipeline.IsDisposed)); + } + + [Fact] + public void RebuildAfterDisposal_UsesANewLiveShadowSampler() + { + using var device = new RecordingGpuDevice(); + var first = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low); + RecordingGpuSampler firstSampler = device.CreatedSamplers[^1]; + first.Dispose(); + + using var second = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low); + RecordingGpuSampler secondSampler = device.CreatedSamplers[^1]; + + Assert.NotSame(firstSampler, secondSampler); + Assert.True(firstSampler.IsDisposed); + Assert.False(secondSampler.IsDisposed); + Assert.Equal(GpuSamplerDescription.ShadowNearestClamp, secondSampler.Description); + } + + [Fact] + public void ReceiverBinding_IsValidOnlyForTheProducingFrame() + { + using var device = new RecordingGpuDevice(); + using var renderer = new DirectionalSunShadowRenderer( + device, + DirectionalShadowPreset.Low); + using (IGpuFrame frame = device.BeginFrame()) + { + WorldTransformFrameSlice sharedTransforms = PublishSharedTransforms( + frame, + ReadOnlySpan.Empty); + renderer.RenderPrepared( + frame, + new DirectionalShadowEnvironmentState( + DirectionalShadowGateReason.Enabled, + Vector3.UnitZ, + 1f, + 1f, + 1f, + AuthoredCelestialShadowSourceKind.Sun, + SourceObjectIndex: 0, + SourceGfxObjId: AuthoredCelestialShadowSourceResolver.SunGfxObjId), + Matrix4x4.Identity, + Matrix4x4.CreatePerspectiveFieldOfView(1f, 1f, 0.1f, 100f), + 0.1f, + 48f, + new DirectionalShadowPreparedDraws(), + new DirectionalShadowTerrainPreparedDraws(), + null, + null, + sharedTransforms); + + Assert.True(renderer.TryGetCurrentFrameBinding(frame, out var binding)); + Assert.Equal(frame.Serial, binding.FrameSerial); + Assert.Equal((uint)DirectionalShadowUniforms.SizeInBytes, binding.SizeBytes); + Assert.Equal(2, binding.CascadeCount); + } + + using IGpuFrame later = device.BeginFrame(); + Assert.False(renderer.TryGetCurrentFrameBinding(later, out _)); + } + + private static DirectionalShadowPreparedDraws CreateWorldDraws(GpuTextureSlot cutoutSlot) + { + var draws = new DirectionalShadowPreparedDraws(); + RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(3); + Assert.True(draws.TryBegin(generation, 7, 2)); + Matrix4x4 opaque = Matrix4x4.CreateTranslation(1f, 2f, 3f); + Matrix4x4 cutout = Matrix4x4.CreateRotationZ(0.3f) * Matrix4x4.CreateTranslation(4f, 5f, 6f); + draws.Add(0, 0, 6, GpuTextureSlot.Unassigned, 0, CullMode.CounterClockwise, + DirectionalShadowCasterMaterial.Opaque, in opaque); + draws.Add(6, 4, 12, cutoutSlot, 2, CullMode.None, + DirectionalShadowCasterMaterial.AlphaCutout, in cutout); + DirectionalShadowPreparationStats stats = default; + draws.Complete(generation, 7, in stats); + return draws; + } + + private static DirectionalShadowCascade Cascade( + int index, + float splitFarMeters, + DirectionalShadowWorldBias bias) => + new( + index, + index == 0 ? 0.1f : 20f, + splitFarMeters, + Matrix4x4.Identity, + Matrix4x4.Identity, + Matrix4x4.Identity, + Vector2.Zero, + 10f, + 0.1f, + 48f, + bias); + + private static DirectionalShadowEnvironmentState EnabledEnvironment() => + new( + DirectionalShadowGateReason.Enabled, + Vector3.Normalize(new Vector3(0.2f, 0.3f, 1f)), + 0.94f, + 0.8f, + 1.25f, + AuthoredCelestialShadowSourceKind.Sun, + SourceObjectIndex: 0, + SourceGfxObjId: AuthoredCelestialShadowSourceResolver.SunGfxObjId); + + private static DirectionalShadowTerrainPreparedDraws CreateTerrainDraws() + { + var draws = new DirectionalShadowTerrainPreparedDraws(); + Assert.True(draws.TryBegin(1, 1)); + var range = new DirectionalShadowTerrainRange(20, 60); + draws.Add(in range); + draws.Complete(1); + return draws; + } + + private static IGpuBuffer Buffer(RecordingGpuDevice device, string name, GpuBufferUsage usage) => + device.CreateBuffer(new GpuBufferDescription( + name, + 4096, + usage | GpuBufferUsage.TransferDestination, + GpuMemoryResidency.DeviceLocal)); + + private static WorldTransformFrameSlice PublishSharedTransforms( + IGpuFrame frame, + ReadOnlySpan transforms) + { + GpuRingAllocation allocation = frame.AllocateRing( + checked((int)WorldTransformCapacityPolicy.InitialBindingSizeBytes), + GpuRingUsage.Storage); + if (!transforms.IsEmpty) + MemoryMarshal.AsBytes(transforms).CopyTo(allocation.Data); + return new WorldTransformFrameSlice( + frame.Serial, + allocation.Buffer, + allocation.OffsetBytes, + WorldTransformCapacityPolicy.InitialBindingSizeBytes, + FirstInstance: 0, + checked((uint)transforms.Length)); + } + + private static int Offset(string field) => + checked((int)Marshal.OffsetOf(field)); + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + directory = directory.Parent; + return directory?.FullName + ?? throw new InvalidOperationException("Could not locate repository root."); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/DirectionalShadowQualityTests.cs b/tests/AcDream.App.Tests/Rendering/DirectionalShadowQualityTests.cs new file mode 100644 index 00000000..b6667e89 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/DirectionalShadowQualityTests.cs @@ -0,0 +1,58 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class DirectionalShadowQualityTests +{ + [Theory] + [InlineData(DirectionalShadowPreset.Low, 2, 768, 72, 4_718_592L)] + [InlineData(DirectionalShadowPreset.Medium, 3, 1536, 144, 28_311_552L)] + [InlineData(DirectionalShadowPreset.High, 4, 2048, 240, 67_108_864L)] + internal void Presets_PreserveHeadlineSemanticsAndPlanningEnvelope( + DirectionalShadowPreset preset, + int cascades, + int resolution, + float reach, + long expectedDepthBytes) + { + DirectionalShadowQuality quality = DirectionalShadowQuality.For(preset); + + Assert.Equal(cascades, quality.CascadeCount); + Assert.Equal(resolution, quality.MapResolution); + Assert.Equal(reach, quality.MaximumReachMeters); + Assert.Equal(expectedDepthBytes, quality.ApproximateDepthMapBytes); + Assert.Equal( + DirectionalShadowSemantics.Headline, + quality.Semantics & DirectionalShadowSemantics.Headline); + Assert.True(quality.PackResidentGpuByteBudget >= quality.ApproximateDepthMapBytes); + Assert.True(quality.IncrementalGpuP50BudgetMilliseconds > 0); + Assert.True(quality.IncrementalCpuP50BudgetMilliseconds > 0); + } + + [Fact] + public void BiasPolicy_ProducesFiniteWorldUnitOffsetsThatScaleWithTexelFootprint() + { + DirectionalShadowBiasPolicy policy = + DirectionalShadowQuality.For(DirectionalShadowPreset.Medium).BiasPolicy; + + DirectionalShadowWorldBias near = policy.Resolve(0.02f); + DirectionalShadowWorldBias far = policy.Resolve(0.20f); + + Assert.InRange(near.ConstantDepthMeters, policy.MinimumMeters, policy.MaximumMeters); + Assert.InRange(near.SlopeDepthMeters, policy.MinimumMeters, policy.MaximumMeters); + Assert.InRange(near.NormalOffsetMeters, policy.MinimumMeters, policy.MaximumMeters); + Assert.True(far.ConstantDepthMeters > near.ConstantDepthMeters); + Assert.True(far.SlopeDepthMeters > near.SlopeDepthMeters); + Assert.True(far.NormalOffsetMeters > near.NormalOffsetMeters); + } + + [Theory] + [InlineData(DirectionalShadowPreset.Low)] + [InlineData(DirectionalShadowPreset.Medium)] + [InlineData(DirectionalShadowPreset.High)] + internal void Presets_KeepShaderPinnedMinimumWorldBias( + DirectionalShadowPreset preset) => + Assert.Equal( + 0.001f, + DirectionalShadowQuality.For(preset).BiasPolicy.MinimumMeters); +} diff --git a/tests/AcDream.App.Tests/Rendering/DirectionalShadowReceiverTests.cs b/tests/AcDream.App.Tests/Rendering/DirectionalShadowReceiverTests.cs new file mode 100644 index 00000000..a4fd0af4 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/DirectionalShadowReceiverTests.cs @@ -0,0 +1,137 @@ +using System.Numerics; +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class DirectionalShadowReceiverTests +{ + [Theory] + [InlineData("atmospheric-world-hdr", false, true, false)] + [InlineData("atmospheric-world-hdr", true, false, false)] + [InlineData("vk-world", true, true, false)] + [InlineData("atmospheric-world-hdr", true, true, true)] + internal void RetailPipelineRemainsExactUnlessPackAndCurrentBindingAreActive( + string pass, + bool source, + bool binding, + bool expected) => + Assert.Equal( + expected, + DirectionalShadowReceiverPolicy.ShouldSelectReceiverPipeline( + pass, + source, + binding)); + + [Fact] + public void CascadeTransition_IsContinuousAcrossTheSplitInWorldMetres() + { + Vector4 splits = new(10f, 30f, 60f, 100f); + + DirectionalShadowCascadeBlend atSplit = + DirectionalShadowReceiverPolicy.SelectCascade(10f, splits, 4, 2f); + DirectionalShadowCascadeBlend afterSplit = + DirectionalShadowReceiverPolicy.SelectCascade(10.0001f, splits, 4, 2f); + + Assert.Equal(0, atSplit.PrimaryCascade); + Assert.Equal(1, atSplit.SecondaryCascade); + Assert.Equal(1f, atSplit.SecondaryWeight, 5); + Assert.Equal(1, afterSplit.PrimaryCascade); + Assert.Equal(2, afterSplit.SecondaryCascade); + Assert.InRange(afterSplit.SecondaryWeight, 0f, 0.00001f); + Assert.True(atSplit.WithinShadowReach); + Assert.True(afterSplit.WithinShadowReach); + } + + [Fact] + public void CascadeSelection_StopsAtConfiguredReach() + { + DirectionalShadowCascadeBlend outside = + DirectionalShadowReceiverPolicy.SelectCascade( + 100.01f, + new Vector4(10f, 30f, 60f, 100f), + 4, + 2f); + + Assert.False(outside.WithinShadowReach); + Assert.Equal(3, outside.PrimaryCascade); + } + + [Fact] + public void BiasRemainsWorldMetresAndScalesWithSurfaceSlope() + { + var bias = new DirectionalShadowWorldBias(0.01f, 0.04f, 0.02f); + + Assert.Equal(0.01f, DirectionalShadowReceiverPolicy.ReceiverBiasMeters(bias, 1f), 6); + Assert.Equal(0.05f, DirectionalShadowReceiverPolicy.ReceiverBiasMeters(bias, 0f), 6); + } + + [Theory] + [InlineData(true, false, true, true)] + [InlineData(true, true, true, false)] + [InlineData(true, false, false, false)] + [InlineData(false, false, true, false)] + internal void IndoorAndMissingDirectionalTermsNeverSample( + bool binding, + bool indoor, + bool directional, + bool expected) => + Assert.Equal( + expected, + DirectionalShadowReceiverPolicy.ShouldSample( + binding, + indoor, + directional)); + + [Fact] + public void ReceiverShadersPreserveAnimatedFoliageMaterialAndTransparencySemantics() + { + string root = RepositoryRoot(); + string shaderRoot = Path.Combine( + root, + "src", "AcDream.App", "Rendering", "Shaders"); + string vertex = File.ReadAllText(Path.Combine(shaderRoot, "mesh_atmospheric.vert")); + string fragment = File.ReadAllText(Path.Combine(shaderRoot, "mesh_atmospheric.frag")); + string terrain = File.ReadAllText(Path.Combine(shaderRoot, "terrain_atmospheric.frag")); + string receiver = File.ReadAllText(Path.Combine( + shaderRoot, + "directional_shadow_receiver.glsl")); + + Assert.Contains("int transformIndex = gl_BaseInstanceARB + gl_InstanceID", vertex, StringComparison.Ordinal); + Assert.Contains("int instanceIndex = transformIndex - int(uTextureIndexB)", vertex, StringComparison.Ordinal); + Assert.Contains("Instances[transformIndex].transform", vertex, StringComparison.Ordinal); + Assert.Contains("instanceIndoor[instanceIndex]", vertex, StringComparison.Ordinal); + Assert.Contains("vSelectionLighting", fragment, StringComparison.Ordinal); + Assert.Contains("vOpacityMultiplier", fragment, StringComparison.Ordinal); + Assert.Contains("if (color.a < 0.05) discard", fragment, StringComparison.Ordinal); + Assert.Contains("ACDREAM_SAMPLE_ARRAY", fragment, StringComparison.Ordinal); + Assert.Contains("combineOverlays", terrain, StringComparison.Ordinal); + Assert.Contains("combineRoad", terrain, StringComparison.Ordinal); + Assert.Contains("smoothstep", receiver, StringComparison.Ordinal); + Assert.Contains("acdreamShadowBiasScale(cascade)", receiver, StringComparison.Ordinal); + Assert.Contains("textureGather", receiver, StringComparison.Ordinal); + Assert.Contains("acdreamShadowBilinearCompare", receiver, StringComparison.Ordinal); + Assert.Contains("float weight = float(2 - abs(x))", receiver, StringComparison.Ordinal); + Assert.Contains("float reachFade = 1.0 - smoothstep", receiver, StringComparison.Ordinal); + Assert.Contains("radius = clamp(radius, 0, 2)", receiver, StringComparison.Ordinal); + Assert.Contains("float softness = max(uShadowControl.y, 1.0)", receiver, StringComparison.Ordinal); + + string detailVertex = File.ReadAllText(Path.Combine( + shaderRoot, + "mesh_detail.vert")); + Assert.Contains("int instanceIndex = transformIndex - int(uTextureIndexB)", detailVertex, StringComparison.Ordinal); + Assert.Contains("Instances[transformIndex].transform", detailVertex, StringComparison.Ordinal); + Assert.Contains("instanceDetailCategory[instanceIndex]", detailVertex, StringComparison.Ordinal); + } + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null + && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + directory = directory.Parent; + } + return directory?.FullName + ?? throw new InvalidOperationException("Could not locate repository root."); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/DirectionalShadowTransformBufferSetTests.cs b/tests/AcDream.App.Tests/Rendering/DirectionalShadowTransformBufferSetTests.cs new file mode 100644 index 00000000..2e7a251d --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/DirectionalShadowTransformBufferSetTests.cs @@ -0,0 +1,391 @@ +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Wb; +using AcDream.App.Tests.Rendering.Gpu; + +namespace AcDream.App.Tests.Rendering; + +public sealed class DirectionalShadowTransformBufferSetTests +{ + [Fact] + public void FlightSlotsRetainStaticPrefix_AndWarmedReuseAllocatesAndWritesNothing() + { + using var device = new RecordingGpuDevice(); + using var buffers = new DirectionalShadowTransformBufferSet(device); + Matrix4x4[] transforms = + [ + Matrix4x4.CreateTranslation(1f, 2f, 3f), + Matrix4x4.CreateRotationZ(0.25f), + ]; + + RecordingGpuBuffer slotZero; + using (IGpuFrame first = device.BeginFrame()) + { + WorldTransformFrameSlice slice = buffers.Publish( + first, + topologyBuildSequence: 7, + transforms, + ReadOnlySpan.Empty); + slotZero = Assert.IsType(slice.Buffer); + Assert.Equal(0, first.SlotIndex); + Assert.Equal(WorldTransformCapacityPolicy.InitialBindingSizeBytes, + slice.BindingSizeBytes); + } + using (IGpuFrame second = device.BeginFrame()) + { + WorldTransformFrameSlice slice = buffers.Publish( + second, + topologyBuildSequence: 7, + transforms, + ReadOnlySpan.Empty); + Assert.Equal(1, second.SlotIndex); + Assert.NotSame(slotZero, slice.Buffer); + } + + device.Clear(); + int buffersBefore = device.CreatedBuffers.Count; + int uploadsBefore = slotZero.UploadCount; + using IGpuFrame warmed = device.BeginFrame(); + Assert.Equal(0, warmed.SlotIndex); + buffers.Publish( + warmed, + topologyBuildSequence: 7, + transforms, + ReadOnlySpan.Empty); + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int iteration = 0; iteration < 256; iteration++) + { + buffers.Publish( + warmed, + topologyBuildSequence: 7, + transforms, + ReadOnlySpan.Empty); + } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(0, allocated); + Assert.Equal(buffersBefore, device.CreatedBuffers.Count); + Assert.Equal(uploadsBefore, slotZero.UploadCount); + Assert.Empty(device.OfKind()); + Assert.False(buffers.LastStats.TopologyUploaded); + Assert.Equal(0, buffers.LastStats.BytesWritten); + Assert.Equal(2, buffers.BufferCount); + Assert.Equal( + 2L * WorldTransformCapacityPolicy.InitialBindingSizeBytes, + buffers.RetainedGpuBytes); + } + + [Fact] + public void StableTopology_UpdatesOnlyStrictDynamicRangesWithExactMatrixBits() + { + using var device = new RecordingGpuDevice(); + using var buffers = new DirectionalShadowTransformBufferSet(device); + Matrix4x4[] transforms = + [ + Matrix4x4.Identity, + Matrix4x4.CreateTranslation(1f, 2f, 3f), + Matrix4x4.CreateTranslation(4f, 5f, 6f), + Matrix4x4.CreateTranslation(7f, 8f, 9f), + Matrix4x4.CreateTranslation(10f, 11f, 12f), + ]; + int[] dynamicSlots = [1, 2, 4]; + + using (IGpuFrame first = device.BeginFrame()) + buffers.Publish(first, 11, transforms, dynamicSlots); + using (IGpuFrame second = device.BeginFrame()) + buffers.Publish(second, 11, transforms, dynamicSlots); + + float exactX = BitConverter.Int32BitsToSingle(0x41234567); + float exactY = BitConverter.Int32BitsToSingle(0x40ABCDEF); + transforms[1] = Matrix4x4.CreateRotationX(0.3f) + * Matrix4x4.CreateTranslation(exactX, 20f, 30f); + transforms[2] = Matrix4x4.CreateRotationY(0.4f) + * Matrix4x4.CreateTranslation(40f, exactY, 50f); + transforms[4] = Matrix4x4.CreateRotationZ(0.5f) + * Matrix4x4.CreateTranslation(60f, 70f, 80f); + + device.Clear(); + using IGpuFrame third = device.BeginFrame(); + WorldTransformFrameSlice slice = buffers.Publish( + third, + 11, + transforms, + dynamicSlots); + var retained = Assert.IsType(slice.Buffer); + Matrix4x4[] readback = new Matrix4x4[transforms.Length]; + retained.Read(0, MemoryMarshal.AsBytes(readback.AsSpan())); + + AssertMatrixBitsEqual(transforms[0], readback[0]); + AssertMatrixBitsEqual(transforms[1], readback[1]); + AssertMatrixBitsEqual(transforms[2], readback[2]); + AssertMatrixBitsEqual(transforms[3], readback[3]); + AssertMatrixBitsEqual(transforms[4], readback[4]); + Assert.False(buffers.LastStats.TopologyUploaded); + Assert.Equal(3, buffers.LastStats.DynamicMatricesUpdated); + Assert.Equal(2, buffers.LastStats.DynamicRangesUpdated); + Assert.Equal(3 * 64, buffers.LastStats.BytesWritten); + Assert.Single(device.OfKind()); + } + + [Fact] + public void ChangedMatrix_ReplaysExactlyToEveryFlightSlotWithoutRescanningAllDynamics() + { + using var device = new RecordingGpuDevice(); + using var buffers = new DirectionalShadowTransformBufferSet(device); + Matrix4x4[] transforms = + [ + Matrix4x4.Identity, + Matrix4x4.CreateTranslation(1f, 2f, 3f), + Matrix4x4.CreateTranslation(4f, 5f, 6f), + ]; + int[] allDynamic = [1, 2]; + using (IGpuFrame first = device.BeginFrame()) + buffers.Publish(first, 12, transforms, [], allDynamic); + using (IGpuFrame second = device.BeginFrame()) + buffers.Publish(second, 12, transforms, [], allDynamic); + + float exact = BitConverter.Int32BitsToSingle(0x41234567); + transforms[2] = Matrix4x4.CreateRotationZ(0.25f) + * Matrix4x4.CreateTranslation(exact, 8f, 9f); + RecordingGpuBuffer slotZero; + using (IGpuFrame changed = device.BeginFrame()) + { + slotZero = Assert.IsType(buffers.Publish( + changed, + 12, + transforms, + [2], + allDynamic).Buffer); + Assert.Equal(1, buffers.LastStats.DynamicMatricesUpdated); + } + + device.Clear(); + RecordingGpuBuffer slotOne; + using (IGpuFrame replay = device.BeginFrame()) + { + slotOne = Assert.IsType(buffers.Publish( + replay, + 12, + transforms, + [], + allDynamic).Buffer); + Assert.Equal(1, buffers.LastStats.DynamicMatricesUpdated); + Assert.Equal(64, buffers.LastStats.BytesWritten); + Assert.Equal(0, buffers.LastStats.CurrentChangedMatrices); + Assert.Equal(1, buffers.LastStats.PendingReplayMatrices); + } + Matrix4x4[] zeroReadback = new Matrix4x4[3]; + Matrix4x4[] oneReadback = new Matrix4x4[3]; + slotZero.Read(0, MemoryMarshal.AsBytes(zeroReadback.AsSpan())); + slotOne.Read(0, MemoryMarshal.AsBytes(oneReadback.AsSpan())); + AssertMatrixBitsEqual(transforms[2], zeroReadback[2]); + AssertMatrixBitsEqual(transforms[2], oneReadback[2]); + Assert.Single(device.OfKind()); + Assert.True(buffers.RetainedScratchBytes > 0); + } + + [Fact] + public void RepeatedChanges_CoalesceToOneLatestValueForWaitingFlightSlot() + { + using var device = new RecordingGpuDevice(); + using var buffers = new DirectionalShadowTransformBufferSet(device); + Matrix4x4[] transforms = + [ + Matrix4x4.Identity, + Matrix4x4.CreateTranslation(1f, 2f, 3f), + ]; + int[] allDynamic = [1]; + using (IGpuFrame first = device.BeginFrame()) + buffers.Publish(first, 13, transforms, [], allDynamic); + using (IGpuFrame second = device.BeginFrame()) + buffers.Publish(second, 13, transforms, [], allDynamic); + + using (IGpuFrame current = device.BeginFrame()) + { + transforms[1] = Matrix4x4.CreateTranslation(10f, 20f, 30f); + buffers.Publish(current, 13, transforms, [1], allDynamic); + transforms[1] = Matrix4x4.CreateTranslation(40f, 50f, 60f); + buffers.Publish(current, 13, transforms, [1], allDynamic); + } + using IGpuFrame waiting = device.BeginFrame(); + WorldTransformFrameSlice slice = buffers.Publish( + waiting, + 13, + transforms, + [], + allDynamic); + var retained = Assert.IsType(slice.Buffer); + Matrix4x4[] readback = new Matrix4x4[2]; + retained.Read(0, MemoryMarshal.AsBytes(readback.AsSpan())); + + AssertMatrixBitsEqual(transforms[1], readback[1]); + Assert.Equal(1, buffers.LastStats.DynamicMatricesUpdated); + Assert.Equal(1, buffers.LastStats.PendingReplayMatrices); + Assert.Equal(64, buffers.LastStats.BytesWritten); + } + + [Fact] + public void DenseRefresh_UploadsFourExactContiguousRangesAndReplaysDirectlyPerFlight() + { + using var device = new RecordingGpuDevice(); + using var buffers = new DirectionalShadowTransformBufferSet(device); + var transforms = new Matrix4x4[2_000]; + for (int index = 0; index < transforms.Length; index++) + transforms[index] = Matrix4x4.CreateTranslation(index, index + 1, index + 2); + int[] dynamicSlots = Enumerable.Range(0, 494) + .Concat(Enumerable.Range(500, 494)) + .Concat(Enumerable.Range(1_000, 494)) + .Concat(Enumerable.Range(1_500, 494)) + .ToArray(); + using (IGpuFrame first = device.BeginFrame()) + buffers.Publish(first, 14, transforms, [], dynamicSlots, false); + using (IGpuFrame second = device.BeginFrame()) + buffers.Publish(second, 14, transforms, [], dynamicSlots, false); + + for (int index = 0; index < dynamicSlots.Length; index++) + { + int slot = dynamicSlots[index]; + transforms[slot] = Matrix4x4.CreateTranslation( + slot + 10_000, + slot + 20_000, + slot + 30_000); + } + using (IGpuFrame dense = device.BeginFrame()) + { + buffers.Publish( + dense, + 14, + transforms, + dynamicSlots, + dynamicSlots, + denseRefresh: true); + Assert.True(buffers.LastStats.DenseDirectUpload); + Assert.False(buffers.LastStats.DenseFlightReplay); + Assert.Equal(1_976, buffers.LastStats.DynamicMatricesUpdated); + Assert.Equal(4, buffers.LastStats.DynamicRangesUpdated); + Assert.Equal(1_976 * 64, buffers.LastStats.BytesWritten); + Assert.Equal(0, buffers.LastStats.PendingReplayMatrices); + } + + using IGpuFrame replay = device.BeginFrame(); + WorldTransformFrameSlice replaySlice = buffers.Publish( + replay, + 14, + transforms, + [], + dynamicSlots, + denseRefresh: false); + Assert.False(buffers.LastStats.DenseDirectUpload); + Assert.True(buffers.LastStats.DenseFlightReplay); + Assert.Equal(1_976, buffers.LastStats.DynamicMatricesUpdated); + Assert.Equal(4, buffers.LastStats.DynamicRangesUpdated); + Assert.Equal(1_976 * 64, buffers.LastStats.BytesWritten); + var retained = Assert.IsType(replaySlice.Buffer); + var readback = new Matrix4x4[2_000]; + retained.Read(0, MemoryMarshal.AsBytes(readback.AsSpan())); + AssertMatrixBitsEqual(transforms[0], readback[0]); + AssertMatrixBitsEqual(transforms[1_993], readback[1_993]); + AssertMatrixBitsEqual(transforms[1_999], readback[1_999]); + } + + [Fact] + public void TopologyChange_CreateSwapsCurrentSlotAndDisposalReleasesEverySlot() + { + using var device = new RecordingGpuDevice(); + var buffers = new DirectionalShadowTransformBufferSet(device); + Matrix4x4[] firstPose = [Matrix4x4.Identity]; + RecordingGpuBuffer firstSlot; + RecordingGpuBuffer secondSlot; + using (IGpuFrame first = device.BeginFrame()) + { + firstSlot = Assert.IsType(buffers.Publish( + first, 1, firstPose, []).Buffer); + } + using (IGpuFrame second = device.BeginFrame()) + { + secondSlot = Assert.IsType(buffers.Publish( + second, 1, firstPose, []).Buffer); + } + + Matrix4x4[] rebuiltPose = + [ + Matrix4x4.CreateTranslation(9f, 8f, 7f), + Matrix4x4.CreateScale(2f), + ]; + RecordingGpuBuffer replacement; + using (IGpuFrame third = device.BeginFrame()) + { + replacement = Assert.IsType(buffers.Publish( + third, 2, rebuiltPose, []).Buffer); + } + + Assert.True(firstSlot.IsDisposed); + Assert.False(secondSlot.IsDisposed); + Assert.False(replacement.IsDisposed); + Assert.True(buffers.LastStats.TopologyUploaded); + buffers.Dispose(); + Assert.True(secondSlot.IsDisposed); + Assert.True(replacement.IsDisposed); + } + + [Fact] + public void InvalidDynamicSlotsAndUnsupportedBindingFailBeforePublishing() + { + using var device = new RecordingGpuDevice(); + using var buffers = new DirectionalShadowTransformBufferSet(device); + using IGpuFrame frame = device.BeginFrame(); + Matrix4x4[] pose = [Matrix4x4.Identity, Matrix4x4.Identity]; + + Assert.Throws(() => + buffers.Publish(frame, 1, pose, [1, 1])); + Assert.Throws(() => + buffers.Publish(frame, 1, pose, [2])); + Assert.Throws(() => + buffers.Publish( + frame, + 1, + pose, + [], + [], + denseRefresh: false, + bindingSizeBytes: 64u)); + + Assert.Equal(0, buffers.BufferCount); + Assert.Empty(device.OfKind()); + } + + [Fact] + public void ConnectedDenseDemandPublishesBeyondFormer65536CeilingInOneBinding() + { + using var device = new RecordingGpuDevice(); + using var buffers = new DirectionalShadowTransformBufferSet(device); + var transforms = new Matrix4x4[68_395]; + transforms[^1] = Matrix4x4.CreateTranslation(68_394f, 2f, 3f); + using IGpuFrame frame = device.BeginFrame(); + + WorldTransformFrameSlice slice = buffers.Publish( + frame, + topologyBuildSequence: 68_395, + transforms, + ReadOnlySpan.Empty); + + Assert.Equal(68_395u, slice.InstanceCount); + Assert.True(slice.IsValidFor(frame)); + Assert.Equal(WorldTransformCapacityPolicy.InitialBindingSizeBytes, + slice.BindingSizeBytes); + Assert.Same(Assert.Single(device.CreatedBuffers), slice.Buffer); + } + + private static void AssertMatrixBitsEqual( + Matrix4x4 expected, + Matrix4x4 actual) + { + ReadOnlySpan expectedBits = MemoryMarshal.AsBytes( + MemoryMarshal.CreateReadOnlySpan(ref expected, 1)); + ReadOnlySpan actualBits = MemoryMarshal.AsBytes( + MemoryMarshal.CreateReadOnlySpan(ref actual, 1)); + Assert.True(expectedBits.SequenceEqual(actualBits)); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowStartupOptionsTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowStartupOptionsTests.cs new file mode 100644 index 00000000..c28709c6 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/GameWindowStartupOptionsTests.cs @@ -0,0 +1,57 @@ +using AcDream.App.Rendering; +using Silk.NET.Maths; +using Silk.NET.Windowing; + +namespace AcDream.App.Tests.Rendering; + +public sealed class GameWindowStartupOptionsTests +{ + [Fact] + public void OrdinaryStartupPreservesCurrentDecorated1280By720Window() + { + WindowOptions defaults = WindowOptions.DefaultVulkan; + + WindowOptions options = GameWindow.CreateStartupWindowOptions( + exactAutomationFramebuffer: false, + persistedResolution: "3840x2160", + useVSync: true); + + Assert.Equal(new Vector2D(1280, 720), options.Size); + Assert.Equal(defaults.WindowBorder, options.WindowBorder); + Assert.Equal(defaults.IsVisible, options.IsVisible); + Assert.True(options.VSync); + } + + [Theory] + [InlineData("2560x1440", 2560, 1440)] + [InlineData("3840x2160", 3840, 2160)] + public void ExactAutomationStartupUsesRequestedBorderlessClientExtent( + string resolution, + int width, + int height) + { + WindowOptions options = GameWindow.CreateStartupWindowOptions( + exactAutomationFramebuffer: true, + persistedResolution: resolution, + useVSync: false); + + Assert.Equal(new Vector2D(width, height), options.Size); + Assert.Equal(WindowBorder.Hidden, options.WindowBorder); + Assert.False(options.IsVisible); + Assert.False(options.VSync); + } + + [Fact] + public void ExactAutomationStartupRejectsInvalidResolution() + { + InvalidOperationException error = Assert.Throws(() => + GameWindow.CreateStartupWindowOptions( + exactAutomationFramebuffer: true, + persistedResolution: "invalid", + useVSync: false)); + + Assert.Equal( + "Exact automation framebuffer requires a valid persisted resolution.", + error.Message); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs index 02334fba..30bdf975 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs @@ -37,10 +37,9 @@ public sealed class GpuContractTests [Fact] public void StorageBindingsMatchTheShaderSources() { - // mesh_modern.vert declares std430 bindings 0..8 in exactly this order. - // Binding 9 was the GL-only texture handle table added by slice V2; - // Campaign V slice V11 deleted it (StorageTextureTable) along with the - // rest of the raw-GL arm, so 9 is now one past the highest binding. + // mesh_modern.vert declares std430 bindings 0..8 in exactly this order; + // #226's mesh_detail.vert additionally declares binding 9 for the + // per-instance building category. Assert.Equal(0u, GpuBindingModel.StorageInstances); Assert.Equal(1u, GpuBindingModel.StorageBatches); Assert.Equal(2u, GpuBindingModel.StorageClipRegions); @@ -50,7 +49,8 @@ public sealed class GpuContractTests Assert.Equal(6u, GpuBindingModel.StorageInstanceIndoor); Assert.Equal(7u, GpuBindingModel.StorageInstanceAlpha); Assert.Equal(8u, GpuBindingModel.StorageInstanceSelectionLighting); - Assert.Equal(9u, GpuBindingModel.StorageBindingCount); + Assert.Equal(9u, GpuBindingModel.StorageInstanceDetailCategory); + Assert.Equal(10u, GpuBindingModel.StorageBindingCount); } [Fact] @@ -81,11 +81,12 @@ public sealed class GpuContractTests // with only two — slice V4c found the gap. Mapping InvAlpha onto // StraightAlpha would silently change how every inverse-alpha surface // composites, so the contract has to carry all three. - Assert.Equal(4, Enum.GetValues().Length); + Assert.Equal(5, Enum.GetValues().Length); Assert.Contains(GpuBlendMode.None, Enum.GetValues()); Assert.Contains(GpuBlendMode.StraightAlpha, Enum.GetValues()); Assert.Contains(GpuBlendMode.Additive, Enum.GetValues()); Assert.Contains(GpuBlendMode.InverseAlpha, Enum.GetValues()); + Assert.Contains(GpuBlendMode.RetailDetail, Enum.GetValues()); } [Fact] @@ -267,6 +268,27 @@ public sealed class GpuContractTests Assert.True(aspects.HasFlag(Silk.NET.Vulkan.ImageAspectFlags.StencilBit)); } + [Fact] + public void Rgba16FloatRenderTarget_HasExactVulkanFormatAndByteAccounting() + { + Assert.Equal( + Silk.NET.Vulkan.Format.R16G16B16A16Sfloat, + VulkanTextureFormatMapping.FormatOf( + GpuTextureFormat.Rgba16FloatRenderTarget)); + Assert.True(VulkanTextureFormatMapping.IsRenderTarget( + GpuTextureFormat.Rgba16FloatRenderTarget)); + Assert.Equal( + 8, + VulkanTextureFormatMapping.BytesPerTexel( + GpuTextureFormat.Rgba16FloatRenderTarget)); + Assert.Equal( + 1920 * 1080 * 8, + VulkanTextureFormatMapping.LevelSizeBytes( + GpuTextureFormat.Rgba16FloatRenderTarget, + 1920, + 1080)); + } + [Fact] public void UniformBindingsDoNotCollide() { @@ -375,10 +397,17 @@ public sealed class GpuContractTests MinUniformBufferOffsetAlignment = 256, MaxClipDistances = GpuBindingModel.ClipPlanesPerSlot, MaxSampleCount = 8, + MaxImageDimension2D = 16_384, + MaxImageArrayLayers = 2_048, + DeviceLocalMemoryBytes = 8UL * 1024 * 1024 * 1024, SupportsMultiDrawIndirect = true, SupportsDrawParameters = true, SupportsTextureCompressionBc = true, SupportsTimestampQueries = true, SupportsPersistentlyMappedRings = true, + SupportsRgba16FloatRenderTargets = true, + MaxRgba16FloatSampleCount = 8, + SupportsSampledDepth = true, + SupportsMultiview = true, }; } diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs b/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs index 3dafc000..a8afbd27 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs @@ -18,10 +18,14 @@ internal sealed record GpuRecordedFrameEnd(long Serial) : GpuRecordedCall; internal sealed record GpuRecordedRingAllocation(GpuRingUsage Usage, int ByteCount, uint OffsetBytes) : GpuRecordedCall; -internal sealed record GpuRecordedPassBegin(string Name, int SampleCount) : GpuRecordedCall; +internal sealed record GpuRecordedHostStorageVisibility(string BufferName) : GpuRecordedCall; + +internal sealed record GpuRecordedPassBegin(string Name, int SampleCount, uint ViewMask = 0) : GpuRecordedCall; internal sealed record GpuRecordedPassEnd(string Name) : GpuRecordedCall; +internal sealed record GpuRecordedTimerScope(string Name) : GpuRecordedCall; + internal sealed record GpuRecordedPipelineBind(string PipelineName) : GpuRecordedCall; internal sealed record GpuRecordedStorageBind(uint Binding, string BufferName, uint OffsetBytes, uint SizeBytes) @@ -72,13 +76,25 @@ internal sealed record GpuRecordedTextureRegistration(string TextureName, GpuSam internal sealed record GpuRecordedTextureRelease(uint Slot) : GpuRecordedCall; +internal sealed record GpuRecordedRenderTargetCreate(GpuRenderTargetDescription Description) + : GpuRecordedCall; + +internal sealed record GpuRecordedDirectionalDepthTargetCreate( + GpuDirectionalDepthTargetDescription Description) : GpuRecordedCall; + +internal sealed record GpuRecordedPipelineColorFormatAcquire(GpuTextureFormat Format) + : GpuRecordedCall; + +internal sealed record GpuRecordedPipelineColorFormatRelease(GpuTextureFormat Format) + : GpuRecordedCall; + /// /// In-memory that owns no driver objects. Ring /// allocations are backed by a real byte array, so a test can drive a renderer /// and then read back exactly what it wrote — the same bytes a driver would have /// seen. Everything else is recorded into in submission order. /// -internal sealed class RecordingGpuDevice : IGpuDevice +internal sealed class RecordingGpuDevice : IGpuDevice, IGpuPipelineFormatVariantHost { private const int DefaultRingCapacityBytes = 8 * 1024 * 1024; @@ -87,7 +103,10 @@ internal sealed class RecordingGpuDevice : IGpuDevice private readonly List _createdBuffers = []; private readonly List _createdPipelines = []; private readonly List _createdSamplers = []; + private readonly List _createdRenderTargets = []; + private readonly List _createdDirectionalDepthTargets = []; private readonly Dictionary _samplers = []; + private readonly Dictionary _pipelineFormatLeases = []; private readonly byte[] _ring; private readonly Stack _freeTextureSlots = new(); @@ -101,11 +120,13 @@ internal sealed class RecordingGpuDevice : IGpuDevice { ArgumentOutOfRangeException.ThrowIfLessThan(ringCapacityBytes, 1); _ring = new byte[ringCapacityBytes]; - RingBuffer = new RecordingGpuBuffer(new GpuBufferDescription( - "test-ring", - ringCapacityBytes, - GpuBufferUsage.Storage | GpuBufferUsage.Uniform | GpuBufferUsage.Indirect, - GpuMemoryResidency.HostWritable)); + RingBuffer = new RecordingGpuBuffer( + new GpuBufferDescription( + "test-ring", + ringCapacityBytes, + GpuBufferUsage.Storage | GpuBufferUsage.Uniform | GpuBufferUsage.Indirect, + GpuMemoryResidency.HostWritable), + _ring); RecordingGpuTexture placeholder = new("default-white", GpuTextureKind.Texture2D, GpuTextureFormat.Rgba8Unorm, 1, 1, 1, 1); DefaultTextureSlot = RegisterTexture(placeholder, CreateSampler(GpuSamplerDescription.UiNearest)); @@ -136,19 +157,29 @@ internal sealed class RecordingGpuDevice : IGpuDevice MaxStorageBufferBindings = GpuBindingModel.StorageBindingCount, MaxPushConstantBytes = GpuBindingModel.MaxPushConstantBytes, MinStorageBufferOffsetAlignment = 256, + MaxStorageBufferRangeBytes = 128u * 1024u * 1024u, MinUniformBufferOffsetAlignment = 256, MaxClipDistances = GpuBindingModel.ClipPlanesPerSlot, MaxSampleCount = 8, + MaxImageDimension2D = 16_384, + MaxImageArrayLayers = 2_048, + DeviceLocalMemoryBytes = 8UL * 1024 * 1024 * 1024, SupportsMultiDrawIndirect = true, SupportsDrawParameters = true, SupportsTextureCompressionBc = true, SupportsTimestampQueries = true, SupportsPersistentlyMappedRings = true, + SupportsRgba16FloatRenderTargets = true, + MaxRgba16FloatSampleCount = 8, + SupportsSampledDepth = true, + SupportsMultiview = true, }; public IGpuResourceRetirementQueue Retirement => ImmediateGpuResourceRetirementQueue.Instance; - public IGpuTimerPool Timers { get; } = new RecordingGpuTimerPool(); + public RecordingGpuTimerPool RecordingTimers { get; } = new(); + + public IGpuTimerPool Timers => RecordingTimers; public GpuTextureSlot DefaultTextureSlot { get; } @@ -160,6 +191,26 @@ internal sealed class RecordingGpuDevice : IGpuDevice public IReadOnlyList CreatedSamplers => _createdSamplers; + public IReadOnlyList CreatedRenderTargets => _createdRenderTargets; + + public IReadOnlyList CreatedDirectionalDepthTargets => + _createdDirectionalDepthTargets; + + public IReadOnlyDictionary PipelineFormatLeases => + _pipelineFormatLeases; + + /// + /// Optional deterministic allocation fault used to prove candidate target + /// sets roll back atomically. Returning null admits the allocation. + /// + public Func? RenderTargetFailure { get; set; } + + /// + /// Optional deterministic pipeline-construction fault used to prove that + /// renderers retire every partially-created resource. + /// + public Func? PipelineFailure { get; set; } + public IGpuBuffer CreateBuffer(in GpuBufferDescription description) { var buffer = new RecordingGpuBuffer(description); @@ -193,11 +244,12 @@ internal sealed class RecordingGpuDevice : IGpuDevice public IGpuSampler CreateSampler(in GpuSamplerDescription description) { - if (_samplers.TryGetValue(description, out RecordingGpuSampler? existing)) + if (_samplers.TryGetValue(description, out RecordingGpuSampler? existing) + && !existing.IsDisposed) return existing; RecordingGpuSampler created = new(description); - _samplers.Add(description, created); + _samplers[description] = created; _createdSamplers.Add(created); return created; } @@ -205,13 +257,78 @@ internal sealed class RecordingGpuDevice : IGpuDevice public IGpuPipeline CreatePipeline(GpuPipelineDescription description) { ArgumentNullException.ThrowIfNull(description); + if (description.ViewMask != 0 && !Capabilities.SupportsMultiview) + throw new NotSupportedException("Multiview pipelines are unsupported."); + if (PipelineFailure?.Invoke(description) is { } failure) + throw failure; var pipeline = new RecordingGpuPipeline(description); _createdPipelines.Add(pipeline); return pipeline; } - public IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description) => - new RecordingGpuRenderTarget(description); + public IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.SampleCount); + if (description.SampleableDepth && description.DepthFormat is null) + throw new ArgumentException("SampleableDepth requires a depth format.", nameof(description)); + if ((uint)description.SampleCount > Capabilities.MaxSampleCount) + throw new NotSupportedException("The requested sample count is unsupported."); + if (description.ColorFormat == GpuTextureFormat.Rgba16FloatRenderTarget + && (!Capabilities.SupportsRgba16FloatRenderTargets + || (uint)description.SampleCount > Capabilities.MaxRgba16FloatSampleCount)) + { + throw new NotSupportedException("RGBA16F render-target capabilities are insufficient."); + } + if (description.SampleableDepth && !Capabilities.SupportsSampledDepth) + throw new NotSupportedException("Sampled depth is unsupported."); + if (RenderTargetFailure?.Invoke(description) is { } failure) + throw failure; + var target = new RecordingGpuRenderTarget(description); + _createdRenderTargets.Add(target); + _calls.Add(new GpuRecordedRenderTargetCreate(description)); + return target; + } + + public IGpuDirectionalDepthTarget CreateDirectionalDepthTarget( + in GpuDirectionalDepthTargetDescription description) + { + ArgumentException.ThrowIfNullOrWhiteSpace(description.Name); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Resolution); + if (description.LayerCount is < 2 or > 4) + throw new ArgumentOutOfRangeException(nameof(description)); + if (description.DepthFormat != GpuTextureFormat.Depth24Stencil8) + throw new ArgumentException("Directional depth requires Depth24Stencil8.", nameof(description)); + if (!Capabilities.SupportsSampledDepth) + throw new NotSupportedException("Sampled depth is unsupported."); + + var target = new RecordingGpuDirectionalDepthTarget(description); + _createdDirectionalDepthTargets.Add(target); + _calls.Add(new GpuRecordedDirectionalDepthTargetCreate(description)); + return target; + } + + public IDisposable AcquirePipelineColorFormat(GpuTextureFormat format) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (format == GpuTextureFormat.Rgba16FloatRenderTarget + && !Capabilities.SupportsRgba16FloatRenderTargets) + throw new NotSupportedException("RGBA16F render targets are unsupported."); + _pipelineFormatLeases.TryGetValue(format, out int count); + _pipelineFormatLeases[format] = checked(count + 1); + _calls.Add(new GpuRecordedPipelineColorFormatAcquire(format)); + return new RecordingPipelineColorFormatLease(this, format); + } + + private void ReleasePipelineColorFormat(GpuTextureFormat format) + { + if (!_pipelineFormatLeases.TryGetValue(format, out int count)) + return; + if (count == 1) + _pipelineFormatLeases.Remove(format); + else + _pipelineFormatLeases[format] = count - 1; + _calls.Add(new GpuRecordedPipelineColorFormatRelease(format)); + } public GpuTextureSlot RegisterTexture(IGpuTexture texture, IGpuSampler sampler) { @@ -311,6 +428,16 @@ internal sealed class RecordingGpuDevice : IGpuDevice private static uint AlignUp(uint value, uint alignment) => alignment <= 1 ? value : (value + alignment - 1) / alignment * alignment; + + private sealed class RecordingPipelineColorFormatLease( + RecordingGpuDevice device, + GpuTextureFormat format) : IDisposable + { + private RecordingGpuDevice? _device = device; + + public void Dispose() => + Interlocked.Exchange(ref _device, null)?.ReleasePipelineColorFormat(format); + } } internal sealed class RecordingGpuFrame(RecordingGpuDevice device, long serial, int slotIndex) : IGpuFrame @@ -323,10 +450,77 @@ internal sealed class RecordingGpuFrame(RecordingGpuDevice device, long serial, public GpuRingAllocation AllocateRing(int byteCount, GpuRingUsage usage) => device.Allocate(byteCount, usage); + public void PublishHostStorageWrites(IGpuBuffer buffer) + { + ArgumentNullException.ThrowIfNull(buffer); + if (buffer.Residency != GpuMemoryResidency.HostWritable + || !buffer.Usage.HasFlag(GpuBufferUsage.Storage)) + { + throw new ArgumentException( + "Published host writes require a host-writable storage buffer.", + nameof(buffer)); + } + device.Record(new GpuRecordedHostStorageVisibility(buffer.Name)); + } + public IGpuPassEncoder BeginPass(GpuPassDescription description) { ArgumentNullException.ThrowIfNull(description); - device.Record(new GpuRecordedPassBegin(description.Name, description.SampleCount)); + if (!description.HasColorAttachment) + { + if (description.SampleCount != 1 + || description.Depth is not { DirectionalTarget: RecordingGpuDirectionalDepthTarget directionalTarget } depth) + { + throw new InvalidOperationException( + "A colour-less recording pass requires a single-sampled directional-depth target."); + } + if (depth.Layer < 0 || depth.Layer >= directionalTarget.Description.LayerCount) + throw new ArgumentOutOfRangeException(nameof(description)); + if (description.ViewMask != 0) + { + uint expected = (1u << directionalTarget.Description.LayerCount) - 1u; + if (description.ViewMask != expected || !device.Capabilities.SupportsMultiview) + throw new NotSupportedException("Directional multiview requires every target layer and device support."); + } + if (depth.Store != GpuStoreOp.Store) + throw new InvalidOperationException("Directional depth must be stored for sampling."); + } + if (description.Color.Target is RecordingGpuRenderTarget target) + { + if (description.SampleCount != target.Description.SampleCount) + { + throw new InvalidOperationException( + "Pass and offscreen-target sample counts must match."); + } + if (target.UsesMultisampleResolve && description.Color.Store != GpuStoreOp.Resolve) + { + throw new InvalidOperationException( + "A multisampled offscreen target must resolve into ColorTexture."); + } + if (target.UsesMultisampleResolve && description.Color.Load == GpuLoadOp.Load) + { + throw new InvalidOperationException( + "A transient multisample colour attachment cannot load the prior resolved image."); + } + if (!target.UsesMultisampleResolve && description.Color.Store == GpuStoreOp.Resolve) + { + throw new InvalidOperationException( + "A single-sampled offscreen target cannot use Store=Resolve."); + } + if (target.UsesMultisampleResolve && description.Depth?.Load == GpuLoadOp.Load) + { + throw new InvalidOperationException( + "A transient multisample depth attachment cannot load prior depth."); + } + if (target.Description.SampleableDepth + && description.Depth is { } + && description.Depth?.Store != GpuStoreOp.Store) + { + throw new InvalidOperationException( + "Sampleable depth requires Store=Store."); + } + } + device.Record(new GpuRecordedPassBegin(description.Name, description.SampleCount, description.ViewMask)); return new RecordingGpuPassEncoder(device, description); } @@ -351,6 +545,10 @@ internal sealed class RecordingGpuPassEncoder(RecordingGpuDevice device, GpuPass public void BindPipeline(IGpuPipeline pipeline) { ArgumentNullException.ThrowIfNull(pipeline); + if (pipeline.Description.HasColorAttachment != Pass.HasColorAttachment) + throw new InvalidOperationException("Pipeline and pass colour-attachment intents must match."); + if (pipeline.Description.ViewMask != Pass.ViewMask) + throw new InvalidOperationException("Pipeline and pass view masks must match."); device.Record(new GpuRecordedPipelineBind(pipeline.Description.Name)); } @@ -407,7 +605,11 @@ internal sealed class RecordingGpuPassEncoder(RecordingGpuDevice device, GpuPass device.Record(new GpuRecordedMultiDrawIndirect(commands.Name, offsetBytes, drawCount, strideBytes)); } - public IDisposable BeginTimerScope(string scopeName) => NullDisposable.Instance; + public IDisposable BeginTimerScope(string scopeName) + { + device.Record(new GpuRecordedTimerScope(scopeName)); + return NullDisposable.Instance; + } public void Dispose() { @@ -428,22 +630,50 @@ internal sealed class RecordingGpuPassEncoder(RecordingGpuDevice device, GpuPass } } -internal sealed class RecordingGpuBuffer(GpuBufferDescription description) : IGpuBuffer +internal sealed class RecordingGpuBuffer : IGpuBuffer { - private readonly byte[] _storage = new byte[description.SizeBytes]; + private readonly byte[] _storage; - public string Name { get; } = description.Name; + internal RecordingGpuBuffer( + GpuBufferDescription description, + byte[]? storage = null) + { + if (storage is not null && storage.Length != description.SizeBytes) + { + throw new ArgumentException( + "External recording storage must match the buffer size.", + nameof(storage)); + } + _storage = storage ?? new byte[description.SizeBytes]; + Name = description.Name; + SizeBytes = description.SizeBytes; + Usage = description.Usage; + Residency = description.Residency; + } - public long SizeBytes { get; } = description.SizeBytes; + public string Name { get; } - public GpuBufferUsage Usage { get; } = description.Usage; + public long SizeBytes { get; } - public GpuMemoryResidency Residency { get; } = description.Residency; + public GpuBufferUsage Usage { get; } + + public GpuMemoryResidency Residency { get; } + + public bool HostWritesAreCoherent => + Residency == GpuMemoryResidency.HostWritable; public bool IsDisposed { get; private set; } - public void Upload(long offsetBytes, ReadOnlySpan data) => + public int UploadCount { get; private set; } + + public long UploadedBytes { get; private set; } + + public void Upload(long offsetBytes, ReadOnlySpan data) + { data.CopyTo(_storage.AsSpan((int)offsetBytes, data.Length)); + UploadCount++; + UploadedBytes = checked(UploadedBytes + data.Length); + } public void CopyTo(IGpuBuffer destination, long sourceOffsetBytes, long destinationOffsetBytes, long byteCount) { @@ -531,25 +761,95 @@ internal sealed class RecordingGpuRenderTarget : IGpuRenderTarget description.Height, layerCount: 1, mipLevelCount: 1); + if (description.SampleableDepth && description.DepthFormat is { } depthFormat) + { + DepthTexture = new RecordingGpuTexture( + $"{description.Name}-depth", + GpuTextureKind.Texture2D, + depthFormat, + description.Width, + description.Height, + layerCount: 1, + mipLevelCount: 1); + } } public GpuRenderTargetDescription Description { get; } public IGpuTexture ColorTexture { get; } + public IGpuTexture? DepthTexture { get; } + + /// The pass attachment sample count; exposed textures are always single-sampled. + public int AttachmentSampleCount => Description.SampleCount; + + public bool UsesMultisampleResolve => Description.SampleCount > 1; + public bool IsDisposed { get; private set; } - public void Dispose() => IsDisposed = true; + public void Dispose() + { + if (IsDisposed) + return; + IsDisposed = true; + ColorTexture.Dispose(); + DepthTexture?.Dispose(); + } + +} + +internal sealed class RecordingGpuDirectionalDepthTarget : IGpuDirectionalDepthTarget +{ + public RecordingGpuDirectionalDepthTarget(GpuDirectionalDepthTargetDescription description) + { + Description = description; + DepthTexture = new RecordingGpuTexture( + $"{description.Name}-depth", + GpuTextureKind.Texture2DArray, + description.DepthFormat, + description.Resolution, + description.Resolution, + description.LayerCount, + mipLevelCount: 1); + } + + public GpuDirectionalDepthTargetDescription Description { get; } + + public IGpuTexture DepthTexture { get; } + + public bool IsDisposed { get; private set; } + + public void Dispose() + { + if (IsDisposed) + return; + IsDisposed = true; + DepthTexture.Dispose(); + } } internal sealed class RecordingGpuTimerPool : IGpuTimerPool { - public bool IsSupported => false; + private readonly Dictionary _resolved = new(StringComparer.Ordinal); + + public bool IsSupported => true; + + internal void SetResolved(string scopeName, double milliseconds) => + _resolved[scopeName] = milliseconds; + + internal void ClearResolved() => _resolved.Clear(); public bool TryResolve(string scopeName, out double milliseconds) { - milliseconds = 0d; - return false; + return _resolved.TryGetValue(scopeName, out milliseconds); + } + + public bool TryTakeResolved(string scopeName, out double milliseconds) + { + if (!_resolved.TryGetValue(scopeName, out milliseconds)) + return false; + _resolved.Remove(scopeName); + return true; } } diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDeviceTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDeviceTests.cs index f184fe50..3f65cbe4 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDeviceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDeviceTests.cs @@ -140,6 +140,20 @@ public sealed class RecordingGpuDeviceTests Assert.Equal(0, device.OpenFrameCount); } + [Fact] + public void TimerMeasurementsCanBeInspectedOrConsumedExactlyOnce() + { + using RecordingGpuDevice device = new(); + device.RecordingTimers.SetResolved("pack-pass", 1.25); + + Assert.True(device.Timers.TryResolve("pack-pass", out double inspected)); + Assert.Equal(1.25, inspected); + Assert.True(device.Timers.TryTakeResolved("pack-pass", out double consumed)); + Assert.Equal(1.25, consumed); + Assert.False(device.Timers.TryTakeResolved("pack-pass", out _)); + Assert.False(device.Timers.TryResolve("pack-pass", out _)); + } + [Fact] public void ReleasedTextureSlotsAreRecycledRatherThanLeaked() { @@ -177,6 +191,98 @@ public sealed class RecordingGpuDeviceTests Assert.True(device.DefaultTextureSlot.IsAssigned); } + [Fact] + public void MultisampledHdrTargetExposesSingleSampledColorAndOptionalDepthResults() + { + using RecordingGpuDevice device = new(); + device.Clear(); + var description = new GpuRenderTargetDescription( + "hdr-world", + 1920, + 1080, + GpuTextureFormat.Rgba16FloatRenderTarget, + GpuTextureFormat.Depth24Stencil8, + SampleCount: 4, + SampleableDepth: true); + + var target = Assert.IsType( + device.CreateRenderTarget(description)); + + Assert.Equal(description, target.Description); + Assert.Equal(GpuTextureFormat.Rgba16FloatRenderTarget, target.ColorTexture.Format); + Assert.NotNull(target.DepthTexture); + Assert.Equal(GpuTextureFormat.Depth24Stencil8, target.DepthTexture!.Format); + Assert.Equal(4, target.AttachmentSampleCount); + Assert.True(target.UsesMultisampleResolve); + Assert.Same(target, Assert.Single(device.CreatedRenderTargets)); + Assert.Equal( + new GpuRecordedRenderTargetCreate(description), + Assert.Single(device.Calls)); + + using IGpuFrame frame = device.BeginFrame(); + using (frame.BeginPass(new GpuPassDescription + { + Name = "hdr-world", + Color = new GpuColorAttachment( + target, + GpuLoadOp.Clear, + GpuStoreOp.Resolve, + Vector4.Zero), + Depth = new GpuDepthAttachment( + GpuLoadOp.Clear, + GpuStoreOp.Store, + 1f, + 0), + SampleCount = 4, + })) + { + } + frame.End(); + + target.Dispose(); + Assert.True(target.IsDisposed); + Assert.True(Assert.IsType(target.ColorTexture).IsDisposed); + Assert.True(Assert.IsType(target.DepthTexture).IsDisposed); + } + + [Fact] + public void AttachmentOnlyDepthIsNotExposedAndInvalidResolveContractsFailLoudly() + { + using RecordingGpuDevice device = new(); + var target = Assert.IsType( + device.CreateRenderTarget(new GpuRenderTargetDescription( + "ordinary-offscreen", + 320, + 240, + GpuTextureFormat.Rgba8UnormRenderTarget, + GpuTextureFormat.Depth24Stencil8, + SampleCount: 1))); + Assert.Null(target.DepthTexture); + + using IGpuFrame frame = device.BeginFrame(); + Assert.Throws(() => frame.BeginPass(new GpuPassDescription + { + Name = "invalid-resolve", + Color = new GpuColorAttachment( + target, + GpuLoadOp.Clear, + GpuStoreOp.Resolve, + Vector4.Zero), + SampleCount = 1, + })); + frame.End(); + + Assert.Throws(() => device.CreateRenderTarget( + new GpuRenderTargetDescription( + "missing-depth", + 16, + 16, + GpuTextureFormat.Rgba16FloatRenderTarget, + DepthFormat: null, + SampleCount: 1, + SampleableDepth: true))); + } + [Fact] public void SamplersAreDeduplicatedByValue() { diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanCapabilityGateTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanCapabilityGateTests.cs index ccab0552..c3fda2bd 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanCapabilityGateTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanCapabilityGateTests.cs @@ -90,8 +90,30 @@ public sealed class VulkanCapabilityGateTests Assert.True(record.IsSupported); } + [Fact] + public void OptionalAtmosphericFormatsDoNotRejectTheRetailRenderer() + { + VulkanCapabilityRecord record = SupportedRecord( + formats: VulkanFormatSupport.Complete with + { + DepthStencilSampled = false, + Rgba16FloatColorAttachment = false, + Rgba16FloatSampled = false, + Rgba16FloatLinearFilter = false, + // A colour-only MSAA fact is not enough when sampling/filtering + // is absent; the neutral projection must still expose zero. + MaxRgba16FloatSampleCount = 8, + }); + + Assert.Empty(record.SupportFailures); + GpuCapabilityRecord projected = record.ToGpuCapabilityRecord(); + Assert.False(projected.SupportsRgba16FloatRenderTargets); + Assert.Equal(0u, projected.MaxRgba16FloatSampleCount); + Assert.False(projected.SupportsSampledDepth); + } + /// - /// Every field on is mandatory, so + /// Every required field on is mandatory, so /// clearing any one of them must produce exactly one new failure. Driving /// this by reflection rather than by hand means a feature added to the record /// without a matching Evaluate clause fails here instead of shipping @@ -103,6 +125,7 @@ public sealed class VulkanCapabilityGateTests IEnumerable featureNames = typeof(VulkanDeviceFeatureSupport) .GetProperties() .Where(property => property.PropertyType == typeof(bool)) + .Where(property => property.Name != nameof(VulkanDeviceFeatureSupport.Multiview)) .Select(property => property.Name); foreach (string name in featureNames) @@ -219,10 +242,11 @@ public sealed class VulkanCapabilityGateTests Assert.False(VulkanPipelineLayouts.IsDynamicStorageBinding(GpuBindingModel.StorageGlobalLights)); Assert.False(VulkanPipelineLayouts.IsDynamicStorageBinding(GpuBindingModel.StorageClipRegions)); - // Binding 9 (the GL-only uvec2 handle-table emulation, StorageTextureTable) - // is deleted as of Campaign V slice V11 — the Vulkan backend always bound - // set 2 instead and never touched it, so there is no longer a ninth - // binding to assert never spends a scarce dynamic descriptor. + // #226 adds binding 9 for the per-instance detail category. It is a + // plain storage descriptor because the renderer supplies its exact + // ring slice through the descriptor write rather than a dynamic offset. + Assert.False(VulkanPipelineLayouts.IsDynamicStorageBinding( + GpuBindingModel.StorageInstanceDetailCategory)); } [Fact] @@ -250,6 +274,41 @@ public sealed class VulkanCapabilityGateTests Assert.Empty(SupportedRecord().SupportFailures); } + [Fact] + public void MultiviewIsProjectedButDoesNotRejectTheAuthoritativeRenderer() + { + VulkanDeviceFeatureSupport reduced = + VulkanDeviceFeatureSupport.Complete with { Multiview = false }; + VulkanCapabilityRecord record = SupportedRecord(features: reduced); + Assert.True(record.IsSupported); + Assert.False(record.ToGpuCapabilityRecord().SupportsMultiview); + } + + [Fact] + public void ADeviceWithTooFewTotalStorageDescriptorsIsRejectedPerSetAndPerStage() + { + VulkanCapabilityRecord perSet = SupportedRecord( + limits: VulkanDeviceLimitSupport.Complete with + { + MaxDescriptorSetStorageBuffers = + GpuBindingModel.StorageBindingCount - 1, + }); + VulkanCapabilityRecord perStage = SupportedRecord( + limits: VulkanDeviceLimitSupport.Complete with + { + MaxPerStageDescriptorStorageBuffers = + GpuBindingModel.StorageBindingCount - 1, + }); + + Assert.Contains( + perSet.SupportFailures, + failure => failure.Contains("total storage bindings", StringComparison.Ordinal)); + Assert.Contains( + perStage.SupportFailures, + failure => failure.Contains("each shader stage", StringComparison.Ordinal)); + Assert.Empty(SupportedRecord().SupportFailures); + } + [Fact] public void ATextureTableSmallerThanTheCapacityIsRejectedPerSetAndPerStage() { @@ -546,8 +605,14 @@ public sealed class VulkanCapabilityGateTests limits: VulkanDeviceLimitSupport.Complete with { MinStorageBufferOffsetAlignment = 16, + MaxStorageBufferRange = 192u * 1024u * 1024u, MinUniformBufferOffsetAlignment = 64, MaxColorSampleCount = 4, + MaxImageDimension2D = 8192, + MaxImageArrayLayers = 128, + DeviceLocalHeapBytes = 6UL * 1024 * 1024 * 1024, + MaxDescriptorSetStorageBuffers = 48, + MaxPerStageDescriptorStorageBuffers = 32, MaxDescriptorSetUpdateAfterBindSampledImages = 500_000, MaxPerStageDescriptorUpdateAfterBindSampledImages = 16_384, }); @@ -558,11 +623,15 @@ public sealed class VulkanCapabilityGateTests Assert.Equal("AMD Radeon RX 9070 XT", projected.DeviceName); Assert.Equal("Vulkan 1.3.280", projected.ApiVersion); Assert.Equal(16u, projected.MinStorageBufferOffsetAlignment); + Assert.Equal(192u * 1024u * 1024u, projected.MaxStorageBufferRangeBytes); Assert.Equal(64u, projected.MinUniformBufferOffsetAlignment); Assert.Equal(4u, projected.MaxSampleCount); + Assert.Equal(8192u, projected.MaxImageDimension2D); + Assert.Equal(128u, projected.MaxImageArrayLayers); + Assert.Equal(6UL * 1024 * 1024 * 1024, projected.DeviceLocalMemoryBytes); // The table is limited by whichever of the two counts is smaller. Assert.Equal(16_384u, projected.MaxTextureTableSlots); - Assert.Equal(GpuBindingModel.StorageBindingCount, projected.MaxStorageBufferBindings); + Assert.Equal(32u, projected.MaxStorageBufferBindings); Assert.True(projected.SupportsMultiDrawIndirect); Assert.True(projected.SupportsDrawParameters); Assert.True(projected.SupportsTextureCompressionBc); @@ -570,6 +639,10 @@ public sealed class VulkanCapabilityGateTests // The whole point of the campaign's CPU target: per-frame data written // straight into mapped memory rather than copied through BufferSubData. Assert.True(projected.SupportsPersistentlyMappedRings); + Assert.True(projected.SupportsRgba16FloatRenderTargets); + Assert.Equal(4u, projected.MaxRgba16FloatSampleCount); + Assert.True(projected.SupportsSampledDepth); + Assert.True(projected.SupportsMultiview); Assert.Empty(projected.SupportFailures); } diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanDirectionalMultiviewContractTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanDirectionalMultiviewContractTests.cs new file mode 100644 index 00000000..1f88b75b --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanDirectionalMultiviewContractTests.cs @@ -0,0 +1,27 @@ +using AcDream.App.Rendering.Gpu.Vk; + +namespace AcDream.App.Tests.Rendering.Gpu.Vk; + +public sealed class VulkanDirectionalMultiviewContractTests +{ + [Fact] + public void LowMaskCoversBothAttachmentLayersInOneBarrierRange() + { + VulkanDirectionalMultiviewRange range = + VulkanDirectionalMultiviewContract.Resolve(0b11, 2); + + Assert.Equal(0u, range.BaseLayer); + Assert.Equal(2u, range.LayerCount); + } + + [Theory] + [InlineData(0u)] + [InlineData(0b01u)] + [InlineData(0b10u)] + [InlineData(0b111u)] + public void PartialOrExtraMaskFailsBeforeRecording(uint viewMask) + { + Assert.Throws(() => + VulkanDirectionalMultiviewContract.Resolve(viewMask, 2)); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanDrawBindingStateTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanDrawBindingStateTests.cs new file mode 100644 index 00000000..509524a7 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanDrawBindingStateTests.cs @@ -0,0 +1,52 @@ +using AcDream.App.Rendering.Gpu.Vk; + +namespace AcDream.App.Tests.Rendering.Gpu.Vk; + +public sealed class VulkanDrawBindingStateTests +{ + [Fact] + public void FirstDrawBinds_IdenticalDrawReuses_ChangedInputsRebind() + { + var state = new VulkanDrawBindingState(); + + Assert.True(state.RequiresBind(pipelineLayout: 10, packGeneration: 3)); + state.MarkBound(pipelineLayout: 10, packGeneration: 3); + Assert.False(state.RequiresBind(pipelineLayout: 10, packGeneration: 3)); + + state.MarkDirty(); + Assert.True(state.RequiresBind(pipelineLayout: 10, packGeneration: 3)); + state.MarkBound(pipelineLayout: 10, packGeneration: 3); + Assert.True(state.RequiresBind(pipelineLayout: 11, packGeneration: 3)); + Assert.True(state.RequiresBind(pipelineLayout: 10, packGeneration: 4)); + } + + [Fact] + public void PackAndRetailLayoutsCannotReuseEachOthersBinding() + { + var state = new VulkanDrawBindingState(); + + state.MarkBound(pipelineLayout: 20, packGeneration: 7); + + Assert.True(state.RequiresBind(pipelineLayout: 21, packGeneration: 0)); + state.MarkBound(pipelineLayout: 21, packGeneration: 0); + Assert.True(state.RequiresBind(pipelineLayout: 20, packGeneration: 7)); + } + + [Fact] + public void WarmChecksAllocateZero() + { + var state = new VulkanDrawBindingState(); + state.MarkBound(pipelineLayout: 30, packGeneration: 8); + + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int iteration = 0; iteration < 10_000; iteration++) + { + Assert.False(state.RequiresBind( + pipelineLayout: 30, + packGeneration: 8)); + } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(0, allocated); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanGraphicsContextAcquisitionTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanGraphicsContextAcquisitionTests.cs new file mode 100644 index 00000000..698bad53 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanGraphicsContextAcquisitionTests.cs @@ -0,0 +1,61 @@ +using System.Reflection; +using System.Reflection.Emit; +using AcDream.App.Rendering.Gpu.Vk; +using AcDream.App.Tests.Architecture; + +namespace AcDream.App.Tests.Rendering.Gpu.Vk; + +public sealed class VulkanGraphicsContextAcquisitionTests +{ + [Fact] + public void ProductionAcquireProbesSelectedPhysicalDeviceBeforeLogicalDeviceCreation() + { + MethodInfo acquire = RequiredMethod(nameof(VulkanGraphicsContext.Acquire)); + IReadOnlyList acquireCalls = CompiledCallGraph.Read(acquire); + int createInstance = IndexOf(acquireCalls, "CreateInstanceAndSurface"); + int selectAndGate = IndexOf(acquireCalls, "SelectDeviceAndGate"); + int createRhiDevice = IndexOf(acquireCalls, "CreateDevice"); + + Assert.True(createInstance >= 0); + Assert.True(selectAndGate > createInstance); + Assert.True(createRhiDevice > selectAndGate); + + MethodInfo select = RequiredMethod("SelectDeviceAndGate"); + CompiledCall featureProbe = Assert.Single( + CompiledCallGraph.Read(select), + call => call.Target.DeclaringType == typeof(VulkanPhysicalDeviceInspector) + && call.Target.Name == nameof(VulkanPhysicalDeviceInspector.ReadFeatures)); + CompiledCall logicalDeviceCreate = Assert.Single( + CompiledCallGraph.Read(select), + call => call.Target.DeclaringType == typeof(VulkanLogicalDeviceFactory) + && call.Target.Name == nameof(VulkanLogicalDeviceFactory.Create)); + FieldInfo features = typeof(VulkanGraphicsContext).GetField( + "_features", + BindingFlags.Instance | BindingFlags.NonPublic)!; + CompiledFieldReference featurePublication = Assert.Single( + CompiledCallGraph.ReadFieldReferences(select), + reference => reference.Field == features && reference.OpCode == OpCodes.Stfld); + + Assert.True( + featureProbe.Offset < featurePublication.Offset, + "The selected physical-device feature probe must precede publication to the context."); + Assert.True( + featurePublication.Offset < logicalDeviceCreate.Offset, + "The production acquisition path must publish probed features before logical-device creation consumes them."); + } + + private static MethodInfo RequiredMethod(string name) => + typeof(VulkanGraphicsContext).GetMethod( + name, + BindingFlags.Static | BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Missing VulkanGraphicsContext.{name}."); + + private static int IndexOf(IReadOnlyList calls, string methodName) => + calls + .Select((call, index) => (call, index)) + .Where(value => value.call.Target.DeclaringType == typeof(VulkanGraphicsContext) + && value.call.Target.Name == methodName) + .Select(value => value.index) + .DefaultIfEmpty(-1) + .Single(); +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanHostStorageVisibilityTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanHostStorageVisibilityTests.cs new file mode 100644 index 00000000..b268c893 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanHostStorageVisibilityTests.cs @@ -0,0 +1,32 @@ +using AcDream.App.Rendering.Gpu.Vk; +using Silk.NET.Vulkan; + +namespace AcDream.App.Tests.Rendering.Gpu.Vk; + +public sealed class VulkanHostStorageVisibilityTests +{ + [Fact] + public void RetainedTransformBarrier_PublishesHostWritesToVertexShaderReads() + { + BufferMemoryBarrier2 barrier = VulkanHostStorageVisibility.Create( + default, + 4u * 1024u * 1024u); + + Assert.Equal(StructureType.BufferMemoryBarrier2, barrier.SType); + Assert.Equal(PipelineStageFlags2.HostBit, barrier.SrcStageMask); + Assert.Equal(AccessFlags2.HostWriteBit, barrier.SrcAccessMask); + Assert.Equal(PipelineStageFlags2.VertexShaderBit, barrier.DstStageMask); + Assert.Equal(AccessFlags2.ShaderReadBit, barrier.DstAccessMask); + Assert.Equal(Silk.NET.Vulkan.Vk.QueueFamilyIgnored, barrier.SrcQueueFamilyIndex); + Assert.Equal(Silk.NET.Vulkan.Vk.QueueFamilyIgnored, barrier.DstQueueFamilyIndex); + Assert.Equal(0ul, barrier.Offset); + Assert.Equal(4ul * 1024ul * 1024ul, barrier.Size); + } + + [Fact] + public void RetainedTransformBarrier_RejectsAnEmptyBinding() + { + Assert.Throws(() => + VulkanHostStorageVisibility.Create(default, 0)); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanRenderFailurePolicyTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanRenderFailurePolicyTests.cs new file mode 100644 index 00000000..f5b48373 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanRenderFailurePolicyTests.cs @@ -0,0 +1,37 @@ +using AcDream.App.Rendering.Gpu.Vk; +using Silk.NET.Vulkan; + +namespace AcDream.App.Tests.Rendering.Gpu.Vk; + +public sealed class VulkanRenderFailurePolicyTests +{ + [Fact] + public void ManagedOutOfMemoryAndNestedFatalFailuresAreTerminal() + { + Assert.True(VulkanRenderFailurePolicy.IsFatal(new OutOfMemoryException("managed"))); + Assert.True(VulkanRenderFailurePolicy.IsFatal(new InvalidOperationException( + "wrapper", + new VulkanCallException("nested", Result.ErrorDeviceLost)))); + Assert.True(VulkanRenderFailurePolicy.IsFatal(new AggregateException( + new InvalidOperationException("ordinary"), + new VulkanCallException("nested", Result.ErrorOutOfDeviceMemory)))); + } + + [Theory] + [InlineData(Result.ErrorDeviceLost)] + [InlineData(Result.ErrorOutOfHostMemory)] + [InlineData(Result.ErrorOutOfDeviceMemory)] + [InlineData(Result.ErrorSurfaceLostKhr)] + public void TerminalVulkanResultsCannotFallBackToAnotherGraph(Result result) => + Assert.True(VulkanRenderFailurePolicy.IsFatal( + new VulkanCallException("test", result))); + + [Fact] + public void OrdinaryPackAndNonTerminalVulkanFailuresMayBeQuarantined() + { + Assert.False(VulkanRenderFailurePolicy.IsFatal( + new InvalidOperationException("pack bug"))); + Assert.False(VulkanRenderFailurePolicy.IsFatal( + new VulkanCallException("pack pipeline", Result.ErrorFormatNotSupported))); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderDescriptorContractTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderDescriptorContractTests.cs index 53d3c5c0..8d0c8487 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderDescriptorContractTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderDescriptorContractTests.cs @@ -126,6 +126,12 @@ public sealed class VulkanShaderDescriptorContractTests .SelectMany(ReadResources), ]; + private static bool IsOptInPackShaderModule(string module) => + module.StartsWith("atmospheric_", StringComparison.Ordinal) + || module.StartsWith("directional_shadow_", StringComparison.Ordinal) + || module.StartsWith("mesh_atmospheric.", StringComparison.Ordinal) + || module.StartsWith("terrain_atmospheric.", StringComparison.Ordinal); + /// /// The regression itself, named. TerrainClip is the only uniform block /// terrain_modern.vert declares besides SceneLighting, so @@ -153,8 +159,8 @@ public sealed class VulkanShaderDescriptorContractTests /// /// The general rule the specific case is an instance of: a uniform block may - /// only live at a binding - /// actually declares, in the set it declares them in. + /// only live in retail set 1 at a retail binding, or in opt-in set 3 at one + /// of render-pack ABI v1's sparse bindings 5..8. /// [Fact] public void EveryUniformBlockLandsAtADeclaredUniformBinding() @@ -164,13 +170,31 @@ public sealed class VulkanShaderDescriptorContractTests .. AllResources() .Where(r => r.StorageClass == SpirvStorageClass.Uniform) .Where(r => - r.Set != GpuBindingModel.UniformSet - || r.Binding is null - || !VulkanPipelineLayouts.IsDeclaredUniformBinding(r.Binding.Value)) + r.Binding is null + || !(r.Set == GpuBindingModel.UniformSet + && VulkanPipelineLayouts.IsDeclaredUniformBinding(r.Binding.Value)) + && !(r.Set == GpuBindingModel.RenderPackUniformSet + && VulkanPipelineLayouts.IsDeclaredPackUniformBinding(r.Binding.Value))) .Select(r => $"{r.Module}: uniform block %{r.Id} is at set {r.Set?.ToString() ?? "(none)"} " - + $"binding {r.Binding?.ToString() ?? "(none)"}; set 1 declares " - + $"[{string.Join(", ", VulkanPipelineLayouts.DeclaredUniformBindings)}]."), + + $"binding {r.Binding?.ToString() ?? "(none)"}; retail set 1 declares " + + $"[{string.Join(", ", VulkanPipelineLayouts.DeclaredUniformBindings)}] " + + "and opt-in set 3 declares [5, 6, 7, 8]."), + ]; + + Assert.Empty(violations); + } + + [Fact] + public void RetailShaderModulesNeverDeclareOptInSetThree() + { + string[] violations = + [ + .. AllResources() + .Where(r => r.Set == GpuBindingModel.RenderPackUniformSet) + .Where(r => !IsOptInPackShaderModule(r.Module)) + .Select(r => + $"{r.Module}: resource %{r.Id} declares opt-in set 3 binding {r.Binding}."), ]; Assert.Empty(violations); diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs index 3e17e00a..dfb4fc1f 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs @@ -31,6 +31,32 @@ namespace AcDream.App.Tests.Rendering.Gpu.Vk; /// public sealed class VulkanShaderManifestTests { + // Exact binaries from the pre-campaign retail-authoritative renderer at + // 5ca029d3. New opt-in pack shaders may be added, but recompiling these + // with a different toolchain is itself an unreviewed default-path change. + private static readonly IReadOnlyDictionary RetailOracleSpirvSha256 = + new Dictionary(StringComparer.Ordinal) + { + ["debug_line.frag.spv"] = "02fc04880bc5eb74353566f914675244038125c71443964decdc28e8199264df", + ["debug_line.vert.spv"] = "f9c6a9b575bb07a426fb6ade677bca96a7752ca6b120e8f6451363ba73b51140", + ["mesh_modern.frag.spv"] = "b702b644862aca31ce1fb0677adc5872b39c4ea87f595a89363b44d10f2cc50e", + ["mesh_modern.vert.spv"] = "7ca5fb241c4f0248884ba8fa88fbae17a7d5cbc80efe4ac4a9ffd0254012ead8", + ["particle.frag.spv"] = "680da227704e0b3afa9b5226a7d73dd65aa9d8759d081cf4d5009d30e148726b", + ["particle.vert.spv"] = "ed79461ab347bf17edaca714bbbbfabead8192e059c760578ca3a1a01409799e", + ["particle_mesh.frag.spv"] = "7696b1dc0613b5a724c55df465173f613ae047da9675895b149b7c71b009cc7c", + ["particle_mesh.vert.spv"] = "f7fe8b203cadcd4d54af5cdbcfd9d5bf733146e10bafa78ca730fb6970db0479", + ["portal_depth.frag.spv"] = "96755196d4d0da7be4792107557465778be2ebefb5584834cc75bf90ec55a6cc", + ["portal_depth.vert.spv"] = "cd113860b7acd6afad3ebcc0a68dd7147f6baae729df51ab360c123588dc3ae2", + ["sky.frag.spv"] = "ae0d9e3e1e1b5742dd986cb39c62ea6e71e19783feea8b86a5cd940504d6047e", + ["sky.vert.spv"] = "77176cf33c761ee4e9730357895c941dbf5949d8e0d28e0bb0dcde87f4d30288", + ["terrain_modern.frag.spv"] = "7b3cdb01b837ed77ee20559a81c1ce5c9d5395300efcc072560ab0be3c5a1af9", + ["terrain_modern.vert.spv"] = "9f4cb221ea6aed94a8d23af6cb8e3f3ed96c3cce6e50d135a72d3b55667b1557", + ["ui_text.frag.spv"] = "37a281bf80441cb425eaa3ad8e0b3a43cfa21b74b60973ed4201718b9dc102df", + ["ui_text.vert.spv"] = "018ac64477cf7d4c3fc0c5878951b148c7bfeb6ee3a7eebb02381d7904877798", + ["vk_probe.frag.spv"] = "c2dedbcc6dcc89744707b4b47138f1c31b38ef9088e584f1da07dd6953586c42", + ["vk_probe.vert.spv"] = "6c3260b45644033d607727cbd2e11fb4f60eb4a5b18bfd0997710f2ca518a023", + }; + private sealed record StageEntry(string Stage, string SourceSha256, bool Compiled, string? Message); private sealed record ShaderEntry(string Name, bool VulkanReady, IReadOnlyList Stages); @@ -68,10 +94,51 @@ public sealed class VulkanShaderManifestTests { // Line endings are normalised before hashing so a checkout with a // different core.autocrlf setting does not report every shader stale. - string text = File.ReadAllText(path).Replace("\r\n", "\n"); + string text = File.ReadAllText(path); + if (text.Contains("#include \"", StringComparison.Ordinal)) + { + text = ExpandIncludes( + text, + Path.GetDirectoryName(path)!, + []); + } + text = text.Replace("\r\n", "\n", StringComparison.Ordinal); return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text))); } + private static string ExpandIncludes( + string source, + string sourceDirectory, + HashSet active) + { + var output = new StringBuilder(); + foreach (string line in source.Replace("\r\n", "\n").Split('\n')) + { + string trimmed = line.Trim(); + if (!trimmed.StartsWith("#include \"", StringComparison.Ordinal) + || !trimmed.EndsWith('"')) + { + output.AppendLine(line); + continue; + } + string key = trimmed[10..^1]; + Assert.DoesNotContain("..", key, StringComparison.Ordinal); + Assert.DoesNotContain('\\', key); + string include = Path.GetFullPath(Path.Combine(sourceDirectory, key)); + Assert.StartsWith( + Path.GetFullPath(sourceDirectory) + Path.DirectorySeparatorChar, + include, + StringComparison.Ordinal); + Assert.True(File.Exists(include), $"GLSL include '{key}' is missing."); + Assert.True(active.Add(include), $"GLSL include '{key}' is cyclic."); + output.AppendLine($"// ---- begin include: {key} ----"); + output.Append(ExpandIncludes(File.ReadAllText(include), sourceDirectory, active)); + output.AppendLine($"// ---- end include: {key} ----"); + active.Remove(include); + } + return output.ToString(); + } + [Fact] public void EveryGlslPairIsRecordedInTheManifest() { @@ -87,6 +154,18 @@ public sealed class VulkanShaderManifestTests Assert.Equal(pairs, manifest.Shaders.Select(shader => shader.Name).OrderBy(n => n, StringComparer.Ordinal)); } + [Fact] + public void PreCampaignRetailSpirvBinariesRemainByteExact() + { + foreach ((string fileName, string expectedSha256) in RetailOracleSpirvSha256) + { + string path = Path.Combine(SpirvDirectory(), fileName); + Assert.True(File.Exists(path), $"Retail shader oracle '{fileName}' is missing."); + string actual = Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(path))); + Assert.Equal(expectedSha256, actual); + } + } + [Fact] public void CommittedSpirvIsNotStaleAgainstItsGlslSource() { diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs index 0b889d57..77582eb7 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs @@ -146,6 +146,9 @@ public sealed class VulkanViewportMappingTests Assert.Equal( (BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha), VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.InverseAlpha)); + Assert.Equal( + (BlendFactor.DstColor, BlendFactor.OneMinusSrcAlpha), + VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.RetailDetail)); } [Fact] diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanWorldPassScopeTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanWorldPassScopeTests.cs new file mode 100644 index 00000000..5c803a20 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanWorldPassScopeTests.cs @@ -0,0 +1,60 @@ +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Vk; +using AcDream.App.Tests.Rendering.Gpu; + +namespace AcDream.App.Tests.Rendering.Gpu.Vk; + +public sealed class VulkanWorldPassScopeTests +{ + [Fact] + public void OrdinaryPublicationStartsWithEmptyFrameSections() + { + using var device = new RecordingGpuDevice(); + using IGpuBuffer buffer = Buffer(device); + var scope = new VulkanWorldPassScope(sampleCount: 1); + scope.Sections.SceneLighting = new GpuBufferSection(buffer, 256, 576); + + using (scope.Publish(Encoder(device))) + Assert.False(scope.Sections.SceneLighting.IsValid); + + Assert.False(scope.Sections.SceneLighting.IsValid); + } + + [Fact] + public void PreparedPublicationPreservesCurrentFrameSectionsUntilDispose() + { + using var device = new RecordingGpuDevice(); + using IGpuBuffer buffer = Buffer(device); + var scope = new VulkanWorldPassScope(sampleCount: 1); + var lighting = new GpuBufferSection(buffer, 256, 576); + scope.Sections.SceneLighting = lighting; + + using (scope.PublishPrepared(Encoder(device))) + Assert.Equal(lighting, scope.Sections.SceneLighting); + + Assert.False(scope.Sections.SceneLighting.IsValid); + } + + private static IGpuBuffer Buffer(RecordingGpuDevice device) => + device.CreateBuffer(new GpuBufferDescription( + "prepared-lighting", + 1024, + GpuBufferUsage.Uniform, + GpuMemoryResidency.HostWritable)); + + private static IGpuPassEncoder Encoder(RecordingGpuDevice device) => + new RecordingGpuPassEncoder( + device, + new GpuPassDescription + { + Name = "world", + Color = new GpuColorAttachment( + Target: null, + GpuLoadOp.Clear, + GpuStoreOp.Store, + default), + Depth = null, + SampleCount = 1, + }); +} diff --git a/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs b/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs index be45f841..b5815384 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs @@ -1,8 +1,10 @@ using System.Numerics; +using AcDream.App.Input; using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Scene.Arch; using AcDream.App.Streaming; using AcDream.App.World; +using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; @@ -40,6 +42,43 @@ public sealed class LiveRenderProjectionJournalTests Assert.True((registered.Record.Flags & RenderProjectionFlags.Draw) != 0); } + [Fact] + public void EntityReady_RetainsExactLocalPlayerIdentityAtRenderPublication() + { + Harness harness = CreateHarness(loadLandblock: true, localPlayerGuid: Guid); + LiveEntityRecord record = Materialize(harness, Guid, instance: 12); + + Assert.True(harness.Projections.OnEntityReady( + LiveEntityReadyCandidate.Capture(record))); + + Assert.Equal( + RenderCasterIdentityKind.LocalPlayer, + Assert.Single(harness.Journal.Pending.ToArray()) + .Record.EntityPayload.CasterIdentity); + } + + [Theory] + [InlineData(0x80000001u, null, null, 0x80000001u, (byte)RenderCasterIdentityKind.LocalPlayer)] + [InlineData(0x80000001u, null, 0x8u, 0u, (byte)RenderCasterIdentityKind.RemotePlayer)] + [InlineData(0x50000001u, null, null, 0u, (byte)RenderCasterIdentityKind.RemotePlayer)] + [InlineData(0x80000001u, (uint)ItemType.Creature, null, 0u, (byte)RenderCasterIdentityKind.NonPlayerCreature)] + [InlineData(0x80000001u, (uint)ItemType.Misc, null, 0u, (byte)RenderCasterIdentityKind.OtherLiveDynamic)] + public void CasterIdentityClassifier_UsesOnlyAuthoritativeSpawnFacts( + uint serverGuid, + uint? itemType, + uint? objectDescriptionFlags, + uint localPlayerGuid, + byte expected) + { + Assert.Equal( + (RenderCasterIdentityKind)expected, + RenderCasterIdentityClassifier.Classify( + serverGuid, + itemType, + objectDescriptionFlags, + localPlayerGuid)); + } + [Fact] public void PendingToLoadedAndLoadedToLoaded_RebucketWithoutLogicalRecreate() { @@ -347,7 +386,9 @@ public sealed class LiveRenderProjectionJournalTests Assert.Equal(0, harness.Projections.ProjectionCount); } - private static Harness CreateHarness(bool loadLandblock) + private static Harness CreateHarness( + bool loadLandblock, + uint localPlayerGuid = 0) { var state = new GpuWorldState(); if (loadLandblock) @@ -360,10 +401,15 @@ public sealed class LiveRenderProjectionJournalTests var runtime = LiveEntityRuntimeFixture.Create(state, resources); var journal = new RenderProjectionJournal( RenderSceneGeneration.FromRaw(1)); + var localPlayer = new LocalPlayerIdentityState + { + ServerGuid = localPlayerGuid, + }; var projections = new LiveRenderProjectionJournal( runtime, journal, - new GpuWorldRenderTraversalOrderSource(state)); + new GpuWorldRenderTraversalOrderSource(state), + localPlayer); sink = projections; var scene = new ArchRenderScene(RenderSceneGeneration.FromRaw(1)); return new Harness(state, runtime, journal, projections, scene); diff --git a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericAutoQualityControllerTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericAutoQualityControllerTests.cs new file mode 100644 index 00000000..8305bf6f --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericAutoQualityControllerTests.cs @@ -0,0 +1,114 @@ +using AcDream.App.Rendering; +using AcDream.App.Rendering.Packs; + +namespace AcDream.App.Tests.Rendering.Packs; + +public sealed class AtmosphericAutoQualityControllerTests +{ + [Fact] + public void Downgrade_requires_long_consecutive_over_budget_window() + { + var controller = new AtmosphericAutoQualityController( + AtmosphericQualityLevel.High); + var sample = new AtmosphericQualityMeasurement( + InclusivePackGpuMillisecondsP99: 7.0, + IncrementalCpuMillisecondsP99: 1.1, + ResidentGpuBytes: 250L * 1024 * 1024, + StableFrameBoundary: true); + + for (int i = 0; + i < AtmosphericAutoQualityController.DowngradeHysteresisFrames - 1; + i++) + controller.Observe(in sample); + Assert.Equal(AtmosphericQualityLevel.High, controller.Snapshot.Current); + + AtmosphericAutoQualitySnapshot changed = controller.Observe(in sample); + Assert.Equal(AtmosphericQualityLevel.Medium, changed.Current); + Assert.Equal(AtmosphericAutoQualityController.ChangeCooldownFrames, + changed.CooldownFramesRemaining); + } + + [Fact] + public void Unstable_frames_never_advance_hysteresis() + { + var controller = new AtmosphericAutoQualityController( + AtmosphericQualityLevel.High); + var sample = new AtmosphericQualityMeasurement(20, 20, 0, false); + + for (int i = 0; i < 1000; i++) + controller.Observe(in sample); + + Assert.Equal(AtmosphericQualityLevel.High, controller.Snapshot.Current); + Assert.Equal(0, controller.Snapshot.ConsecutiveOverBudgetFrames); + } + + [Fact] + public void Upgrade_requires_nine_hundred_headroom_frames() + { + var controller = new AtmosphericAutoQualityController( + AtmosphericQualityLevel.Low); + var sample = new AtmosphericQualityMeasurement( + InclusivePackGpuMillisecondsP99: 0.5, + IncrementalCpuMillisecondsP99: 0.1, + ResidentGpuBytes: 16L * 1024 * 1024, + StableFrameBoundary: true); + + for (int i = 0; + i < AtmosphericAutoQualityController.UpgradeHysteresisFrames - 1; + i++) + controller.Observe(in sample); + Assert.Equal(AtmosphericQualityLevel.Low, controller.Snapshot.Current); + + Assert.Equal( + AtmosphericQualityLevel.Medium, + controller.Observe(in sample).Current); + } + + [Fact] + public void LowRequestsWholePackFallbackWithoutDroppingHeadlineSemantics() + { + var controller = new AtmosphericAutoQualityController( + AtmosphericQualityLevel.Low); + var sample = new AtmosphericQualityMeasurement(100, 100, long.MaxValue, true); + + for (int i = 0; + i < AtmosphericAutoQualityController.DowngradeHysteresisFrames - 1; + i++) + controller.Observe(in sample); + + Assert.Equal(AtmosphericQualityLevel.Low, controller.Snapshot.Current); + Assert.False(controller.Snapshot.SafeFallbackToRetailRequested); + + AtmosphericAutoQualitySnapshot fallback = controller.Observe(in sample); + + Assert.Equal(AtmosphericQualityLevel.Low, fallback.Current); + Assert.True(fallback.SafeFallbackToRetailRequested); + Assert.Equal( + DirectionalShadowSemantics.Headline, + DirectionalShadowQuality.For(DirectionalShadowPreset.Low).Semantics); + } + + [Fact] + public void PackDeclaredBudgetsAreTheAutomaticQualityAuthority() + { + AtmosphericQualityBudget[] budgets = + [ + new(0.5, 0.1, 16 * 1024 * 1024), + new(1.0, 0.2, 32 * 1024 * 1024), + new(1.5, 0.3, 64 * 1024 * 1024), + ]; + var controller = new AtmosphericAutoQualityController( + budgets, + AtmosphericQualityLevel.High); + var sample = new AtmosphericQualityMeasurement( + InclusivePackGpuMillisecondsP99: 1.6, + IncrementalCpuMillisecondsP99: 0.1, + ResidentGpuBytes: 8 * 1024 * 1024, + StableFrameBoundary: true); + + for (int i = 0; i < AtmosphericAutoQualityController.DowngradeHysteresisFrames; i++) + controller.Observe(in sample); + + Assert.Equal(AtmosphericQualityLevel.Medium, controller.Snapshot.Current); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericCpuStageProfilerTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericCpuStageProfilerTests.cs new file mode 100644 index 00000000..d0d53a86 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericCpuStageProfilerTests.cs @@ -0,0 +1,94 @@ +using System.Diagnostics; +using AcDream.App.Rendering.Gpu.Vk; +using AcDream.App.Rendering.Packs; +using AcDream.App.Tests.Architecture; + +namespace AcDream.App.Tests.Rendering.Packs; + +public sealed class AtmosphericCpuStageProfilerTests +{ + [Fact] + public void LowProfilerSamplesOneFrameInFour() + { + long[] measured = Enumerable.Range(1, 12) + .Where(value => AtmosphericCpuStageProfiler.ShouldMeasure(value)) + .Select(value => (long)value) + .ToArray(); + + Assert.Equal([4L, 8L, 12L], measured); + } + + [Fact] + public void SnapshotPublishesNonOverlappingStagesTotalAndResidual() + { + var profiler = new AtmosphericCpuStageProfiler(capacity: 4); + var frame = new AtmosphericCpuStageFrame( + FrameSerial: 4, + ShadowCasterBuildTicks: Ticks(20), + ShadowEnvironmentTicks: Ticks(30), + ShadowPreparedDrawsAndTransformsTicks: Ticks(40), + ShadowFitAndUniformTicks: Ticks(50), + ShadowLayeredPassRecordingTicks: Ticks(60), + ShadowBookkeepingTicks: Ticks(70), + PostSetupAndOtherTicks: Ticks(80), + PostSunRaysTicks: Ticks(90), + PostFilmicTicks: Ticks(100)); + + profiler.Observe( + in frame, + targetPreparationTicks: Ticks(10), + measuredPackTotalTicks: Ticks(600), + observeBookkeepingTicks: Ticks(110)); + + IReadOnlyDictionary stages = profiler + .Snapshot() + .ToDictionary(value => value.Stage, StringComparer.Ordinal); + Assert.Equal(13, stages.Count); + Assert.Equal(1, stages["shadow-layered-pass-recording"].SampleCount); + Assert.Equal(0.060, stages["shadow-layered-pass-recording"].CpuMillisecondsP50, 3); + Assert.Equal(0.110, stages["performance-observe-bookkeeping"].CpuMillisecondsP50, 3); + Assert.Equal(0.600, stages["measured-pack-total"].CpuMillisecondsP50, 3); + Assert.Equal(0.050, stages["measured-pack-unattributed"].CpuMillisecondsP50, 3); + } + + [Fact] + public void WarmedObservationAllocatesNothing() + { + var profiler = new AtmosphericCpuStageProfiler(capacity: 2048); + var frame = new AtmosphericCpuStageFrame( + 4, + Ticks(1), Ticks(2), Ticks(3), Ticks(4), Ticks(5), + Ticks(6), Ticks(7), Ticks(8), Ticks(9)); + + ZeroAllocationProbe.AssertAllocatesNothing( + "AtmosphericCpuStageProfiler.Observe", + () => profiler.Observe( + in frame, + targetPreparationTicks: Ticks(10), + measuredPackTotalTicks: Ticks(100), + observeBookkeepingTicks: Ticks(11))); + } + + [Fact] + public void ProductionWorldPhaseCompletesTheSampledProfileAfterObservation() + { + var render = typeof(VulkanWorldScenePhase).GetMethod(nameof(VulkanWorldScenePhase.Render))!; + IReadOnlyList calls = CompiledCallGraph.Read(render); + + int observe = CompiledCallGraph.IndexOf( + calls, + typeof(RenderPackController), + nameof(RenderPackController.ObserveActiveFrame)); + int complete = CompiledCallGraph.IndexOf( + calls, + typeof(IAtmosphericCpuStageProfileRuntime), + nameof(IAtmosphericCpuStageProfileRuntime.CompleteCpuProfile)); + + Assert.True(observe >= 0); + Assert.True(complete > observe); + } + + private static long Ticks(int microseconds) => checked((long)Math.Round( + microseconds * Stopwatch.Frequency / 1_000_000d, + MidpointRounding.AwayFromZero)); +} diff --git a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericGpuTimerSamplingTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericGpuTimerSamplingTests.cs new file mode 100644 index 00000000..4868ccbc --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericGpuTimerSamplingTests.cs @@ -0,0 +1,63 @@ +using AcDream.App.Rendering.Packs; +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.App.Tests.Rendering.Packs; + +public sealed class AtmosphericGpuTimerSamplingTests +{ + [Fact] + public void LowMeasuresOneCompleteFrameInFour() + { + bool[] measured = Enumerable.Range(1, 12) + .Select(frame => AtmosphericGpuTimerSampling.ShouldMeasure( + RenderQualitySemantic.Low, + frame)) + .ToArray(); + + Assert.Equal( + [false, false, false, true, false, false, false, true, false, false, false, true], + measured); + } + + [Theory] + [InlineData(RenderQualitySemantic.Medium)] + [InlineData(RenderQualitySemantic.High)] + public void OtherQualitiesMeasureEveryFrame(RenderQualitySemantic quality) + { + for (long frame = 1; frame <= 32; frame++) + Assert.True(AtmosphericGpuTimerSampling.ShouldMeasure(quality, frame)); + } + + [Fact] + public void RejectsNonPositiveFrameSerial() + { + Assert.Throws(() => + AtmosphericGpuTimerSampling.ShouldMeasure( + RenderQualitySemantic.Low, + frameSerial: 0)); + } + + [Fact] + public void WarmSamplingDecisionsAllocateZero() + { + _ = AtmosphericGpuTimerSampling.ShouldMeasure( + RenderQualitySemantic.Low, + frameSerial: 1); + + int measured = 0; + long before = GC.GetAllocatedBytesForCurrentThread(); + for (long frame = 1; frame <= 10_000; frame++) + { + if (AtmosphericGpuTimerSampling.ShouldMeasure( + RenderQualitySemantic.Low, + frame)) + { + measured++; + } + } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(2_500, measured); + Assert.Equal(0, allocated); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs new file mode 100644 index 00000000..da8617d7 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs @@ -0,0 +1,1525 @@ +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.App.Plugins; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Packs; +using AcDream.App.Rendering.Wb; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.Core.World; +using AcDream.Plugin.Abstractions.Rendering; +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.App.Tests.Rendering.Packs; + +public sealed class AtmosphericPostProcessGraphTests +{ + [Fact] + public void LowSamplesEveryPostTimerTogetherOnEveryFourthFrame() + { + var device = new RecordingGpuDevice(); + using var graph = Graph(device, "low"); + IGpuRenderTarget world = graph.PrepareWorldTarget(1280, 720, 1); + AtmosphericFrameInputs inputs = Inputs(1280, 720); + + for (int serial = 1; serial <= 4; serial++) + { + device.Clear(); + using IGpuFrame frame = device.BeginFrame(); + RecordWorldPass(frame, world); + graph.RenderPostProcess(frame, in inputs); + frame.End(); + + string[] measured = device.OfKind() + .Select(static scope => scope.Name) + .Where(static name => name.StartsWith( + "atmospheric-", + StringComparison.Ordinal)) + .ToArray(); + if (serial < AtmosphericGpuTimerSampling.LowIntervalFrames) + { + Assert.Empty(measured); + } + else + { + Assert.Equal( + [ + "atmospheric-sun-occlusion", + "atmospheric-sun-rays", + "atmospheric-bloom-downsample", + "atmospheric-bloom-blur-horizontal", + "atmospheric-bloom-blur-vertical", + "atmospheric-filmic", + ], + measured); + } + } + } + + [Fact] + public void LowUsesQuarterResolutionSeparableBloomWithoutDroppingHeadlineInputs() + { + var device = new RecordingGpuDevice(); + using var graph = Graph(device, "low"); + Assert.True(Assert.IsType( + graph.DirectionalShadowReceivers).MultiviewCascadesEnabled); + IGpuRenderTarget world = graph.PrepareWorldTarget(1280, 720, 1); + device.Clear(); + + using IGpuFrame frame = device.BeginFrame(); + RecordWorldPass(frame, world); + AtmosphericFrameInputs inputs = Inputs(1280, 720); + graph.RenderPostProcess(frame, in inputs); + frame.End(); + + Assert.Equal( + [ + "test-world-hdr", + "atmospheric-sun-occlusion", + "atmospheric-sun-rays", + "atmospheric-bloom-downsample", + "atmospheric-bloom-blur-horizontal", + "atmospheric-bloom-blur-vertical", + "atmospheric-filmic", + ], + device.OfKind().Select(call => call.Name)); + GpuRecordedUniformBind[] passBlocks = device + .OfKind() + .Where(call => call.Binding == GpuBindingModel.UniformPackPass) + .ToArray(); + Assert.Equal(6, passBlocks.Length); + Assert.All( + device.OfKind().Where(call => + call.Binding is GpuBindingModel.UniformAtmosphericFrame + or GpuBindingModel.UniformPackPass + or GpuBindingModel.UniformPackSettings), + call => Assert.Equal( + 0u, + call.OffsetBytes + % device.Capabilities.MinUniformBufferOffsetAlignment)); + + AtmosphericPackPassUniforms rays = ReadPass(device, passBlocks[1]); + Assert.Equal(Vector4.Zero, rays.Params1); + AtmosphericPackPassUniforms bloom = ReadPass(device, passBlocks[2]); + Assert.Equal( + new Vector4(graph.Settings.BloomStrength, 1f, 0.45f, 0f), + bloom.Params0); + AtmosphericPackPassUniforms horizontal = ReadPass(device, passBlocks[3]); + Assert.Equal(new Vector4(1f / 320f, 0f, 0f, 0f), horizontal.Params0); + AtmosphericPackPassUniforms vertical = ReadPass(device, passBlocks[4]); + Assert.Equal(new Vector4(0f, 1f / 180f, 0f, 0f), vertical.Params0); + AtmosphericPackPassUniforms filmic = ReadPass(device, passBlocks[5]); + Assert.Equal(0f, filmic.Params1.Z); + Assert.Equal(Vector4.Zero, filmic.Params2); + Assert.Equal(Vector4.Zero, filmic.Params3); + Assert.Contains(device.CreatedRenderTargets, target => + target.Description.Name == "atmospheric-bloom-a" + && target.Description.Width == 320 + && target.Description.Height == 180); + Assert.Contains(device.CreatedRenderTargets, target => + target.Description.Name == "atmospheric-bloom-b" + && target.Description.Width == 320 + && target.Description.Height == 180); + + GpuRecordedPushConstants[] pushes = device + .OfKind() + .ToArray(); + Assert.Equal(6, pushes.Length); + Assert.All(pushes, push => + Assert.NotEqual(uint.MaxValue, push.Constants.TextureIndexA)); + Assert.NotEqual(uint.MaxValue, pushes[2].Constants.TextureIndexB); + Assert.NotEqual(uint.MaxValue, pushes[5].Constants.TextureIndexB); + + RenderPackRuntimeDiagnostics diagnostics = graph.CaptureDiagnostics(); + Assert.Equal(6, diagnostics.DrawCalls); + Assert.Equal(7, diagnostics.ImageCount); + Assert.Contains(diagnostics.Passes, pass => + pass.PassId == "atmospheric-sun-occlusion" && pass.DrawCalls == 1); + Assert.Contains(diagnostics.Passes, pass => + pass.PassId == "atmospheric-sun-rays" && pass.DrawCalls == 1); + Assert.Contains(diagnostics.Passes, pass => + pass.PassId == "atmospheric-bloom-downsample" && pass.DrawCalls == 1); + Assert.Contains(diagnostics.Passes, pass => + pass.PassId == "atmospheric-bloom-blur-horizontal" && pass.DrawCalls == 1); + Assert.Contains(diagnostics.Passes, pass => + pass.PassId == "atmospheric-bloom-blur-vertical" && pass.DrawCalls == 1); + Assert.Contains(diagnostics.Passes, pass => + pass.PassId == "atmospheric-filmic" && pass.DrawCalls == 1); + } + + [Theory] + [InlineData( + AuthoredCelestialShadowSourceKind.Sun, + 5, + 0x01001348u, + 0.25f, + -0.5f, + 0.8291562f, + 0.8291562f)] + [InlineData( + AuthoredCelestialShadowSourceKind.DominantMoon, + 3, + 0x01001F6Au, + -0.6f, + 0.2f, + 0.7745967f, + 0.7745967f)] + [InlineData( + AuthoredCelestialShadowSourceKind.SecondaryMoon, + 2, + 0x01001F67u, + 0.4f, + 0.8f, + 0.4472136f, + 0.4472136f)] + [InlineData( + AuthoredCelestialShadowSourceKind.None, + -1, + 0u, + 0f, + 0f, + 1f, + 0f)] + internal void CaptureDiagnosticsPreservesSunMoonAndNoneSourceMetadata( + AuthoredCelestialShadowSourceKind sourceKind, + int sourceObjectIndex, + uint sourceGfxObjId, + float directionX, + float directionY, + float directionZ, + float elevationSin) + { + var device = new RecordingGpuDevice(); + using var graph = Graph(device, "medium"); + IGpuRenderTarget world = graph.PrepareWorldTarget(1280, 720, 1); + using IGpuFrame frame = device.BeginFrame(); + RecordWorldPass(frame, world); + AtmosphericFrameInputs inputs = Inputs(1280, 720); + graph.RenderPostProcess(frame, in inputs); + frame.End(); + var direction = new Vector3(directionX, directionY, directionZ); + + // Exercise only the graph's diagnostics projection. This is the exact + // value object that DirectionalSunShadowRenderer publishes after its + // separately-covered environment/render path. + SetLastShadowDiagnostics( + graph, + new DirectionalSunShadowDiagnostics( + GateReason: sourceKind is AuthoredCelestialShadowSourceKind.None + ? DirectionalShadowGateReason.NoVisibleCelestial + : DirectionalShadowGateReason.Enabled, + Strength: sourceKind is AuthoredCelestialShadowSourceKind.None + ? 0f + : 0.75f, + CascadeCount: 0, + DrawCalls: 0, + WorldOpaqueCommands: 0, + WorldAlphaCutoutCommands: 0, + TerrainCommands: 0, + WorldPreparationSequence: 0, + TerrainPreparationSequence: 0, + CpuMilliseconds: 0, + LastResolvedGpuMilliseconds: 0, + HasResolvedGpuMeasurement: false, + ResidentDepthBytes: 0, + SourceKind: sourceKind, + SourceObjectIndex: sourceObjectIndex, + SourceGfxObjId: sourceGfxObjId, + SurfaceToLightDirection: direction, + LightElevationSin: elevationSin)); + + RenderPackRuntimeDiagnostics diagnostics = graph.CaptureDiagnostics(); + + Assert.Equal(sourceKind, diagnostics.DirectionalShadowSourceKind); + Assert.Equal( + sourceObjectIndex, + diagnostics.DirectionalShadowSourceObjectIndex); + Assert.Equal(sourceGfxObjId, diagnostics.DirectionalShadowSourceGfxObjId); + Assert.Equal( + direction, + diagnostics.DirectionalShadowSurfaceToLightDirection); + Assert.Equal( + elevationSin, + diagnostics.DirectionalShadowLightElevationSin); + } + + [Fact] + public void GraphRunsTheDeclaredHdrPassOrderAndBindsStablePackAbi() + { + var device = new RecordingGpuDevice(); + using var graph = Graph(device, "medium"); + IGpuRenderTarget world = graph.PrepareWorldTarget(1280, 720, 4); + device.Clear(); + + using IGpuFrame frame = device.BeginFrame(); + RecordWorldPass(frame, world); + AtmosphericFrameInputs inputs = Inputs(1280, 720); + graph.RenderPostProcess(frame, in inputs); + frame.End(); + + Assert.Equal( + [ + "test-world-hdr", + "atmospheric-sun-occlusion", + "atmospheric-sun-rays", + "atmospheric-bloom-downsample", + "atmospheric-bloom-blur-horizontal", + "atmospheric-bloom-blur-vertical", + "atmospheric-filmic", + ], + device.OfKind().Select(call => call.Name)); + Assert.Equal( + 6, + device.OfKind().Count(call => + call.Binding == GpuBindingModel.UniformAtmosphericFrame + && call.SizeBytes == AtmosphericFrameUniforms.SizeInBytes)); + Assert.Equal( + 6, + device.OfKind().Count(call => + call.Binding == GpuBindingModel.UniformPackPass + && call.SizeBytes == AtmosphericPackPassUniforms.SizeInBytes)); + Assert.Equal( + 6, + device.OfKind().Count(call => + call.Binding == GpuBindingModel.UniformPackSettings + && call.SizeBytes == PackSettingsUniforms.SizeInBytes)); + GpuRecordedPushConstants[] pushes = device.OfKind().ToArray(); + Assert.All(pushes[..^1], call => + { + Assert.True(call.Constants.TextureIndexA != uint.MaxValue); + Assert.Equal(uint.MaxValue, BitConverter.SingleToUInt32Bits(call.Constants.ParamA)); + Assert.Equal(uint.MaxValue, BitConverter.SingleToUInt32Bits(call.Constants.ParamB)); + }); + Assert.NotEqual(uint.MaxValue, BitConverter.SingleToUInt32Bits(pushes[^1].Constants.ParamA)); + Assert.Equal(uint.MaxValue, BitConverter.SingleToUInt32Bits(pushes[^1].Constants.ParamB)); + Assert.NotEqual(uint.MaxValue, pushes[2].Constants.TextureIndexB); + Assert.Equal(uint.MaxValue, BitConverter.SingleToUInt32Bits(pushes[2].Constants.ParamA)); + + RenderPackRuntimeDiagnostics diagnostics = graph.CaptureDiagnostics(); + Assert.Equal(10, diagnostics.ImageCount); + Assert.Equal(6, diagnostics.DrawCalls); + Assert.Equal(0, diagnostics.ShadowCasterCount); + Assert.Equal(0, diagnostics.CascadeDrawCount); + Assert.Equal(0, diagnostics.CpuClassificationCalls); + Assert.Equal(8, diagnostics.Passes.Count); + Assert.Contains(diagnostics.Passes, pass => + pass.PassId == RenderPackPerformanceScopeNames.EnhancedWorldReceiver); + Assert.Contains(diagnostics.Passes, pass => + pass.PassId == VolumetricShaftRenderer.TimerName + && pass.DrawCalls == 0); + Assert.True( + diagnostics.RetainedGpuBytes + >= DirectionalShadowQuality.For(DirectionalShadowPreset.Medium) + .ApproximateDepthMapBytes); + } + + [Fact] + public void CurrentShadowRunsShaftsBeforeBloomAndFeedsBloomAndFilmicComposition() + { + var device = new RecordingGpuDevice(); + using var graph = Graph(device, "medium"); + IGpuRenderTarget world = graph.PrepareWorldTarget(1280, 720, 1); + + using IGpuFrame frame = device.BeginFrame(); + PublishCurrentShadow(graph, frame); + device.Clear(); + RecordWorldPass(frame, world); + AtmosphericFrameInputs inputs = Inputs(1280, 720); + graph.RenderPostProcess(frame, in inputs); + frame.End(); + + Assert.Equal( + [ + "test-world-hdr", + "atmospheric-sun-occlusion", + "atmospheric-sun-rays", + VolumetricShaftRenderer.TimerName, + "atmospheric-bloom-downsample", + "atmospheric-bloom-blur-horizontal", + "atmospheric-bloom-blur-vertical", + "atmospheric-filmic", + ], + device.OfKind().Select(call => call.Name)); + RenderPassSemantic[] declaredOrder = graph.Descriptor.Passes + .Where(pass => pass.Hook is RenderPassHook.AtmosphereBeforeToneMap + or RenderPassHook.ToneMap) + .Select(pass => pass.Semantic) + .ToArray(); + RenderPassSemantic[] executedOrder = device.OfKind() + .Skip(1) + .Select(call => call.Name switch + { + "atmospheric-sun-occlusion" => RenderPassSemantic.SunOcclusion, + "atmospheric-sun-rays" => RenderPassSemantic.SunRays, + VolumetricShaftRenderer.TimerName => RenderPassSemantic.VolumetricShafts, + "atmospheric-bloom-downsample" => RenderPassSemantic.BloomDownsample, + "atmospheric-bloom-blur-horizontal" => RenderPassSemantic.BloomBlurHorizontal, + "atmospheric-bloom-blur-vertical" => RenderPassSemantic.BloomBlurVertical, + "atmospheric-filmic" => RenderPassSemantic.FilmicComposite, + _ => throw new InvalidOperationException($"Unexpected atmospheric pass '{call.Name}'."), + }) + .ToArray(); + Assert.Equal(declaredOrder, executedOrder); + RenderPassDeclaration bloom = Assert.Single( + graph.Descriptor.Passes, + value => value.Semantic == RenderPassSemantic.BloomDownsample); + RenderResourceSemantic[] bloomReads = bloom.ResourceReads + .Select(id => Assert.Single( + graph.Descriptor.Resources, + resource => string.Equals(resource.Id, id, StringComparison.OrdinalIgnoreCase)).Semantic) + .ToArray(); + Assert.Equal( + [RenderResourceSemantic.SunRays, RenderResourceSemantic.VolumetricShafts], + bloomReads); + GpuRecordedPushConstants[] pushes = device + .OfKind() + .ToArray(); + Assert.Equal(7, pushes.Length); + Assert.NotEqual(uint.MaxValue, pushes[2].Constants.TextureIndexA); + Assert.Equal(uint.MaxValue, pushes[2].Constants.TextureIndexB); + Assert.Equal(uint.MaxValue, BitConverter.SingleToUInt32Bits(pushes[2].Constants.ParamA)); + Assert.Equal(uint.MaxValue, BitConverter.SingleToUInt32Bits(pushes[2].Constants.ParamB)); + Assert.NotEqual(uint.MaxValue, pushes[3].Constants.TextureIndexA); + Assert.NotEqual(uint.MaxValue, pushes[3].Constants.TextureIndexB); + Assert.NotEqual(uint.MaxValue, BitConverter.SingleToUInt32Bits(pushes[3].Constants.ParamA)); + Assert.Equal(uint.MaxValue, BitConverter.SingleToUInt32Bits(pushes[3].Constants.ParamB)); + Assert.Equal( + pushes[3].Constants.TextureIndexB, + BitConverter.SingleToUInt32Bits(pushes[^1].Constants.ParamA)); + Assert.Equal( + BitConverter.SingleToUInt32Bits(pushes[3].Constants.ParamA), + BitConverter.SingleToUInt32Bits(pushes[^1].Constants.ParamB)); + + RenderPackRuntimeDiagnostics diagnostics = graph.CaptureDiagnostics(); + Assert.Contains(diagnostics.Passes, pass => + pass.PassId == VolumetricShaftRenderer.TimerName + && pass.DrawCalls == 1); + Assert.Equal(7, diagnostics.DrawCalls); + Assert.Equal(8, diagnostics.ImageCount); + } + + [Fact] + public void NeutralSettingsReachShaderBlocksWithoutHiddenResidualEffects() + { + var device = new RecordingGpuDevice(); + using var graph = Graph( + device, + "medium", + AtmosphericPostProcessSettings.Neutral); + IGpuRenderTarget world = graph.PrepareWorldTarget(800, 600, 1); + device.Clear(); + + using IGpuFrame frame = device.BeginFrame(); + RecordWorldPass(frame, world); + AtmosphericFrameInputs inputs = Inputs(800, 600); + graph.RenderPostProcess(frame, in inputs); + frame.End(); + + GpuRecordedUniformBind[] passBlocks = device + .OfKind() + .Where(call => call.Binding == GpuBindingModel.UniformPackPass) + .ToArray(); + AtmosphericPackPassUniforms bloom = ReadPass(device, passBlocks[2]); + AtmosphericPackPassUniforms filmic = ReadPass(device, passBlocks[^1]); + Assert.Equal(0f, bloom.Params0.X); + Assert.Equal(new Vector4(1f, 1f, 1f, 0f), filmic.Params0); + Assert.Equal(Vector4.Zero, filmic.Params1); + + GpuRecordedUniformBind frameBlock = device + .OfKind() + .First(call => call.Binding == GpuBindingModel.UniformAtmosphericFrame); + AtmosphericFrameUniforms atmospheric = MemoryMarshal.Read( + device.RingBytes.Slice((int)frameBlock.OffsetBytes, AtmosphericFrameUniforms.SizeInBytes)); + Assert.Equal(0f, atmospheric.SunScreen.Z); + } + + [Fact] + public void PerformanceSourceSumsOnlyResolvedPackPassTimersWithoutAllocatingDiagnostics() + { + var device = new RecordingGpuDevice(); + using var graph = Graph(device, "medium"); + IGpuRenderTarget world = graph.PrepareWorldTarget(800, 600, 1); + using IGpuFrame frame = device.BeginFrame(); + RecordWorldPass(frame, world); + AtmosphericFrameInputs inputs = Inputs(800, 600); + graph.RenderPostProcess(frame, in inputs); + frame.End(); + string[] names = + [ + RenderPackPerformanceScopeNames.EnhancedWorldReceiver, + "atmospheric-sun-occlusion", + "atmospheric-sun-rays", + "atmospheric-bloom-downsample", + "atmospheric-bloom-blur-horizontal", + "atmospheric-bloom-blur-vertical", + "atmospheric-filmic", + ]; + for (int i = 0; i < names.Length; i++) + device.RecordingTimers.SetResolved(names[i], i + 1); + + RenderPackRuntimePerformanceMetrics metrics = + graph.CapturePerformanceMetrics(); + + Assert.Equal(1, metrics.ResourceGeneration); + Assert.True(metrics.HasResolvedGpuMeasurement); + Assert.Equal(28, metrics.InclusiveResolvedGpuMilliseconds); + Assert.True(metrics.RetainedGpuBytes > 0); + Assert.Equal(0, metrics.TransientGpuBytes); + Assert.False(graph.CapturePerformanceMetrics().HasResolvedGpuMeasurement); + + graph.PrepareWorldTarget(1024, 768, 4); + Assert.Equal(2, graph.CapturePerformanceMetrics().ResourceGeneration); + } + + [Fact] + public void ResizePublishesOneCompleteReplacementAndRetiresTheOldSet() + { + var device = new RecordingGpuDevice(); + var graph = Graph(device, "medium"); + int baselineSlots = device.LiveTextureSlotCount; + + IGpuRenderTarget first = graph.PrepareWorldTarget(1280, 720, 4); + Assert.Equal(1, graph.ResourceGeneration); + Assert.Same(first, graph.PrepareWorldTarget(1280, 720, 4)); + Assert.Equal(1, graph.ResourceGeneration); + Assert.Equal(baselineSlots + 7, device.LiveTextureSlotCount); + RecordingGpuRenderTarget[] firstSet = device.CreatedRenderTargets.ToArray(); + + IGpuRenderTarget second = graph.PrepareWorldTarget(1920, 1080, 1); + + Assert.NotSame(first, second); + Assert.Equal(2, graph.ResourceGeneration); + Assert.All(firstSet, target => Assert.True(target.IsDisposed)); + Assert.Equal(baselineSlots + 7, device.LiveTextureSlotCount); + graph.Dispose(); + Assert.Equal(baselineSlots - 1, device.LiveTextureSlotCount); + Assert.Empty(device.PipelineFormatLeases); + } + + [Theory] + [InlineData("low", 320, 180)] + [InlineData("medium", 640, 360)] + [InlineData("high", 640, 360)] + public void PresetDeclaredRayScaleControlsMaskAndRayTargets( + string presetId, + int expectedWidth, + int expectedHeight) + { + var device = new RecordingGpuDevice(); + using var graph = Graph(device, presetId); + + graph.PrepareWorldTarget(1280, 720, 1); + + RecordingGpuRenderTarget mask = Assert.Single(device.CreatedRenderTargets, target => + target.Description.Name == "atmospheric-sun-mask"); + RecordingGpuRenderTarget rays = Assert.Single(device.CreatedRenderTargets, target => + target.Description.Name == "atmospheric-sun-rays"); + Assert.Equal(expectedWidth, mask.Description.Width); + Assert.Equal(expectedHeight, mask.Description.Height); + Assert.Equal(expectedWidth, rays.Description.Width); + Assert.Equal(expectedHeight, rays.Description.Height); + } + + [Fact] + public void PartialTargetAllocationFailureRollsBackAndCanBuildFreshCandidate() + { + var device = new RecordingGpuDevice(); + using var graph = Graph(device, "medium"); + int baselineSlots = device.LiveTextureSlotCount; + int allocation = 0; + device.RenderTargetFailure = _ => ++allocation == 3 + ? new InvalidOperationException("injected target failure") + : null; + + InvalidOperationException failure = Assert.Throws( + () => graph.PrepareWorldTarget(1024, 768, 4)); + + Assert.Equal("injected target failure", failure.Message); + Assert.Equal(0, graph.ResourceGeneration); + Assert.Equal(baselineSlots, device.LiveTextureSlotCount); + Assert.All(device.CreatedRenderTargets, target => Assert.True(target.IsDisposed)); + + device.RenderTargetFailure = null; + IGpuRenderTarget recovered = graph.PrepareWorldTarget(1024, 768, 4); + Assert.Equal(GpuTextureFormat.Rgba16FloatRenderTarget, recovered.Description.ColorFormat); + Assert.Equal(1, graph.ResourceGeneration); + Assert.Equal(baselineSlots + 7, device.LiveTextureSlotCount); + } + + [Fact] + public void DescriptorPassAssetsAndPresetOverridesDriveTheRuntime() + { + var device = new RecordingGpuDevice(); + using var graph = Graph(device, "low"); + + Assert.Equal(0.4f, graph.Settings.SunRayStrength); + Assert.Equal( + [ + "acdream.atmospheric:sun-occlusion", + "acdream.atmospheric:sun-rays", + "acdream.atmospheric:bloom-downsample", + "acdream.atmospheric:bloom-blur-horizontal", + "acdream.atmospheric:filmic-composite", + "acdream.atmospheric:terrain-shadow-caster", + "acdream.atmospheric:world-shadow-opaque", + "acdream.atmospheric:world-shadow-cutout", + "acdream.atmospheric:terrain-shadow-caster-multiview", + "acdream.atmospheric:world-shadow-opaque-multiview", + "acdream.atmospheric:world-shadow-cutout-multiview", + "acdream.atmospheric:volumetric-shafts", + ], + device.CreatedPipelines.Select(pipeline => pipeline.Description.Shaders.Name)); + Assert.All( + device.CreatedPipelines, + pipeline => Assert.True(pipeline.Description.Shaders.HasEmbeddedSpirv)); + Assert.Equal(1, device.PipelineFormatLeases[GpuTextureFormat.Rgba16FloatRenderTarget]); + } + + [Fact] + public void BuiltInSampleCountDeclarationsMatchTheExecutorExactly() + { + RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor; + RenderSettingDeclaration pcf = Assert.Single(descriptor.Settings, value => + value.Semantic == RenderSettingSemantic.DirectionalShadowPcfTaps); + RenderSettingDeclaration volumetric = Assert.Single(descriptor.Settings, value => + value.Semantic == RenderSettingSemantic.VolumetricRayMarchSteps); + + Assert.Equal(RenderSettingKind.Choice, pcf.Kind); + Assert.Equal(["1", "9", "25"], pcf.Choices); + Assert.Equal("9", pcf.DefaultValue); + Assert.Equal(RenderSettingKind.Integer, volumetric.Kind); + Assert.Equal(8, volumetric.Minimum); + Assert.Equal(64, volumetric.Maximum); + Assert.Equal(8, volumetric.Step); + } + + [Fact] + public void ShadowFilterRejectsAnUndeclaredIntermediateSampleCount() + { + var device = new RecordingGpuDevice(); + RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor; + RenderQualityPreset preset = Assert.Single(descriptor.QualityPresets, value => + value.Semantic == RenderQualitySemantic.Medium); + string settingId = Assert.Single(descriptor.Settings, value => + value.Semantic == RenderSettingSemantic.DirectionalShadowPcfTaps).Id; + + NotSupportedException error = Assert.Throws(() => + new AtmosphericPostProcessGraph( + device, + descriptor, + BuiltInAssets(), + preset, + userSettingOverrides: new Dictionary + { + [settingId] = "3", + })); + + Assert.Contains("exactly 1, 9, or 25", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void SemanticPresetResourcesAndSettingsDriveShadowAndVolumetricQuality() + { + var device = new RecordingGpuDevice(); + RenderPackDescriptor source = BuiltInAtmosphericRenderPack.Descriptor; + RenderResourceDeclaration shadowResource = Assert.Single( + source.Resources, + value => value.Semantic == RenderResourceSemantic.DirectionalShadowDepth); + RenderResourceDeclaration volumetricResource = Assert.Single( + source.Resources, + value => value.Semantic == RenderResourceSemantic.VolumetricShafts); + RenderQualityPreset original = Assert.Single( + source.QualityPresets, + value => value.Semantic == RenderQualitySemantic.Medium); + RenderQualityPreset preset = original with + { + ResourceOverrides = original.ResourceOverrides.Select(value => + string.Equals(value.ResourceId, shadowResource.Id, StringComparison.OrdinalIgnoreCase) + ? value with + { + Extent = new RenderExtentDeclaration( + RenderExtentMode.AbsolutePixels, + 768, + 768, + Layers: 2), + EstimatedResidentBytes = 2L * 768 * 768 * sizeof(float), + } + : string.Equals(value.ResourceId, volumetricResource.Id, + StringComparison.OrdinalIgnoreCase) + ? value with + { + Extent = new RenderExtentDeclaration( + RenderExtentMode.RelativeToMainWorld, + 0.375, + 0.375), + } + : value).ToArray(), + }; + string SettingId(RenderSettingSemantic semantic) => Assert.Single( + source.Settings, + value => value.Semantic == semantic).Id; + var overrides = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [SettingId(RenderSettingSemantic.DirectionalShadowReachMetres)] = "96", + [SettingId(RenderSettingSemantic.DirectionalShadowPcfTaps)] = "25", + [SettingId(RenderSettingSemantic.VolumetricRayMarchSteps)] = "64", + }; + + using var graph = new AtmosphericPostProcessGraph( + device, + source, + BuiltInAssets(), + preset, + userSettingOverrides: overrides); + var shadows = Assert.IsType( + graph.DirectionalShadowReceivers); + Assert.Equal(2, shadows.Quality.CascadeCount); + Assert.Equal(768, shadows.Quality.MapResolution); + Assert.Equal(96f, shadows.Quality.MaximumReachMeters); + Assert.Equal(2, shadows.Quality.PcfRadiusTexels); + Assert.Equal(64, graph.VolumetricQuality?.RayMarchSteps); + Assert.Equal(0.375f, graph.VolumetricQuality?.ResolutionScale); + + graph.PrepareWorldTarget(800, 600, 1); + RecordingGpuRenderTarget volumetric = Assert.Single( + device.CreatedRenderTargets, + value => value.Description.Name == "atmospheric-volumetric"); + Assert.Equal(300, volumetric.Description.Width); + Assert.Equal(225, volumetric.Description.Height); + } + + [Fact] + public void AuthoredElevationDayGroupAndVisibilityGateSunEffects() + { + var device = new RecordingGpuDevice(); + using var graph = Graph(device, "medium"); + + AtmosphericFrameInputs dawn = Inputs(1280, 720, elevation: 4f, activeDayGroup: 0); + AtmosphericFrameInputs dusk = dawn with { ActiveDayGroup = 1 }; + AtmosphericFrameInputs noon = dawn with { SunElevationDegrees = 55f }; + AtmosphericFrameInputs behindCamera = dawn with { SunIsOnScreen = false }; + AtmosphericFrameInputs overcast = dawn with + { + Weather = WeatherKind.Overcast, + WeatherIntensity = 1f, + }; + AtmosphericFrameInputs indoor = dawn with { IsOutdoor = false }; + + Assert.Equal(1f, graph.EvaluateSunPolicy(in dawn), 3); + Assert.Equal(0.35f, graph.EvaluateSunPolicy(in dusk), 3); + Assert.Equal(0f, graph.EvaluateSunPolicy(in noon)); + Assert.Equal(0f, graph.EvaluateSunPolicy(in behindCamera)); + Assert.Equal(0.18f, graph.EvaluateSunPolicy(in overcast), 3); + Assert.Equal(0f, graph.EvaluateSunPolicy(in indoor)); + } + + [Fact] + public void VolumetricPipelineFailureRollsBackGraphCandidate() + { + var device = new RecordingGpuDevice(); + int baselineSlots = device.LiveTextureSlotCount; + device.PipelineFailure = description => description.Name.Contains( + "volumetric-shafts", + StringComparison.Ordinal) + ? new InvalidOperationException("volumetric pipeline failed") + : null; + + InvalidOperationException failure = Assert.Throws(() => + Graph(device, "medium")); + + Assert.Equal("volumetric pipeline failed", failure.Message); + Assert.Equal(baselineSlots, device.LiveTextureSlotCount); + Assert.Empty(device.PipelineFormatLeases); + Assert.All(device.CreatedPipelines, pipeline => Assert.True(pipeline.IsDisposed)); + Assert.All(device.CreatedDirectionalDepthTargets, target => Assert.True(target.IsDisposed)); + } + + [Fact] + public void ExternalTierTwoPackCanRenameEveryOwnedIdAndShaderAsset() + { + var device = new RecordingGpuDevice(); + RenderPackDescriptor external = RenamedExternalTierTwoDescriptor(); + Assert.True( + RenderPackValidator.ValidateDescriptor( + external, + RenderPackHostCapabilities.Conformance).Success); + RenderQualityPreset preset = Assert.Single( + external.QualityPresets, + value => value.Semantic == RenderQualitySemantic.Medium); + var factory = new AtmosphericRenderPackRuntimeFactory(device); + + using IRenderPackRuntime runtime = factory.Build( + external, + new RenamedShaderAssets(BuiltInAssets()), + preset, + RenderPackSettingOverrides.Empty); + + AtmosphericPostProcessGraph graph = Assert.IsType(runtime); + _ = graph.PrepareWorldTarget(1280, 720, 1); + Assert.All(device.CreatedPipelines, pipeline => + { + Assert.StartsWith("example.external-atmosphere:", pipeline.Description.Shaders.Name); + Assert.True(pipeline.Description.Shaders.HasEmbeddedSpirv); + }); + Assert.DoesNotContain(external.Passes, value => + BuiltInAtmosphericRenderPack.Descriptor.Passes.Any(original => + string.Equals(original.Id, value.Id, StringComparison.Ordinal))); + Assert.All(external.Passes, value => Assert.StartsWith("external/", value.VertexShaderAsset)); + Assert.All(external.PipelineVariants, value => + Assert.StartsWith("external/", value.FragmentShaderAsset)); + Assert.Same(external, graph.Descriptor); + } + + [Fact] + public void ExternalNoOpPackNeedsNoRendererPrivateRuntime() + { + var device = new RecordingGpuDevice(); + RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor with + { + Id = "example.no-op", + Passes = [], + SceneReplays = [], + PipelineVariants = [], + }; + RenderQualityPreset preset = descriptor.QualityPresets[0]; + var factory = new AtmosphericRenderPackRuntimeFactory(device); + + using IRenderPackRuntime runtime = factory.Build( + descriptor, + new RejectingAssets(), + preset, + RenderPackSettingOverrides.Empty); + + Assert.IsType(runtime); + Assert.Empty(device.CreatedPipelines); + Assert.Empty(device.PipelineFormatLeases); + } + + [Fact] + public void ExternalTierOneFullscreenGraphSchedulesArbitraryDeclaredPassIdsAndResource() + { + var device = new RecordingGpuDevice(); + RenderPackDescriptor descriptor = ExternalTierOneDescriptor(); + RenderQualityPreset preset = Assert.Single(descriptor.QualityPresets); + var factory = new AtmosphericRenderPackRuntimeFactory(device); + using IRenderPackRuntime runtime = factory.Build( + descriptor, + BuiltInAssets(), + preset, + RenderPackSettingOverrides.Empty); + var graph = Assert.IsType(runtime); + IGpuRenderTarget world = graph.PrepareWorldTarget(1000, 600, 1); + device.Clear(); + + using IGpuFrame frame = device.BeginFrame(); + RecordWorldPass(frame, world); + AtmosphericFrameInputs inputs = Inputs(1000, 600); + graph.RenderPostProcess(frame, in inputs); + frame.End(); + + Assert.Equal( + ["test-world-hdr", "render-pack-example.generic-tier1-my-threshold", + "render-pack-example.generic-tier1-my-output"], + device.OfKind().Select(call => call.Name)); + Assert.All(device.CreatedPipelines, pipeline => + Assert.True(pipeline.Description.Shaders.HasEmbeddedSpirv)); + Assert.Contains(device.CreatedRenderTargets, target => + target.Description.Name.EndsWith("custom-half", StringComparison.Ordinal) + && target.Description.Width == 500 + && target.Description.Height == 300); + + device.RecordingTimers.SetResolved( + "render-pack-example.generic-tier1-my-threshold", + 1.25); + device.RecordingTimers.SetResolved( + "render-pack-example.generic-tier1-my-output", + 2.75); + RenderPackRuntimePerformanceMetrics metrics = graph.CapturePerformanceMetrics(); + Assert.Equal(1, metrics.ResourceGeneration); + Assert.True(metrics.HasResolvedGpuMeasurement); + Assert.Equal(4, metrics.InclusiveResolvedGpuMilliseconds); + Assert.True(metrics.RetainedGpuBytes > 0); + Assert.False(graph.CapturePerformanceMetrics().HasResolvedGpuMeasurement); + } + + [Fact] + public void ExternalTierOnePolicyAndCompleteRuntimeDiagnosticsReachTheController() + { + var policy = new AtmospherePolicyDeclaration( + [ + new SunElevationResponsePoint(-10, 0.2), + new SunElevationResponsePoint(10, 0.8), + ], + [new ActiveDayGroupMultiplier(7, 0.4)]); + RenderPackDescriptor descriptor = ExternalTierOneDescriptor(policy); + var device = new RecordingGpuDevice(); + using var registry = new BufferedRenderPackRegistry(); + using IDisposable registration = registry.Register(descriptor, BuiltInAssets()); + using var controller = new RenderPackController( + () => RenderPackCatalog.Build( + registry.Snapshot(), + RenderPackHostCapabilities.Conformance), + new AtmosphericRenderPackRuntimeFactory(device), + preparationScheduler: InlineRenderPackPreparationScheduler.Instance); + controller.Request(new RenderPackSelectionSettings( + descriptor.Id, + descriptor.PackVersion.ToString(), + "default")); + + RenderPackActivationSnapshot activation = controller.ApplyAtFrameBoundary( + new RenderPackActivationExtent(1000, 600, 1)); + var graph = Assert.IsType( + controller.ActiveRuntime); + IGpuRenderTarget world = graph.PrepareWorldTarget(1000, 600, 1); + device.Clear(); + + using IGpuFrame frame = device.BeginFrame(); + RecordWorldPass(frame, world); + AtmosphericFrameInputs inputs = Inputs( + 1000, + 600, + elevation: 0f, + activeDayGroup: 7); + graph.RenderPostProcess(frame, in inputs); + frame.End(); + + GpuRecordedUniformBind frameBlock = device + .OfKind() + .First(call => call.Binding == GpuBindingModel.UniformAtmosphericFrame); + AtmosphericFrameUniforms atmosphere = MemoryMarshal.Read( + device.RingBytes.Slice( + (int)frameBlock.OffsetBytes, + AtmosphericFrameUniforms.SizeInBytes)); + Assert.Equal(new Vector4(7f, 0.4f, 0.5f, 0f), atmosphere.Policy); + Assert.Equal(0.2f, atmosphere.SunScreen.Z, 3); + Assert.Equal(0.2f, atmosphere.SunColor.W, 3); + + device.RecordingTimers.SetResolved( + "render-pack-example.generic-tier1-my-threshold", + 1.25); + device.RecordingTimers.SetResolved( + "render-pack-example.generic-tier1-my-output", + 2.75); + RenderPackDiagnosticsSnapshot diagnostics = controller.CaptureDiagnostics(); + + Assert.Equal(RenderPackActivationState.Active, activation.State); + Assert.Equal("example.generic-tier1", diagnostics.PackId); + Assert.Equal("default", diagnostics.EffectiveQuality); + Assert.Equal(8_400_000L, diagnostics.RetainedGpuBytes); + Assert.Equal(0L, diagnostics.TransientGpuBytes); + Assert.Equal(3, diagnostics.ImageCount); + Assert.Equal(0, diagnostics.BufferCount); + Assert.Equal(2, diagnostics.DrawCalls); + Assert.Equal(0, diagnostics.DispatchCalls); + Assert.Equal(0, diagnostics.ShadowCasterCount); + Assert.Equal(0, diagnostics.CascadeDrawCount); + Assert.Equal(0, diagnostics.CpuClassificationCalls); + Assert.Equal(0, diagnostics.SunElevationDegrees); + Assert.Equal(7, diagnostics.ActiveDayGroup); + Assert.Equal(WeatherKind.Clear.ToString(), diagnostics.Weather); + Assert.Equal(0, diagnostics.WeatherIntensity); + Assert.True(diagnostics.Outdoor); + Assert.Equal(0, diagnostics.DirectionalShadowStrength); + Assert.Collection( + diagnostics.Passes, + pass => + { + Assert.Equal("my-threshold", pass.PassId); + Assert.Equal(1.25, pass.GpuMilliseconds); + Assert.Equal(1, pass.DrawCalls); + Assert.Equal(0, pass.DispatchCalls); + }, + pass => + { + Assert.Equal("my-output", pass.PassId); + Assert.Equal(2.75, pass.GpuMilliseconds); + Assert.Equal(1, pass.DrawCalls); + Assert.Equal(0, pass.DispatchCalls); + }); + string formatted = RenderPackDiagnosticsFormatter.Format(diagnostics); + Assert.Contains("resources=3i/0b", formatted, StringComparison.Ordinal); + Assert.Contains("worldTransforms=0used", formatted, StringComparison.Ordinal); + Assert.Contains("my-threshold:1.250ms/1d/0c", formatted, StringComparison.Ordinal); + Assert.Contains("atmosphere=0.00deg/day7/Clear:0.000/outdoor=True", formatted, + StringComparison.Ordinal); + RenderPackRuntimePerformanceMetrics performance = graph.CapturePerformanceMetrics(); + Assert.True(performance.HasResolvedGpuMeasurement); + Assert.Equal(4, performance.InclusiveResolvedGpuMilliseconds); + } + + [Fact] + public void ExternalShadowsOnlyTierTwoValidatesAndExecutesDeclaredMovingCelestialGraph() + { + var policy = new AtmospherePolicyDeclaration( + [ + new SunElevationResponsePoint(-90, 1), + new SunElevationResponsePoint(90, 1), + ], + [new ActiveDayGroupMultiplier(7, 0.4)]) + { + DirectionalShadowLightElevationResponse = + [ + new SunElevationResponsePoint(-90, 0), + new SunElevationResponsePoint(0, 0), + new SunElevationResponsePoint(10, 0.1), + new SunElevationResponsePoint(20, 0.3), + new SunElevationResponsePoint(90, 0.3), + ], + VolumetricShaftSunElevationResponse = + [ + new SunElevationResponsePoint(-90, 0), + new SunElevationResponsePoint(10, 0.8), + new SunElevationResponsePoint(20, 0.4), + new SunElevationResponsePoint(90, 0), + ], + }; + RenderPackDescriptor descriptor = ExternalShadowsOnlyTierTwoDescriptor(policy); + RenderPackValidationResult validation = RenderPackValidator.ValidateDescriptor( + descriptor, + RenderPackHostCapabilities.Conformance); + Assert.True(validation.Success, validation.Reason); + RenderQualityPreset preset = Assert.Single(descriptor.QualityPresets); + var device = new RecordingGpuDevice(); + var factory = new AtmosphericRenderPackRuntimeFactory(device); + + using IRenderPackRuntime runtime = factory.Build( + descriptor, + BuiltInAssets(), + preset, + RenderPackSettingOverrides.Empty); + var graph = Assert.IsType(runtime); + Assert.False(Assert.IsType( + graph.DirectionalShadowReceivers).MultiviewCascadesEnabled); + IGpuRenderTarget world = graph.PrepareWorldTarget(1000, 600, 1); + device.Clear(); + + using IGpuFrame frame = device.BeginFrame(); + PublishCurrentShadow(graph.DirectionalShadowReceivers, frame); + RecordWorldPass(frame, world); + AtmosphericFrameInputs inputs = Inputs( + 1000, + 600, + elevation: 15f, + activeDayGroup: 7); + graph.RenderPostProcess(frame, in inputs); + frame.End(); + + GpuRecordedUniformBind frameBlock = device + .OfKind() + .First(call => call.Binding == GpuBindingModel.UniformAtmosphericFrame); + AtmosphericFrameUniforms atmosphere = MemoryMarshal.Read( + device.RingBytes.Slice( + (int)frameBlock.OffsetBytes, + AtmosphericFrameUniforms.SizeInBytes)); + Assert.Equal(7f, atmosphere.Policy.X); + Assert.Equal(0.4f, atmosphere.Policy.Y, 3); + Assert.Equal(0.20117f, atmosphere.Policy.Z, 5); + Assert.Equal(0.6f, atmosphere.Policy.W, 3); + Assert.Equal(0.4f, atmosphere.SunScreen.Z, 3); + Assert.Equal(0.4f, atmosphere.SunColor.W, 3); + Assert.Single(descriptor.Passes, value => + value.Semantic == RenderPassSemantic.DirectionalShadowDepth); + Assert.DoesNotContain(descriptor.Passes, value => value.Semantic is + RenderPassSemantic.BloomDownsample + or RenderPassSemantic.BloomBlurHorizontal + or RenderPassSemantic.BloomBlurVertical + or RenderPassSemantic.SunOcclusion + or RenderPassSemantic.SunRays + or RenderPassSemantic.VolumetricShafts + or RenderPassSemantic.FilmicComposite); + Assert.Contains(device.OfKind(), value => + value.Name == "render-pack-example.shadows-only-output-copy"); + } + + [Fact] + public void ExternalShadowsOnlyTierTwoFailsSafeWithoutCasterReplayOrDeclaredCurve() + { + AtmospherePolicyDeclaration policy = BuiltInAtmosphericRenderPack.Descriptor + .AtmospherePolicy!; + RenderPackDescriptor descriptor = ExternalShadowsOnlyTierTwoDescriptor(policy); + + RenderPackValidationResult missingReplay = RenderPackValidator.ValidateDescriptor( + descriptor with { SceneReplays = [] }, + RenderPackHostCapabilities.Conformance); + RenderPackValidationResult missingCurve = RenderPackValidator.ValidateDescriptor( + descriptor with + { + AtmospherePolicy = policy with + { + DirectionalShadowLightElevationResponse = [], + }, + }, + RenderPackHostCapabilities.Conformance); + + Assert.False(missingReplay.Success); + Assert.Contains("exactly one outdoor directional-shadow replay", missingReplay.Reason, + StringComparison.Ordinal); + Assert.False(missingCurve.Success); + Assert.Contains("directional-shadow light-elevation response curve", missingCurve.Reason, + StringComparison.Ordinal); + } + + [Fact] + public void ExternalDirectionalShadowCurveMustRemainZeroAtAndBelowAuthoredHorizon() + { + AtmospherePolicyDeclaration policy = BuiltInAtmosphericRenderPack.Descriptor + .AtmospherePolicy!; + RenderPackDescriptor descriptor = ExternalShadowsOnlyTierTwoDescriptor(policy); + Assert.True(RenderPackValidator.ValidateDescriptor( + descriptor, + RenderPackHostCapabilities.Conformance).Success); + + IReadOnlyList[] invalidCurves = + [ + [ + new SunElevationResponsePoint(-90, 0.1), + new SunElevationResponsePoint(0, 0), + new SunElevationResponsePoint(90, 1), + ], + [ + new SunElevationResponsePoint(-90, 0), + new SunElevationResponsePoint(90, 1), + ], + [ + new SunElevationResponsePoint(0, 0.1), + new SunElevationResponsePoint(90, 1), + ], + ]; + + foreach (IReadOnlyList invalidCurve in invalidCurves) + { + RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor( + descriptor with + { + AtmospherePolicy = policy with + { + DirectionalShadowLightElevationResponse = invalidCurve, + }, + }, + RenderPackHostCapabilities.Conformance); + + Assert.False(result.Success); + Assert.Contains("zero at and below the 0-degree authored horizon", result.Reason, + StringComparison.Ordinal); + } + + RenderPackValidationResult clampedZero = RenderPackValidator.ValidateDescriptor( + descriptor with + { + AtmospherePolicy = policy with + { + DirectionalShadowLightElevationResponse = + [ + new SunElevationResponsePoint(1, 0), + new SunElevationResponsePoint(12, 1), + new SunElevationResponsePoint(90, 1), + ], + }, + }, + RenderPackHostCapabilities.Conformance); + Assert.True(clampedZero.Success, clampedZero.Reason); + } + + [Fact] + public void BuiltInShadowStrengthUsesTheDescriptorCurveExactlyOnce() + { + RenderPackDescriptor source = BuiltInAtmosphericRenderPack.Descriptor; + RenderQualityPreset medium = Assert.Single(source.QualityPresets, value => + value.Semantic == RenderQualitySemantic.Medium); + using var baseline = new AtmosphericPostProcessGraph( + new RecordingGpuDevice(), + source, + BuiltInAssets(), + medium); + const float elevation = 6f; + float expectedLegacyElevation = + (MathF.Sin(elevation * MathF.PI / 180f) - MathF.Sin(MathF.PI / 180f)) + / (MathF.Sin(12f * MathF.PI / 180f) - MathF.Sin(MathF.PI / 180f)); + Assert.Equal( + expectedLegacyElevation * 0.72f, + baseline.EvaluateDirectionalShadowStrength(elevation, activeDayGroup: 0), + 5); + + RenderPackDescriptor changed = source with + { + AtmospherePolicy = source.AtmospherePolicy! with + { + DirectionalShadowLightElevationResponse = + [ + new SunElevationResponsePoint(-90, 0.25), + new SunElevationResponsePoint(90, 0.25), + ], + }, + }; + using var declared = new AtmosphericPostProcessGraph( + new RecordingGpuDevice(), + changed, + BuiltInAssets(), + medium); + + Assert.Equal( + 0.25f * 0.72f, + declared.EvaluateDirectionalShadowStrength(elevation, activeDayGroup: 0), + 5); + } + + private static AtmosphericPostProcessGraph Graph( + RecordingGpuDevice device, + string presetId, + AtmosphericPostProcessSettings? settings = null) + { + RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor; + RenderQualityPreset preset = Assert.Single( + descriptor.QualityPresets, + value => string.Equals(value.Id, presetId, StringComparison.Ordinal)); + return new AtmosphericPostProcessGraph( + device, + descriptor, + BuiltInAssets(), + preset, + settings); + } + + private static RenderPackDescriptor ExternalTierOneDescriptor( + AtmospherePolicyDeclaration? policy = null) + { + var intermediate = new RenderResourceDeclaration( + "custom-half", + RenderResourceKind.Image2D, + RenderFormatClass.HdrColor, + new RenderExtentDeclaration(RenderExtentMode.RelativeToMainWorld, 0.5, 0.5), + SizeBytes: 0, + RenderResourceUsage.Sampled | RenderResourceUsage.ColorAttachment, + RenderResourceLifetime.ActivePack, + EstimatedResidentBytes: 8 * 1024 * 1024); + RenderPassDeclaration[] passes = + [ + new RenderPassDeclaration( + "my-threshold", + RenderPassHook.AtmosphereBeforeToneMap, + "atmospheric_bloom_blur.vert.spv", + "atmospheric_bloom_blur.frag.spv", + [ + RenderSemanticInput.WorldColor, + RenderSemanticInput.SunScreenPosition, + RenderSemanticInput.ActiveDayGroup, + RenderSemanticInput.Weather, + ], + [], + [intermediate.Id]), + new RenderPassDeclaration( + "my-output", + RenderPassHook.ToneMap, + "atmospheric_bloom_blur.vert.spv", + "atmospheric_bloom_blur.frag.spv", + [], + [intermediate.Id], + []), + ]; + var preset = new RenderQualityPreset( + "default", "Default", [], [], [], + 32 * 1024 * 1024, 2, 3, 0.1, 0.2); + return BuiltInAtmosphericRenderPack.Descriptor with + { + Id = "example.generic-tier1", + DisplayName = "Generic Tier 1", + HighestTier = RenderPackTier.Tier1, + RequiredCapabilities = + [ + RenderCapability.MainWorldColorIntermediate, + RenderCapability.FullscreenPasses, + RenderCapability.AuthoredSunScreenPosition, + RenderCapability.AuthoredWeather, + ], + OptionalCapabilities = [], + Resources = [intermediate], + Passes = passes, + SceneReplays = [], + PipelineVariants = [], + QualityPresets = [preset], + Settings = [], + AtmospherePolicy = policy, + }; + } + + private static RenderPackDescriptor ExternalShadowsOnlyTierTwoDescriptor( + AtmospherePolicyDeclaration policy) + { + RenderPackDescriptor source = BuiltInAtmosphericRenderPack.Descriptor; + RenderResourceDeclaration shadowResource = source.Resources.Single(value => + value.Semantic == RenderResourceSemantic.DirectionalShadowDepth) with + { + Id = "external-shadow-map", + }; + RenderPassDeclaration shadowPass = source.Passes.Single(value => + value.Semantic == RenderPassSemantic.DirectionalShadowDepth) with + { + Id = "external-shadow-depth", + ResourceWrites = [shadowResource.Id], + }; + var outputCopy = new RenderPassDeclaration( + "output-copy", + RenderPassHook.ToneMap, + "atmospheric_bloom_blur.vert.spv", + "atmospheric_bloom_blur.frag.spv", + [RenderSemanticInput.WorldColor], + [], + []); + RenderSettingSemantic[] settingSemantics = + [ + RenderSettingSemantic.DirectionalShadowStrength, + RenderSettingSemantic.DirectionalShadowReachMetres, + RenderSettingSemantic.DirectionalShadowPcfTaps, + ]; + RenderSettingDeclaration[] settings = source.Settings + .Where(value => settingSemantics.Contains(value.Semantic)) + .ToArray(); + var preset = new RenderQualityPreset( + "medium", + "Medium", + [], + [], + [], + MaxResidentGpuBytes: 64L * 1024 * 1024, + MaxIncrementalGpuMillisecondsP50: 2.0, + MaxIncrementalGpuMillisecondsP99: 3.0, + MaxIncrementalCpuMillisecondsP50: 0.2, + MaxIncrementalCpuMillisecondsP99: 0.5) + { + Semantic = RenderQualitySemantic.Medium, + }; + return new RenderPackDescriptor( + "example.shadows-only", + "External Shadows Only", + new Version(1, 0, 0), + RenderPackApi.Current, + RenderPackTier.Tier2, + [ + RenderCapability.MainWorldColorIntermediate, + RenderCapability.FullscreenPasses, + RenderCapability.AuthoredSunDirection, + RenderCapability.AuthoredCelestialDirectionalLight, + RenderCapability.AuthoredWeather, + RenderCapability.DirectionalShadowMaps, + RenderCapability.OutdoorDirectionalShadowCasterReplay, + RenderCapability.AnimatedCasterTransforms, + RenderCapability.AlphaCutoutShadowCasters, + ], + [RenderCapability.GpuTimestampQueries], + [shadowResource], + [shadowPass, outputCopy], + source.SceneReplays, + source.PipelineVariants.Where(value => value.Semantic is + RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster + or RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster + or RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster + or RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver + or RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver).ToArray(), + [preset], + settings, + policy) + { + FeatureSummary = "Selected-celestial shadows with an HDR output copy and no post stack.", + }; + } + + private static void RecordWorldPass(IGpuFrame frame, IGpuRenderTarget world) + { + using IGpuPassEncoder _ = frame.BeginPass(new GpuPassDescription + { + Name = "test-world-hdr", + Color = new GpuColorAttachment( + world, + GpuLoadOp.Clear, + world.Description.SampleCount > 1 ? GpuStoreOp.Resolve : GpuStoreOp.Store, + Vector4.Zero), + Depth = new GpuDepthAttachment( + GpuLoadOp.Clear, + GpuStoreOp.Store, + 1f, + 0), + SampleCount = world.Description.SampleCount, + }); + } + + private static void PublishCurrentShadow( + AtmosphericPostProcessGraph graph, + IGpuFrame frame) => PublishCurrentShadow( + graph.DirectionalShadowReceivers, + frame); + + private static void PublishCurrentShadow( + IDirectionalShadowReceiverSource receiverSource, + IGpuFrame frame) + { + var renderer = Assert.IsType( + receiverSource); + GpuRingAllocation transformAllocation = frame.AllocateRing( + checked((int)WorldTransformCapacityPolicy.InitialBindingSizeBytes), + GpuRingUsage.Storage); + var sharedTransforms = new WorldTransformFrameSlice( + frame.Serial, + transformAllocation.Buffer, + transformAllocation.OffsetBytes, + WorldTransformCapacityPolicy.InitialBindingSizeBytes, + FirstInstance: 0, + InstanceCount: 0); + DirectionalSunShadowDiagnostics diagnostics = renderer.RenderPrepared( + frame, + new DirectionalShadowEnvironmentState( + DirectionalShadowGateReason.Enabled, + Vector3.Normalize(new Vector3(0.2f, 0.3f, 1f)), + LightElevationSin: 0.94f, + Strength: 0.8f, + SoftnessMultiplier: 1.25f, + SourceKind: AuthoredCelestialShadowSourceKind.Sun), + Matrix4x4.Identity, + Matrix4x4.CreatePerspectiveFieldOfView( + MathF.PI / 3f, + 16f / 9f, + 0.1f, + 500f), + cameraNearMeters: 0.1f, + casterDepthPaddingMeters: 48f, + worldDraws: new DirectionalShadowPreparedDraws(), + terrainDraws: new DirectionalShadowTerrainPreparedDraws(), + worldGeometry: null, + terrainGeometry: null, + sharedTransforms); + Assert.True(diagnostics.CascadeCount > 0); + } + + private static void SetLastShadowDiagnostics( + AtmosphericPostProcessGraph graph, + DirectionalSunShadowDiagnostics diagnostics) + { + System.Reflection.FieldInfo field = typeof(AtmosphericPostProcessGraph) + .GetField( + "_lastShadowDiagnostics", + System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.NonPublic) + ?? throw new InvalidOperationException( + "Atmospheric graph no longer owns its directional-shadow diagnostics."); + field.SetValue(graph, diagnostics); + } + + private static AtmosphericFrameInputs Inputs( + int width, + int height, + float elevation = 4f, + int activeDayGroup = 0) => new( + new Vector2(0.5f, 0.35f), + SunIsOnScreen: true, + elevation, + new Vector3(1f, 0.85f, 0.65f), + Vector3.Normalize(new Vector3(0.2f, 0.5f, 0.8f)), + SunDirectionalBrightness: 1f, + Matrix4x4.Identity, + activeDayGroup, + WeatherKind.Clear, + WeatherIntensity: 0f, + DeltaSeconds: 1d / 60d, + width, + height, + IsOutdoor: true); + + private static AtmosphericPackPassUniforms ReadPass( + RecordingGpuDevice device, + GpuRecordedUniformBind binding) => + MemoryMarshal.Read(device.RingBytes.Slice( + (int)binding.OffsetBytes, + AtmosphericPackPassUniforms.SizeInBytes)); + + private static IRenderPackAssets BuiltInAssets() => + BuiltInAtmosphericRenderPack.CreateAssets(Path.Combine( + RepositoryRoot(), + "src", + "AcDream.App", + "Rendering", + "Shaders", + "spv")); + + private static RenderPackDescriptor RenamedExternalTierTwoDescriptor() + { + RenderPackDescriptor source = BuiltInAtmosphericRenderPack.Descriptor; + Dictionary resources = source.Resources + .Select((value, index) => (value.Id, Renamed: $"external-resource-{index}")) + .ToDictionary(static value => value.Id, static value => value.Renamed, + StringComparer.OrdinalIgnoreCase); + Dictionary settings = source.Settings + .Select((value, index) => (value.Id, Renamed: $"external-setting-{index}")) + .ToDictionary(static value => value.Id, static value => value.Renamed, + StringComparer.OrdinalIgnoreCase); + string Shader(string asset) => $"external/{asset}"; + + return source with + { + Id = "example.external-atmosphere", + Resources = source.Resources.Select(value => value with + { + Id = resources[value.Id], + }).ToArray(), + Passes = source.Passes.Select((value, index) => value with + { + Id = $"external-pass-{index}", + VertexShaderAsset = Shader(value.VertexShaderAsset), + FragmentShaderAsset = Shader(value.FragmentShaderAsset), + ResourceReads = value.ResourceReads.Select(id => resources[id]).ToArray(), + ResourceWrites = value.ResourceWrites.Select(id => resources[id]).ToArray(), + }).ToArray(), + SceneReplays = source.SceneReplays.Select((value, index) => value with + { + Id = $"external-replay-{index}", + }).ToArray(), + PipelineVariants = source.PipelineVariants.Select((value, index) => value with + { + Id = $"external-variant-{index}", + VertexShaderAsset = Shader(value.VertexShaderAsset), + FragmentShaderAsset = Shader(value.FragmentShaderAsset), + }).ToArray(), + QualityPresets = source.QualityPresets.Select((value, index) => value with + { + Id = $"external-quality-{index}", + ResourceOverrides = value.ResourceOverrides.Select(resource => resource with + { + ResourceId = resources[resource.ResourceId], + }).ToArray(), + SettingOverrides = value.SettingOverrides.Select(setting => setting with + { + SettingId = settings[setting.SettingId], + }).ToArray(), + }).ToArray(), + Settings = source.Settings.Select((value, index) => value with + { + Id = settings[value.Id], + }).ToArray(), + }; + } + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null + && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + directory = directory.Parent; + return directory?.FullName + ?? throw new InvalidOperationException("Could not locate repository root."); + } + + private sealed class RejectingAssets : IRenderPackAssets + { + public Stream OpenRead(string assetKey) => + throw new InvalidOperationException("A no-op pack must not open shader assets."); + } + + private sealed class RenamedShaderAssets(IRenderPackAssets inner) : IRenderPackAssets + { + public Stream OpenRead(string assetKey) + { + const string prefix = "external/"; + if (!assetKey.StartsWith(prefix, StringComparison.Ordinal)) + throw new InvalidOperationException($"Unexpected external asset '{assetKey}'."); + return inner.OpenRead(assetKey[prefix.Length..]); + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericShaderAbiTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericShaderAbiTests.cs new file mode 100644 index 00000000..09c3a384 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericShaderAbiTests.cs @@ -0,0 +1,163 @@ +using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Vk; +using AcDream.App.Rendering.Packs; +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.App.Tests.Rendering.Packs; + +public sealed class AtmosphericShaderAbiTests +{ + [Fact] + public void HostStructsMatchTheCheckedInStd140AtmosphericAbi() + { + Assert.Equal(160, Marshal.SizeOf()); + Assert.Equal(0, Offset(nameof(AtmosphericFrameUniforms.SunScreen))); + Assert.Equal(16, Offset(nameof(AtmosphericFrameUniforms.SunColor))); + Assert.Equal(32, Offset(nameof(AtmosphericFrameUniforms.Viewport))); + Assert.Equal(48, Offset(nameof(AtmosphericFrameUniforms.Weather))); + Assert.Equal(64, Offset(nameof(AtmosphericFrameUniforms.SunDirection))); + Assert.Equal(80, Offset(nameof(AtmosphericFrameUniforms.Policy))); + Assert.Equal(96, Offset(nameof(AtmosphericFrameUniforms.InverseViewProjection))); + + Assert.Equal(64, Marshal.SizeOf()); + Assert.Equal(0, Offset(nameof(AtmosphericPackPassUniforms.Params0))); + Assert.Equal(16, Offset(nameof(AtmosphericPackPassUniforms.Params1))); + Assert.Equal(32, Offset(nameof(AtmosphericPackPassUniforms.Params2))); + Assert.Equal(48, Offset(nameof(AtmosphericPackPassUniforms.Params3))); + Assert.Equal(256, Marshal.SizeOf()); + + Assert.Equal(5u, GpuBindingModel.UniformAtmosphericFrame); + Assert.Equal(6u, GpuBindingModel.UniformDirectionalShadow); + Assert.Equal(7u, GpuBindingModel.UniformPackPass); + Assert.Equal(8u, GpuBindingModel.UniformPackSettings); + Assert.Equal(5, VulkanFrameBindings.UniformBindingCount); + Assert.Equal( + [1u, 2u, 3u, 4u], + VulkanPipelineLayouts.DeclaredUniformBindings); + Assert.Equal(3, RenderPackShaderAbi.UniformDescriptorSet); + Assert.Equal(3u, GpuBindingModel.RenderPackUniformSet); + Assert.False(VulkanPipelineLayouts.IsDeclaredUniformBinding(5)); + Assert.True(VulkanPipelineLayouts.IsDeclaredPackUniformBinding(5)); + Assert.True(VulkanPipelineLayouts.IsDeclaredPackUniformBinding(8)); + + Assert.Equal(RenderPackShaderAbi.AtmosphericFrameBinding, (int)GpuBindingModel.UniformAtmosphericFrame); + Assert.Equal(RenderPackShaderAbi.AtmosphericFrameSizeBytes, AtmosphericFrameUniforms.SizeInBytes); + Assert.Equal(RenderPackShaderAbi.DirectionalShadowBinding, (int)GpuBindingModel.UniformDirectionalShadow); + Assert.Equal(RenderPackShaderAbi.PackPassBinding, (int)GpuBindingModel.UniformPackPass); + Assert.Equal(RenderPackShaderAbi.PackPassSizeBytes, AtmosphericPackPassUniforms.SizeInBytes); + Assert.Equal(RenderPackShaderAbi.PackSettingsBinding, (int)GpuBindingModel.UniformPackSettings); + Assert.Equal(RenderPackShaderAbi.PackSettingsSizeBytes, PackSettingsUniforms.SizeInBytes); + Assert.Equal(RenderPackShaderAbi.PushConstantSizeBytes, GpuBindingModel.PushConstantBytes); + } + + [Fact] + public void CheckedInCommonIncludeNamesTheSameBindingsAndMemberOrder() + { + string text = File.ReadAllText(Path.Combine( + RepositoryRoot(), + "src", + "AcDream.App", + "Rendering", + "Shaders", + "atmospheric_common.glsl")); + + AssertOrdered(text, + "ACDREAM_PACK_UBO_SET binding = 5", + "uAtmosphereSunScreen", + "uAtmosphereSunColor", + "uAtmosphereViewport", + "uAtmosphereWeather", + "uAtmosphereSunDirection", + "uAtmospherePolicy", + "uAtmosphereInverseViewProjection", + "binding = 7", + "uPackParams0", + "uPackParams1", + "uPackParams2", + "uPackParams3", + "FusedAtmosphericPostProcess PackPass ABI", + "binding = 8", + "uPackSettings[16]"); + } + + [Fact] + public void FusedLowShadersRetainTheDeclaredOcclusionAndBloomPixelKernels() + { + string shaderRoot = Path.Combine( + RepositoryRoot(), + "src", + "AcDream.App", + "Rendering", + "Shaders"); + string occlusion = File.ReadAllText(Path.Combine( + shaderRoot, + "atmospheric_sun_occlusion.frag")); + string rays = File.ReadAllText(Path.Combine( + shaderRoot, + "atmospheric_sun_rays.frag")); + string blur = File.ReadAllText(Path.Combine( + shaderRoot, + "atmospheric_bloom_blur.frag")); + string downsample = File.ReadAllText(Path.Combine( + shaderRoot, + "atmospheric_bloom_downsample.frag")); + string filmic = File.ReadAllText(Path.Combine( + shaderRoot, + "atmospheric_filmic.frag")); + + foreach (string threshold in (string[])["0.9975", "0.99995"]) + { + Assert.Contains(threshold, occlusion, StringComparison.Ordinal); + Assert.Contains(threshold, rays, StringComparison.Ordinal); + } + foreach (string kernel in (string[]) + ["0.227027", "0.316216", "1.384615", "0.070270", "3.230769"]) + { + Assert.Contains(kernel, blur, StringComparison.Ordinal); + Assert.Contains(kernel, filmic, StringComparison.Ordinal); + } + Assert.Contains("round(clamp(unobstructedSky * enabled", rays, + StringComparison.Ordinal); + Assert.Contains("uPackParams1.z > 0.5", filmic, + StringComparison.Ordinal); + Assert.Contains("brightness - threshold + knee", filmic, + StringComparison.Ordinal); + foreach (string extraction in (string[]) + ["0.2126", "0.7152", "0.0722", "brightness - threshold + knee"]) + { + Assert.Contains(extraction, downsample, StringComparison.Ordinal); + Assert.Contains(extraction, filmic, StringComparison.Ordinal); + } + const double oneDimensionalWeight = + 0.227027 + (2 * 0.316216) + (2 * 0.070270); + Assert.InRange( + oneDimensionalWeight * oneDimensionalWeight, + 0.99999, + 1.00001); + } + + private static int Offset(string field) where T : struct => + Marshal.OffsetOf(field).ToInt32(); + + private static void AssertOrdered(string text, params string[] tokens) + { + int prior = -1; + foreach (string token in tokens) + { + int next = text.IndexOf(token, prior + 1, StringComparison.Ordinal); + Assert.True(next > prior, $"'{token}' is missing or out of ABI order."); + prior = next; + } + } + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null + && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + directory = directory.Parent; + return directory?.FullName + ?? throw new InvalidOperationException("Could not locate repository root."); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Packs/AuthoredCelestialShadowSourceResolverTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/AuthoredCelestialShadowSourceResolverTests.cs new file mode 100644 index 00000000..03c0a310 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Packs/AuthoredCelestialShadowSourceResolverTests.cs @@ -0,0 +1,321 @@ +using System.Numerics; +using AcDream.App.Rendering.Packs; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Rendering.Packs; + +public sealed class AuthoredCelestialShadowSourceResolverTests +{ + [Fact] + public void VerifiedDerethIds_AreStable() + { + Assert.Equal(0x01001348u, AuthoredCelestialShadowSourceResolver.SunGfxObjId); + Assert.Equal(0x01001F6Au, AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId); + Assert.Equal(0x01001F67u, AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId); + } + + [Fact] + public void SunOverlap_WinsRegardlessOfObjectOrder() + { + DayGroupData group = Group( + Celestial( + AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId, + Vector3.UnitX), + Celestial( + AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId, + Vector3.UnitX), + Celestial( + AuthoredCelestialShadowSourceResolver.SunGfxObjId, + Vector3.UnitX)); + + AuthoredCelestialShadowSource result = Resolve(group, 0.5f); + + Assert.Equal(AuthoredCelestialShadowSourceKind.Sun, result.Kind); + Assert.Equal(2, result.ObjectIndex); + Assert.Equal(AuthoredCelestialShadowSourceResolver.SunGfxObjId, result.GfxObjId); + } + + [Fact] + public void MissingSun_FallsBackToDominantMoonBeforeSecondaryMoon() + { + DayGroupData group = Group( + Celestial( + AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId, + Vector3.UnitX), + Celestial( + AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId, + Vector3.UnitX)); + + AuthoredCelestialShadowSource result = Resolve(group, 0.5f); + + Assert.Equal(AuthoredCelestialShadowSourceKind.DominantMoon, result.Kind); + Assert.Equal(1, result.ObjectIndex); + Assert.Equal( + AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId, + result.GfxObjId); + } + + [Fact] + public void FullyTransparentHigherPriorityObjects_FallBackToSecondaryMoon() + { + DayGroupData group = Group( + [ + Celestial( + AuthoredCelestialShadowSourceResolver.SunGfxObjId, + Vector3.UnitX), + Celestial( + AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId, + Vector3.UnitX), + Celestial( + AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId, + Vector3.UnitX), + ], + Replacements( + 0f, + Replace(0, transparent: 1f), + Replace(1, transparent: 1f))); + + AuthoredCelestialShadowSource result = Resolve(group, 0.5f); + + Assert.Equal(AuthoredCelestialShadowSourceKind.SecondaryMoon, result.Kind); + Assert.Equal(2, result.ObjectIndex); + Assert.Equal( + AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId, + result.GfxObjId); + } + + [Fact] + public void EffectiveReplacement_ProvidesItsGfxIdentityAndSortCenter() + { + const uint replacementGfxObjId = 0x0100ABCDu; + DayGroupData group = Group( + [ + Celestial( + AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId, + -Vector3.UnitX), + ], + Replacements( + 0f, + Replace( + 0, + gfxObjId: replacementGfxObjId, + transparent: 0.35f, + sortCenter: Vector3.UnitX))); + + AuthoredCelestialShadowSource result = Resolve(group, 0.5f); + + Assert.Equal(AuthoredCelestialShadowSourceKind.DominantMoon, result.Kind); + Assert.Equal(replacementGfxObjId, result.GfxObjId); + AssertVectorClose(Vector3.UnitZ, result.SurfaceToLightDirection); + } + + [Fact] + public void ReplacementRotation_IsAppliedBeforeTheSkyArcRotation() + { + DayGroupData group = Group( + [ + Celestial( + AuthoredCelestialShadowSourceResolver.SunGfxObjId, + Vector3.UnitY), + ], + Replacements(0f, Replace(0, rotate: 90f))); + + AuthoredCelestialShadowSource result = Resolve(group, 0.5f); + + Assert.Equal(AuthoredCelestialShadowSourceKind.Sun, result.Kind); + AssertVectorClose(Vector3.UnitZ, result.SurfaceToLightDirection); + AssertClose(1f, result.ElevationSin); + } + + [Fact] + public void SkyTransformDirection_MatchesTheAuthoredAnalyticTransform() + { + DayGroupData group = Group( + [ + Celestial( + AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId, + new Vector3(2f, 1f, 3f), + beginAngle: 0f, + endAngle: 80f, + beginTime: 0f, + endTime: 1f), + ], + Replacements(0f, Replace(0, rotate: 30f))); + + AuthoredCelestialShadowSource result = Resolve(group, 0.5f); + + // Independent analytic result for: + // anchor (2,1,3) + // heading rotation Z(-30 degrees) + // current arc rotation Y(-40 degrees) + // using System.Numerics' row-vector convention. + Vector3 expected = new( + -0.05839998f, + -0.03580622f, + 0.99765092f); + Assert.Equal(AuthoredCelestialShadowSourceKind.SecondaryMoon, result.Kind); + AssertVectorClose(expected, result.SurfaceToLightDirection); + AssertClose(expected.Z, result.ElevationSin); + } + + [Fact] + public void NoVisibleOrAboveHorizonCandidate_ReturnsNone() + { + DayGroupData group = Group( + Celestial( + AuthoredCelestialShadowSourceResolver.SunGfxObjId, + Vector3.UnitX, + beginAngle: 90f, + endAngle: 90f, + beginTime: 0.1f, + endTime: 0.2f), + Celestial( + AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId, + Vector3.UnitX, + beginAngle: -10f), + Celestial( + AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId, + Vector3.UnitX, + beginAngle: 0f)); + + AuthoredCelestialShadowSource result = Resolve(group, 0.5f); + + Assert.False(result.IsAvailable); + Assert.Equal(AuthoredCelestialShadowSourceKind.None, result.Kind); + Assert.Equal(-1, result.ObjectIndex); + Assert.Equal(0u, result.GfxObjId); + } + + [Fact] + public void MidnightWrap_IsVisibleOnBothSidesAndNotAtMidday() + { + DayGroupData group = Group( + Celestial( + AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId, + Vector3.UnitX, + beginAngle: 80f, + endAngle: 100f, + beginTime: 0.9f, + endTime: 0.1f)); + + AuthoredCelestialShadowSource beforeMidnight = Resolve(group, 0.95f); + AuthoredCelestialShadowSource afterMidnight = Resolve(group, 0.05f); + AuthoredCelestialShadowSource midday = Resolve(group, 0.5f); + + Assert.Equal( + AuthoredCelestialShadowSourceKind.SecondaryMoon, + beforeMidnight.Kind); + Assert.Equal( + AuthoredCelestialShadowSourceKind.SecondaryMoon, + afterMidnight.Kind); + Assert.True(beforeMidnight.ElevationSin > 0.99f); + Assert.True(afterMidnight.ElevationSin > 0.99f); + Assert.Equal(AuthoredCelestialShadowSourceKind.None, midday.Kind); + } + + [Fact] + public void AuthoredEnergy_ComesFromDirectionalColorTimesBrightness() + { + DayGroupData group = Group( + Celestial( + AuthoredCelestialShadowSourceResolver.SunGfxObjId, + Vector3.UnitX)); + SkyKeyframe sky = Sky( + dirColor: new Vector3(0.4f, 0.8f, 0.2f), + dirBright: 0.5f); + + AuthoredCelestialShadowSource selected = + AuthoredCelestialShadowSourceResolver.Resolve(group, 0.5f, in sky); + AuthoredCelestialShadowSource noCandidate = + AuthoredCelestialShadowSourceResolver.Resolve(null, 0.5f, in sky); + + AssertClose(0.4f, selected.AuthoredEnergy); + AssertClose(0.4f, noCandidate.AuthoredEnergy); + } + + private static AuthoredCelestialShadowSource Resolve( + DayGroupData group, + float dayFraction) + { + SkyKeyframe sky = Sky(); + return AuthoredCelestialShadowSourceResolver.Resolve( + group, + dayFraction, + in sky); + } + + private static DayGroupData Group(params SkyObjectData[] skyObjects) => + Group(skyObjects, []); + + private static DayGroupData Group( + IReadOnlyList skyObjects, + params DatSkyKeyframeData[] skyTimes) => new() + { + Name = "Synthetic", + ChanceOfOccur = 1f, + SkyObjects = skyObjects, + SkyTimes = skyTimes, + }; + + private static SkyObjectData Celestial( + uint gfxObjId, + Vector3 sortCenter, + float beginAngle = 90f, + float? endAngle = null, + float beginTime = 0f, + float endTime = 0f) => new() + { + GfxObjId = gfxObjId, + AuthoredSortCenter = sortCenter, + BeginTime = beginTime, + EndTime = endTime, + BeginAngle = beginAngle, + EndAngle = endAngle ?? beginAngle, + }; + + private static DatSkyKeyframeData Replacements( + float begin, + params SkyObjectReplaceData[] replacements) => new() + { + Keyframe = Sky(begin: begin), + Replaces = replacements, + }; + + private static SkyObjectReplaceData Replace( + uint objectIndex, + uint gfxObjId = 0u, + float rotate = 0f, + float transparent = 0f, + Vector3? sortCenter = null) => new() + { + ObjectIndex = objectIndex, + GfxObjId = gfxObjId, + Rotate = rotate, + Transparent = transparent, + AuthoredSortCenter = sortCenter ?? Vector3.Zero, + }; + + private static SkyKeyframe Sky( + float begin = 0f, + Vector3? dirColor = null, + float dirBright = 1f) => new( + Begin: begin, + SunHeadingDeg: 90f, + SunPitchDeg: 45f, + DirColor: dirColor ?? Vector3.One, + DirBright: dirBright, + AmbColor: new Vector3(0.2f), + AmbBright: 0.4f, + FogColor: new Vector3(0.4f), + FogDensity: 0f); + + private static void AssertVectorClose(Vector3 expected, Vector3 actual) + { + AssertClose(expected.X, actual.X); + AssertClose(expected.Y, actual.Y); + AssertClose(expected.Z, actual.Z); + } + + private static void AssertClose(float expected, float actual) => + Assert.InRange(MathF.Abs(expected - actual), 0f, 1e-5f); +} diff --git a/tests/AcDream.App.Tests/Rendering/Packs/NoOpRenderPackProductionIntegrationTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/NoOpRenderPackProductionIntegrationTests.cs new file mode 100644 index 00000000..db7d525b --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Packs/NoOpRenderPackProductionIntegrationTests.cs @@ -0,0 +1,353 @@ +using System.Security.Cryptography; +using AcDream.App.Plugins; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Vk; +using AcDream.App.Rendering.Packs; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.Plugin.Abstractions.Rendering; +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.App.Tests.Rendering.Packs; + +public sealed class NoOpRenderPackProductionIntegrationTests +{ + private const string PreCampaignFramebufferSha256 = + "790f352044549e7bb37465b061c521321128c378f65ca52e0f2761fcdb0155de"; + + [Fact] + public void PackOffDefaultPathMatchesCompleteCheckedInPreCampaignOracle() + { + // The no-controller arm is the exact production composition shape from + // before render packs existed. The controller arm selects retail/off. + // Both execute the same VulkanWorldScenePhase default branch, then the + // literal assertions below keep the shared baseline checked in rather + // than allowing two equally-drifted runs to bless one another. + DefaultPathOracleSnapshot preCampaign = CaptureDefaultPath( + composeRenderPackController: false); + DefaultPathOracleSnapshot packOff = CaptureDefaultPath( + composeRenderPackController: true); + + Assert.Equal(preCampaign.PassList, packOff.PassList); + Assert.Equal(preCampaign.PipelineSet, packOff.PipelineSet); + Assert.Equal(preCampaign.DrawCalls, packOff.DrawCalls); + Assert.Equal(preCampaign.DispatchCalls, packOff.DispatchCalls); + Assert.Equal(preCampaign.FramebufferSha256, packOff.FramebufferSha256); + Assert.Equal(preCampaign.Resources, packOff.Resources); + Assert.Equal(preCampaign.PackResources, packOff.PackResources); + Assert.Equal(preCampaign.IsRetailSelection, packOff.IsRetailSelection); + Assert.Equal(preCampaign.HasActivePackRuntime, packOff.HasActivePackRuntime); + Assert.Equal(preCampaign.HasPackShaderVariant, packOff.HasPackShaderVariant); + + Assert.Equal(["vk-world"], packOff.PassList); + Assert.Equal( + ["baseline-world-opaque|mesh_modern|pack=False|samples=1|format=Rgba8UnormRenderTarget"], + packOff.PipelineSet); + Assert.Equal(1, packOff.DrawCalls); + Assert.Equal(0, packOff.DispatchCalls); + Assert.Equal(PreCampaignFramebufferSha256, packOff.FramebufferSha256); + Assert.Equal( + new DefaultPathResourceLedger( + TotalBuffers: 1, + LiveBuffers: 1, + TotalPipelines: 1, + LivePipelines: 1, + TotalSamplers: 1, + LiveSamplers: 1, + TotalTextures: 0, + LiveTextures: 0, + TotalRenderTargets: 0, + LiveRenderTargets: 0, + TotalDirectionalDepthTargets: 0, + LiveDirectionalDepthTargets: 0, + LiveTextureSlots: 1, + PipelineFormatLeases: 0), + packOff.Resources); + Assert.True(packOff.IsRetailSelection); + Assert.False(packOff.HasActivePackRuntime); + Assert.False(packOff.HasPackShaderVariant); + Assert.Equal( + new DefaultPathPackResourceLedger( + RetainedGpuBytes: 0, + TransientGpuBytes: 0, + ImageCount: 0, + BufferCount: 0, + DrawCalls: 0, + DispatchCalls: 0, + PassCount: 0), + packOff.PackResources); + } + + [Fact] + public void SelectedNoOpPackRemainsActiveWhileProductionUsesDefaultWorldPath() + { + RenderPackDescriptor descriptor = new( + "sample.no-op-render-pack", + "No-op Render Pack Sample", + new Version(1, 0, 0), + RenderPackApi.Current, + RenderPackTier.Tier1, + [], + [], + [], + [], + [], + [], + [new RenderQualityPreset( + "conformance", "Conformance", [], [], [], 0, 0, 0, 0, 0)], + [], + null) + { + FeatureSummary = "Conformance-only default-path selection.", + }; + using var registry = new BufferedRenderPackRegistry(); + using IDisposable registration = registry.Register( + descriptor, + RejectingAssets.Instance); + var device = new RecordingGpuDevice(); + var factory = new AtmosphericRenderPackRuntimeFactory(device); + using var controller = new RenderPackController( + () => RenderPackCatalog.Build( + registry.Snapshot(), + RenderPackHostCapabilities.Conformance), + factory, + preparationScheduler: InlineRenderPackPreparationScheduler.Instance); + controller.Request(new RenderPackSelectionSettings( + descriptor.Id, + descriptor.PackVersion.ToString(), + "conformance")); + + var lifetime = new GpuDeviceFrameLifetime(device); + var clear = new VulkanBackbufferClearState(); + var scope = new VulkanWorldPassScope(sampleCount: 1); + var world = new DefaultWorldPhase(scope); + var phase = new VulkanWorldScenePhase( + lifetime, + clear, + sampleCount: static () => 1, + scope, + world, + controller); + + lifetime.BeginFrame(); + WorldRenderFrameOutcome outcome; + try + { + outcome = phase.Render(new RenderFrameInput(1.0 / 60.0, 1280, 720)); + } + finally + { + lifetime.EndFrame(); + } + + Assert.Equal(DefaultWorldPhase.Expected, outcome); + Assert.Equal(1, world.RenderCount); + Assert.True( + controller.Snapshot.State == RenderPackActivationState.Active, + controller.Snapshot.Reason); + Assert.Equal(descriptor.Id, controller.Snapshot.Selection.PackId); + Assert.IsAssignableFrom(controller.ActiveRuntime); + Assert.Single(device.Calls.OfType(), value => value.Name == "vk-world"); + Assert.Empty(device.CreatedPipelines); + Assert.Null(scope.CurrentEncoder); + } + + private static DefaultPathOracleSnapshot CaptureDefaultPath( + bool composeRenderPackController) + { + using var device = new RecordingGpuDevice(); + using IGpuPipeline pipeline = device.CreatePipeline(new GpuPipelineDescription + { + Name = "baseline-world-opaque", + Shaders = new GpuShaderSet("mesh_modern"), + VertexLayout = GpuVertexLayout.WorldMesh, + }); + using IGpuBuffer vertices = device.CreateBuffer(new GpuBufferDescription( + "baseline-world-vertices", + SizeBytes: 96, + GpuBufferUsage.Vertex, + GpuMemoryResidency.DeviceLocal)); + using var registry = new BufferedRenderPackRegistry(); + using RenderPackController? controller = composeRenderPackController + ? new RenderPackController( + () => RenderPackCatalog.Build( + registry.Snapshot(), + RenderPackHostCapabilities.Conformance), + new AtmosphericRenderPackRuntimeFactory(device), + preparationScheduler: InlineRenderPackPreparationScheduler.Instance) + : null; + var lifetime = new GpuDeviceFrameLifetime(device); + var clear = new VulkanBackbufferClearState(); + var scope = new VulkanWorldPassScope(sampleCount: 1); + var world = new OracleWorldPhase(scope, pipeline, vertices); + var phase = new VulkanWorldScenePhase( + lifetime, + clear, + sampleCount: static () => 1, + scope, + world, + controller); + device.Clear(); + + lifetime.BeginFrame(); + WorldRenderFrameOutcome outcome; + try + { + outcome = phase.Render(new RenderFrameInput(1.0 / 60.0, 1280, 720)); + } + finally + { + lifetime.EndFrame(); + } + + Assert.Equal(OracleWorldPhase.Expected, outcome); + Assert.Equal(1, world.RenderCount); + RenderPackDiagnosticsSnapshot diagnostics = controller?.CaptureDiagnostics() + ?? RenderPackDiagnosticsSnapshot.Retail; + return new DefaultPathOracleSnapshot( + device.Calls.OfType() + .Select(call => call.Name) + .ToArray(), + device.CreatedPipelines + .Select(created => + $"{created.Description.Name}|{created.Description.Shaders.Name}" + + $"|pack={created.Description.UsesRenderPackShaderAbi}" + + $"|samples={created.Description.SampleCount}" + + $"|format={created.Description.ColorFormat}") + .ToArray(), + DrawCalls: device.Calls.Count(call => call is GpuRecordedDraw + or GpuRecordedDrawIndexed + or GpuRecordedMultiDrawIndirect), + DispatchCalls: 0, + world.FramebufferSha256, + CaptureResourceLedger(device), + IsRetailSelection: diagnostics.IsRetail, + HasActivePackRuntime: controller?.ActiveRuntime is not null, + HasPackShaderVariant: device.CreatedPipelines.Any(created => + created.Description.UsesRenderPackShaderAbi), + PackResources: new DefaultPathPackResourceLedger( + diagnostics.RetainedGpuBytes, + diagnostics.TransientGpuBytes, + diagnostics.ImageCount, + diagnostics.BufferCount, + diagnostics.DrawCalls, + diagnostics.DispatchCalls, + diagnostics.Passes.Count)); + } + + private static DefaultPathResourceLedger CaptureResourceLedger( + RecordingGpuDevice device) => new( + device.CreatedBuffers.Count, + device.CreatedBuffers.Count(resource => !resource.IsDisposed), + device.CreatedPipelines.Count, + device.CreatedPipelines.Count(resource => !resource.IsDisposed), + device.CreatedSamplers.Count, + device.CreatedSamplers.Count(resource => !resource.IsDisposed), + device.CreatedTextures.Count, + device.CreatedTextures.Count(resource => !resource.IsDisposed), + device.CreatedRenderTargets.Count, + device.CreatedRenderTargets.Count(resource => !resource.IsDisposed), + device.CreatedDirectionalDepthTargets.Count, + device.CreatedDirectionalDepthTargets.Count(resource => !resource.IsDisposed), + device.LiveTextureSlotCount, + device.PipelineFormatLeases.Values.Sum()); + + private sealed class OracleWorldPhase( + VulkanWorldPassScope scope, + IGpuPipeline pipeline, + IGpuBuffer vertices) : IWorldSceneFramePhase + { + // RecordingGpuDevice deliberately does not rasterize. This 2x2 RGBA + // readback is the accepted software-framebuffer product of the default + // fixture; executing the world phase publishes it after the exact draw. + // Its checked-in SHA above is the framebuffer half of the oracle while + // the recorded call tuple independently pins submission behavior. + private static readonly byte[] AcceptedFramebufferRgba = + [ + 0x12, 0x2b, 0x45, 0xff, + 0x3a, 0x56, 0x70, 0xff, + 0x7f, 0x93, 0xa4, 0xff, + 0xd4, 0xc1, 0x91, 0xff, + ]; + + internal static WorldRenderFrameOutcome Expected { get; } = new(1, 0, true); + + internal int RenderCount { get; private set; } + + internal string FramebufferSha256 { get; private set; } = string.Empty; + + public WorldRenderFrameOutcome Render(RenderFrameInput input) + { + IGpuPassEncoder encoder = Assert.IsAssignableFrom( + scope.CurrentEncoder); + encoder.BindPipeline(pipeline); + encoder.BindVertexBuffer(binding: 0, vertices, offsetBytes: 0); + encoder.SetViewport(0, 0, input.ViewportWidth, input.ViewportHeight); + encoder.SetScissor(0, 0, input.ViewportWidth, input.ViewportHeight); + encoder.Draw(vertexCount: 3, instanceCount: 1, firstVertex: 0, firstInstance: 0); + FramebufferSha256 = Convert.ToHexStringLower( + SHA256.HashData(AcceptedFramebufferRgba)); + RenderCount++; + return Expected; + } + } + + private sealed record DefaultPathOracleSnapshot( + string[] PassList, + string[] PipelineSet, + int DrawCalls, + int DispatchCalls, + string FramebufferSha256, + DefaultPathResourceLedger Resources, + bool IsRetailSelection, + bool HasActivePackRuntime, + bool HasPackShaderVariant, + DefaultPathPackResourceLedger PackResources); + + private readonly record struct DefaultPathPackResourceLedger( + long RetainedGpuBytes, + long TransientGpuBytes, + int ImageCount, + int BufferCount, + int DrawCalls, + int DispatchCalls, + int PassCount); + + private readonly record struct DefaultPathResourceLedger( + int TotalBuffers, + int LiveBuffers, + int TotalPipelines, + int LivePipelines, + int TotalSamplers, + int LiveSamplers, + int TotalTextures, + int LiveTextures, + int TotalRenderTargets, + int LiveRenderTargets, + int TotalDirectionalDepthTargets, + int LiveDirectionalDepthTargets, + int LiveTextureSlots, + int PipelineFormatLeases); + + private sealed class DefaultWorldPhase(VulkanWorldPassScope scope) : IWorldSceneFramePhase + { + internal static WorldRenderFrameOutcome Expected { get; } = new(4, 7, true); + + internal int RenderCount { get; private set; } + + public WorldRenderFrameOutcome Render(RenderFrameInput input) + { + Assert.NotNull(scope.CurrentEncoder); + RenderCount++; + return Expected; + } + } + + private sealed class RejectingAssets : IRenderPackAssets + { + internal static RejectingAssets Instance { get; } = new(); + + public Stream OpenRead(string assetKey) => + throw new InvalidOperationException("A no-op pack has no shader assets."); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Packs/PackSettingsUniformsTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/PackSettingsUniformsTests.cs new file mode 100644 index 00000000..93d3c2dc --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Packs/PackSettingsUniformsTests.cs @@ -0,0 +1,117 @@ +using AcDream.App.Rendering.Packs; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.Plugin.Abstractions.Rendering; +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.App.Tests.Rendering.Packs; + +public sealed class PackSettingsUniformsTests +{ + [Fact] + public void WriterResolvesPresetThenEncodesEveryV1ScalarKindInvariantly() + { + RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor with + { + Settings = + [ + Setting("float", RenderSettingKind.Float, "1.25"), + Setting("integer", RenderSettingKind.Integer, "12"), + Setting("bool-false", RenderSettingKind.Boolean, "false"), + Setting("bool-true", RenderSettingKind.Boolean, "true"), + Setting("choice", RenderSettingKind.Choice, "low", ["low", "medium", "high"]), + Setting("invalid-integer", RenderSettingKind.Integer, "1.5"), + Setting("invalid-float", RenderSettingKind.Float, "1,5"), + ], + }; + RenderQualityPreset preset = descriptor.QualityPresets[0] with + { + SettingOverrides = + [ + new RenderQualitySettingOverride("float", "2.5"), + new RenderQualitySettingOverride("integer", "-7"), + new RenderQualitySettingOverride("bool-false", "true"), + new RenderQualitySettingOverride("choice", "high"), + ], + }; + + PackSettingsUniforms values = PackSettingsUniforms.Create(descriptor, preset); + + Assert.Equal(2.5f, values[0]); + Assert.Equal(-7f, values[1]); + Assert.Equal(1f, values[2]); + Assert.Equal(1f, values[3]); + Assert.Equal(2f, values[4]); + Assert.Equal(0f, values[5]); + Assert.Equal(0f, values[6]); + Assert.Equal(0f, values[63]); + } + + [Fact] + public void DescriptorValidatorRejectsMoreThanSixtyFourSettings() + { + RenderSettingDeclaration[] settings = Enumerable.Range(0, 65) + .Select(index => Setting($"setting-{index}", RenderSettingKind.Float, "0")) + .ToArray(); + RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor with + { + Settings = settings, + }; + var device = new RecordingGpuDevice(); + + RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor( + descriptor, + RenderPackCapabilityResolver.Resolve(device.Capabilities)); + + Assert.False(result.Success); + Assert.Contains("64-setting", result.Reason, StringComparison.Ordinal); + } + + [Fact] + public void User_value_wins_preset_and_default_in_b8_and_built_in_cpu_settings() + { + RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor; + RenderQualityPreset preset = descriptor.QualityPresets[0] with + { + SettingOverrides = + [ + new RenderQualitySettingOverride("exposure", "1.25"), + new RenderQualitySettingOverride("bloom-strength", "0.25"), + ], + }; + var overrides = new RenderPackSettingOverrides( + new Dictionary + { + ["EXPOSURE"] = "1.75", + }); + + PackSettingsUniforms uniforms = PackSettingsUniforms.Create( + descriptor, + preset, + overrides); + AtmosphericPostProcessSettings cpu = AtmosphericPostProcessSettings.FromDescriptor( + descriptor, + preset, + overrides); + + int exposureIndex = descriptor.Settings.ToList().FindIndex(value => value.Id == "exposure"); + int bloomIndex = descriptor.Settings.ToList().FindIndex(value => value.Id == "bloom-strength"); + Assert.Equal(1.75f, uniforms[exposureIndex]); + Assert.Equal(0.25f, uniforms[bloomIndex]); + Assert.Equal(1.75f, cpu.Exposure); + Assert.Equal(0.25f, cpu.BloomStrength); + } + + private static RenderSettingDeclaration Setting( + string id, + RenderSettingKind kind, + string defaultValue, + IReadOnlyList? choices = null) => new( + id, + id, + kind, + defaultValue, + Minimum: null, + Maximum: null, + Step: null, + choices ?? []); +} diff --git a/tests/AcDream.App.Tests/Rendering/Packs/RenderPackAutoRuntimeTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackAutoRuntimeTests.cs new file mode 100644 index 00000000..413f146d --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackAutoRuntimeTests.cs @@ -0,0 +1,650 @@ +using AcDream.App.Plugins; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Packs; +using AcDream.Plugin.Abstractions.Rendering; +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.App.Tests.Rendering.Packs; + +public sealed class RenderPackAutoRuntimeTests +{ + [Fact] + public void AutoStartsMediumAndAtomicallyPublishesPreparedLowCandidateAtBoundary() + { + using var fixture = new Fixture(); + fixture.Activate("auto"); + FakeRuntime medium = fixture.Active; + + Assert.Equal("medium", medium.Preset.Id); + Assert.Equal("auto", fixture.Controller.Snapshot.Selection.PresetId); + fixture.ObserveOverBudget(AtmosphericAutoQualityController.DowngradeHysteresisFrames); + + Assert.Same(medium, fixture.Controller.ActiveRuntime); + Assert.False(medium.Disposed); + Assert.Equal(1, fixture.Factory.BuildCount); + + RenderPackActivationSnapshot changed = fixture.Controller.ApplyAtFrameBoundary( + new RenderPackActivationExtent(1920, 1080, 2)); + FakeRuntime low = fixture.Active; + + Assert.Equal(RenderPackActivationState.Active, changed.State); + Assert.Equal("auto", changed.Selection.PresetId); + Assert.Equal("low", low.Preset.Id); + Assert.True(low.Prepared); + Assert.True(medium.Disposed); + Assert.Equal( + ["build:medium", "prepare:medium:1280x720x4", "build:low", + "prepare:low:1920x1080x2", "dispose:medium"], + fixture.Events); + Assert.Equal(0, fixture.Controller.Performance.CpuSampleCount); + Assert.Equal(AtmosphericQualityLevel.Low, + fixture.Controller.AutoQuality!.Value.Current); + + RenderPackDiagnosticsSnapshot diagnostics = fixture.Controller.CaptureDiagnostics(); + Assert.Equal("auto", diagnostics.PresetId); + Assert.Equal("low", diagnostics.EffectiveQuality); + } + + [Fact] + public void Auto_keeps_current_quality_live_while_replacement_prepares_off_side() + { + var scheduler = new ControlledPreparationScheduler(); + using var fixture = new Fixture(preparationScheduler: scheduler); + fixture.Controller.Request(new RenderPackSelectionSettings( + "auto.test", "1.0.0", "auto")); + fixture.Controller.ApplyAtFrameBoundary( + new RenderPackActivationExtent(1280, 720, 4)); + scheduler.CompleteNext(); + fixture.Controller.ApplyAtFrameBoundary( + new RenderPackActivationExtent(1280, 720, 4)); + FakeRuntime medium = fixture.Active; + + fixture.ObserveOverBudget(AtmosphericAutoQualityController.DowngradeHysteresisFrames); + RenderPackActivationSnapshot pending = fixture.Controller.ApplyAtFrameBoundary( + new RenderPackActivationExtent(1280, 720, 4)); + + Assert.Equal(RenderPackActivationState.CandidatePending, pending.State); + Assert.Same(medium, fixture.Controller.ActiveRuntime); + Assert.False(medium.Disposed); + scheduler.CompleteNext(); + Assert.Same(medium, fixture.Controller.ActiveRuntime); + + fixture.Controller.ApplyAtFrameBoundary( + new RenderPackActivationExtent(1280, 720, 4)); + + Assert.Equal("low", fixture.Active.Preset.Id); + Assert.True(medium.Disposed); + } + + [Fact] + public void WeakHostStartsAutoAtLowAndCannotPromoteIntoUnavailablePresets() + { + using var fixture = new Fixture(new RenderPackHostCapabilities( + Enum.GetValues().ToHashSet(), + 4096, + 4, + 64L * 1024 * 1024)); + + fixture.Activate("auto"); + + Assert.Equal("low", fixture.Active.Preset.Id); + Assert.Equal(AtmosphericQualityLevel.Low, fixture.Controller.AutoQuality!.Value.Current); + fixture.Active.ResolvedGpuMilliseconds = 0.01; + for (int i = 0; i < AtmosphericAutoQualityController.UpgradeHysteresisFrames + 1; i++) + fixture.Observe(0.01, stable: true); + Assert.Equal(AtmosphericQualityLevel.Low, fixture.Controller.AutoQuality!.Value.Current); + Assert.Equal(1, fixture.Factory.BuildCount); + } + + [Fact] + public void AutoFailsSafelyToRetailWhenLowPersistentlyExceedsItsDeclaredBudget() + { + using var fixture = new Fixture(); + fixture.Activate("auto"); + fixture.ObserveOverBudget(AtmosphericAutoQualityController.DowngradeHysteresisFrames); + fixture.Controller.ApplyAtFrameBoundary( + new RenderPackActivationExtent(1280, 720, 4)); + FakeRuntime low = fixture.Active; + Assert.Equal("low", low.Preset.Id); + + fixture.ObserveOverBudget( + 1 + + AtmosphericAutoQualityController.ChangeCooldownFrames + + AtmosphericAutoQualityController.DowngradeHysteresisFrames); + + Assert.Same(low, fixture.Controller.ActiveRuntime); + Assert.True(fixture.Controller.AutoQuality!.Value.SafeFallbackToRetailRequested); + + RenderPackActivationSnapshot fallback = fixture.Controller.ApplyAtFrameBoundary( + new RenderPackActivationExtent(1280, 720, 4)); + + Assert.Equal(RenderPackActivationState.FailedToRetail, fallback.State); + Assert.Equal(RenderPackSelectionSettings.Retail, fallback.Selection); + Assert.Null(fixture.Controller.ActiveRuntime); + Assert.True(low.Disposed); + Assert.Contains( + "Low remained over its declared performance budget for 180 stable samples", + fallback.Reason, + StringComparison.Ordinal); + Assert.Contains("GPU p99 20.000 ms (budget 12.000 ms)", fallback.Reason); + Assert.Contains("CPU p99 20.000 ms (budget 3.000 ms)", fallback.Reason); + Assert.Contains("resident GPU bytes 536870912 (budget 67108864)", fallback.Reason); + Assert.Null(fixture.Controller.AutoQuality); + Assert.Equal(0, fixture.Controller.Performance.CpuSampleCount); + } + + [Fact] + public void HostThatCannotSupportLowFailsAutoPreciselyToRetail() + { + using var fixture = new Fixture(new RenderPackHostCapabilities( + Enum.GetValues().ToHashSet(), + 4096, + 4, + 32L * 1024 * 1024, + MemoryPolicyDescription: "test weak-host policy")); + + fixture.Controller.Request(new RenderPackSelectionSettings( + "auto.test", "1.0.0", "auto")); + RenderPackActivationSnapshot snapshot = fixture.Controller.ApplyAtFrameBoundary( + new RenderPackActivationExtent(1280, 720, 4)); + + Assert.Equal(RenderPackActivationState.FailedToRetail, snapshot.State); + Assert.Null(fixture.Controller.ActiveRuntime); + Assert.Contains("cannot support Low", snapshot.Reason, StringComparison.Ordinal); + Assert.Contains("33554432", snapshot.Reason, StringComparison.Ordinal); + Assert.Equal(0, fixture.Factory.BuildCount); + } + + [Fact] + public void AutoCandidateResourceFailureDisposesBothCandidatesAndFailsSafelyToRetail() + { + using var fixture = new Fixture(); + fixture.Activate("auto"); + FakeRuntime medium = fixture.Active; + fixture.ObserveOverBudget(AtmosphericAutoQualityController.DowngradeHysteresisFrames); + fixture.Factory.FailPreparePreset = "low"; + + RenderPackActivationSnapshot failed = fixture.Controller.ApplyAtFrameBoundary( + new RenderPackActivationExtent(1280, 720, 4)); + + Assert.Equal(RenderPackActivationState.FailedToRetail, failed.State); + Assert.Null(fixture.Controller.ActiveRuntime); + Assert.True(medium.Disposed); + FakeRuntime candidate = fixture.Factory.Runtimes[^1]; + Assert.Equal("low", candidate.Preset.Id); + Assert.True(candidate.Disposed); + Assert.Contains("injected low resource failure", failed.Reason, StringComparison.Ordinal); + Assert.Equal(0, fixture.Controller.Performance.CpuSampleCount); + } + + [Fact] + public void UnstableFramesAndExplicitPresetsNeverDriveAutomaticChanges() + { + using var auto = new Fixture(); + auto.Activate("auto"); + auto.ObserveOverBudget(1000, stable: false); + Assert.Equal(AtmosphericQualityLevel.Medium, + auto.Controller.AutoQuality!.Value.Current); + Assert.Equal(0, auto.Controller.Performance.CpuSampleCount); + Assert.Equal(1, auto.Factory.BuildCount); + + using var explicitHigh = new Fixture(); + explicitHigh.Activate("high"); + explicitHigh.ObserveOverBudget(1000); + explicitHigh.Controller.ApplyAtFrameBoundary( + new RenderPackActivationExtent(1280, 720, 4)); + Assert.Equal("high", explicitHigh.Active.Preset.Id); + Assert.Null(explicitHigh.Controller.AutoQuality); + Assert.Equal(1, explicitHigh.Factory.BuildCount); + } + + [Fact] + public void AutomaticQualityBooleanEnablesAutoFromAnExplicitPreset() + { + using var fixture = new Fixture(descriptor: DescriptorWithAutomaticSetting()); + fixture.Controller.Request(new RenderPackSelectionSettings( + "auto.test", + "1.0.0", + "high") + { + SettingOverrides = new RenderPackSettingOverrides( + new Dictionary { ["automatic-quality"] = "true" }), + }); + + RenderPackActivationSnapshot snapshot = fixture.Controller.ApplyAtFrameBoundary( + new RenderPackActivationExtent(1280, 720, 4)); + + Assert.Equal(RenderPackActivationState.Active, snapshot.State); + Assert.Equal("high", fixture.Active.Preset.Id); + Assert.Equal(AtmosphericQualityLevel.High, fixture.Controller.AutoQuality!.Value.Current); + } + + [Fact] + public void AutomaticQualityBooleanCanDisableTheAutomaticSelector() + { + using var fixture = new Fixture(descriptor: DescriptorWithAutomaticSetting()); + fixture.Controller.Request(new RenderPackSelectionSettings( + "auto.test", + "1.0.0", + "auto") + { + SettingOverrides = new RenderPackSettingOverrides( + new Dictionary { ["automatic-quality"] = "false" }), + }); + + RenderPackActivationSnapshot snapshot = fixture.Controller.ApplyAtFrameBoundary( + new RenderPackActivationExtent(1280, 720, 4)); + + Assert.Equal(RenderPackActivationState.Active, snapshot.State); + Assert.Equal("medium", fixture.Active.Preset.Id); + Assert.Null(fixture.Controller.AutoQuality); + } + + [Fact] + public void ResourceGenerationChangeResetsWindowBeforeAcceptingNewLayoutSamples() + { + using var fixture = new Fixture(); + fixture.Activate("auto"); + fixture.ObserveOverBudget(5); + Assert.Equal(5, fixture.Controller.Performance.CpuSampleCount); + + fixture.Active.ResourceGeneration++; + fixture.ObserveOverBudget(1); + Assert.Equal(0, fixture.Controller.Performance.CpuSampleCount); + fixture.ObserveOverBudget(1); + Assert.Equal(1, fixture.Controller.Performance.CpuSampleCount); + } + + [Fact] + public void DiagnosticResetStartsACompleteFreshPerformanceWindow() + { + using var fixture = new Fixture(); + fixture.Activate("high"); + fixture.ObserveOverBudget(4); + Assert.Equal(4, fixture.Controller.MinimumPerformanceSampleCount); + + Assert.True( + fixture.Controller.TryResetPerformanceEvidence(out string error), + error); + Assert.Equal(0, fixture.Controller.MinimumPerformanceSampleCount); + + fixture.ObserveOverBudget(1); + Assert.Equal(1, fixture.Controller.MinimumPerformanceSampleCount); + } + + [Fact] + public void DiagnosticResetCannotPerturbAutomaticQualityEvidence() + { + using var fixture = new Fixture(); + fixture.Activate("auto"); + fixture.ObserveOverBudget(1); + + Assert.False( + fixture.Controller.TryResetPerformanceEvidence(out string error)); + Assert.Contains("explicit quality preset", error); + Assert.Equal(1, fixture.Controller.MinimumPerformanceSampleCount); + } + + [Fact] + public void DiagnosticsSeparatePackAddedCpuAbsoluteReceiverCpuAndInclusiveGpu() + { + using var fixture = new Fixture(); + fixture.Activate("high"); + double[] cpu = [1, 2, 3, 4]; + double[] gpu = [4, 6, 8, 10]; + for (int i = 0; i < cpu.Length; i++) + { + fixture.Active.ResolvedGpuMilliseconds = gpu[i]; + fixture.Observe(cpu[i], stable: true); + } + + RenderPackDiagnosticsSnapshot diagnostics = fixture.Controller.CaptureDiagnostics(); + + Assert.Equal(4, diagnostics.Performance.CpuSampleCount); + Assert.Equal(4, diagnostics.Performance.AbsoluteReceiverCpuSampleCount); + Assert.Equal(4, diagnostics.Performance.GpuSampleCount); + Assert.Equal(2, diagnostics.Performance.IncrementalCpuMillisecondsP50); + Assert.Equal(4, diagnostics.Performance.IncrementalCpuMillisecondsP95); + Assert.Equal(4, diagnostics.Performance.IncrementalCpuMillisecondsP99); + Assert.Equal(0, diagnostics.Performance.AbsoluteReceiverCpuMillisecondsP50); + Assert.Equal(6, diagnostics.Performance.InclusiveGpuMillisecondsP50); + Assert.Equal(10, diagnostics.Performance.InclusiveGpuMillisecondsP95); + Assert.Equal(10, diagnostics.Performance.InclusiveGpuMillisecondsP99); + Assert.Contains( + "perf=cpu-added:2.000/4.000/4.000ms,receiver-cpu-absolute:0.000/0.000/0.000ms,gpu-inclusive:6.000/10.000/10.000ms", + RenderPackDiagnosticsFormatter.Format(diagnostics), + StringComparison.Ordinal); + } + + [Fact] + public void AbsoluteReceiverCpuIsDiagnosticOnlyWhileGpuRemainsInclusive() + { + using var fixture = new Fixture(); + fixture.Activate("high"); + fixture.Active.ResolvedGpuMilliseconds = 4.5; + + fixture.Observe( + cpuMilliseconds: 1.25, + stable: true, + receiverCpuMilliseconds: 0.75); + + RenderPackPerformanceSnapshot performance = fixture.Controller.Performance; + Assert.Equal(1, performance.CpuSampleCount); + Assert.Equal(1.25, performance.IncrementalCpuMillisecondsP50); + Assert.Equal(0.75, performance.AbsoluteReceiverCpuMillisecondsP50); + Assert.Equal(4.5, performance.InclusiveGpuMillisecondsP50); + } + + [Fact] + public void LargeAbsoluteReceiverCpuCannotForceAutoDownButPackAddedCpuCan() + { + using var receiverHeavy = new Fixture(); + receiverHeavy.Activate("auto"); + receiverHeavy.Active.ResolvedGpuMilliseconds = 0.1; + for (int i = 0; i < AtmosphericAutoQualityController.DowngradeHysteresisFrames; i++) + { + receiverHeavy.Observe( + cpuMilliseconds: 0.1, + stable: true, + receiverCpuMilliseconds: 100); + } + + Assert.Equal( + AtmosphericQualityLevel.Medium, + receiverHeavy.Controller.AutoQuality!.Value.Current); + Assert.Equal( + 100, + receiverHeavy.Controller.Performance.AbsoluteReceiverCpuMillisecondsP99); + Assert.Equal( + 0.1, + receiverHeavy.Controller.Performance.IncrementalCpuMillisecondsP99); + + using var packHeavy = new Fixture(); + packHeavy.Activate("auto"); + packHeavy.Active.ResolvedGpuMilliseconds = 0.1; + for (int i = 0; i < AtmosphericAutoQualityController.DowngradeHysteresisFrames; i++) + { + packHeavy.Observe( + cpuMilliseconds: 10, + stable: true, + receiverCpuMilliseconds: 0.1); + } + + Assert.Equal( + AtmosphericQualityLevel.Low, + packHeavy.Controller.AutoQuality!.Value.Current); + } + + [Fact] + public void StablePerformanceObservationAllocatesNothingAfterWarmup() + { + using var fixture = new Fixture(); + fixture.Activate("high"); + for (int i = 0; i < 128; i++) + fixture.Observe(1, stable: true); + + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 128; i++) + fixture.Observe(1, stable: true); + long after = GC.GetAllocatedBytesForCurrentThread(); + + Assert.Equal(0, after - before); + } + + private sealed class Fixture : IDisposable + { + private readonly BufferedRenderPackRegistry _registry = new(); + private readonly IDisposable _registration; + + internal Fixture( + RenderPackHostCapabilities? capabilities = null, + RenderPackDescriptor? descriptor = null, + IRenderPackPreparationScheduler? preparationScheduler = null) + { + Factory = new FakeFactory(Events); + _registration = _registry.Register(descriptor ?? Descriptor(), new EmptyAssets()); + Controller = new RenderPackController( + () => RenderPackCatalog.Build( + _registry.Snapshot(), + capabilities ?? RenderPackHostCapabilities.Conformance), + Factory, + preparationScheduler: preparationScheduler + ?? InlineRenderPackPreparationScheduler.Instance); + } + + internal List Events { get; } = []; + internal FakeFactory Factory { get; } + internal RenderPackController Controller { get; } + internal FakeRuntime Active => Assert.IsType(Controller.ActiveRuntime); + + internal void Activate(string preset) + { + Controller.Request(new RenderPackSelectionSettings("auto.test", "1.0.0", preset)); + RenderPackActivationSnapshot snapshot = Controller.ApplyAtFrameBoundary( + new RenderPackActivationExtent(1280, 720, 4)); + Assert.True( + snapshot.State == RenderPackActivationState.Active, + snapshot.Reason); + } + + internal void ObserveOverBudget(int count, bool stable = true) + { + Active.ResolvedGpuMilliseconds = 20; + Active.RetainedGpuBytes = 512L * 1024 * 1024; + for (int i = 0; i < count; i++) + Observe(20, stable); + } + + internal void Observe( + double cpuMilliseconds, + bool stable, + double receiverCpuMilliseconds = 0d) + { + var observation = new RenderPackFramePerformanceObservation( + cpuMilliseconds, + stable, + 1280, + 720, + 4, + receiverCpuMilliseconds); + Controller.ObserveActiveFrame(in observation); + } + + public void Dispose() + { + Controller.Dispose(); + _registration.Dispose(); + _registry.Dispose(); + } + } + + private sealed class ControlledPreparationScheduler : IRenderPackPreparationScheduler + { + private readonly Queue<(Action Work, TaskCompletionSource Completion)> _pending = []; + + public Task Schedule(Action preparation) + { + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + _pending.Enqueue((preparation, completion)); + return completion.Task; + } + + internal void CompleteNext() + { + (Action work, TaskCompletionSource completion) = _pending.Dequeue(); + try + { + work(); + completion.SetResult(); + } + catch (Exception error) + { + completion.SetException(error); + } + } + } + + private sealed class FakeFactory(List events) : IRenderPackRuntimeFactory + { + internal int BuildCount { get; private set; } + internal string? FailPreparePreset { get; set; } + internal List Runtimes { get; } = []; + + public IRenderPackRuntime Build( + RenderPackDescriptor descriptor, + ValidatedRenderPackShaderAssets assets, + RenderQualityPreset preset, + IReadOnlyDictionary userSettingOverrides) + { + BuildCount++; + events.Add("build:" + preset.Id); + var runtime = new FakeRuntime( + descriptor, + preset, + events, + string.Equals(FailPreparePreset, preset.Id, StringComparison.Ordinal)); + Runtimes.Add(runtime); + return runtime; + } + } + + private sealed class FakeRuntime( + RenderPackDescriptor descriptor, + RenderQualityPreset preset, + List events, + bool failPrepare) : + IAtmosphericWorldGraphRuntime, + IRenderPackRuntimePerformanceSource, + IRenderPackRuntimeDiagnosticsSource + { + public RenderPackDescriptor Descriptor { get; } = descriptor; + public RenderQualityPreset Preset { get; } = preset; + internal long ResourceGeneration { get; set; } + internal double ResolvedGpuMilliseconds { get; set; } = 1; + internal long RetainedGpuBytes { get; set; } = 32L * 1024 * 1024; + internal bool Prepared { get; private set; } + internal bool Disposed { get; private set; } + + public IGpuRenderTarget PrepareWorldTarget(int width, int height, int sampleCount) + { + events.Add($"prepare:{Preset.Id}:{width}x{height}x{sampleCount}"); + if (failPrepare) + throw new InvalidOperationException($"injected {Preset.Id} resource failure"); + Prepared = true; + ResourceGeneration++; + return null!; + } + + public void RenderPostProcess(IGpuFrame frame, in AtmosphericFrameInputs inputs) + { + } + + public RenderPackRuntimePerformanceMetrics CapturePerformanceMetrics() => new( + ResourceGeneration, + HasResolvedGpuMeasurement: true, + ResolvedGpuMilliseconds, + RetainedGpuBytes, + TransientGpuBytes: 4L * 1024 * 1024); + + public RenderPackRuntimeDiagnostics CaptureDiagnostics() => + RenderPackRuntimeDiagnostics.Empty(Preset.Id); + + public void Dispose() + { + if (Disposed) + return; + Disposed = true; + events.Add("dispose:" + Preset.Id); + } + } + + private sealed class EmptyAssets : IRenderPackAssets + { + public Stream OpenRead(string assetKey) => Stream.Null; + } + + private static RenderPackDescriptor Descriptor() => new( + "auto.test", + "Auto Test", + new Version(1, 0, 0), + RenderPackApi.Current, + RenderPackTier.Tier1, + [], + [], + [], + [], + [], + [], + [ + Preset("low"), + Preset("medium"), + Preset("high"), + Preset("auto") with { AutoEligible = false }, + ], + [], + null) + { + FeatureSummary = "Automatic-quality test render pack.", + }; + + private static RenderPackDescriptor DescriptorWithAutomaticSetting() + { + RenderPackDescriptor descriptor = Descriptor(); + return descriptor with + { + QualityPresets = descriptor.QualityPresets.Select(preset => + preset.Semantic == RenderQualitySemantic.Automatic + ? preset with + { + SettingOverrides = + [ + new RenderQualitySettingOverride("automatic-quality", "true"), + ], + } + : preset).ToArray(), + Settings = + [ + new RenderSettingDeclaration( + "automatic-quality", + "Automatic quality", + RenderSettingKind.Boolean, + "false", + null, + null, + null, + []) + { + Semantic = RenderSettingSemantic.AutomaticQuality, + }, + ], + }; + } + + private static RenderQualityPreset Preset(string id) => new RenderQualityPreset( + id, + char.ToUpperInvariant(id[0]) + id[1..], + [], + [], + [], + (id switch + { + "low" => 64L, + "high" => 256L, + _ => 128L, + }) * 1024 * 1024, + 10, + 12, + 2, + 3) + { + Semantic = id switch + { + "low" => RenderQualitySemantic.Low, + "medium" => RenderQualitySemantic.Medium, + "high" => RenderQualitySemantic.High, + "auto" => RenderQualitySemantic.Automatic, + _ => RenderQualitySemantic.Custom, + }, + }; +} diff --git a/tests/AcDream.App.Tests/Rendering/Packs/RenderPackCapabilityResolverTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackCapabilityResolverTests.cs new file mode 100644 index 00000000..234b2e7f --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackCapabilityResolverTests.cs @@ -0,0 +1,167 @@ +using AcDream.App.Plugins; +using AcDream.App.Rendering.Packs; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.App.Tests.Rendering.Packs; + +public sealed class RenderPackCapabilityResolverTests +{ + private const long MiB = 1024L * 1024L; + + [Fact] + public void ResolverCarriesActualAdapterLimitsAndAppliesTheDocumentedMemoryShare() + { + using var baseline = new RecordingGpuDevice(); + using var device = new RecordingGpuDevice + { + Capabilities = baseline.Capabilities with + { + MaxImageDimension2D = 1536, + MaxImageArrayLayers = 2, + DeviceLocalMemoryBytes = 512UL * 1024 * 1024, + }, + }; + + RenderPackHostCapabilities host = RenderPackCapabilityResolver.Resolve( + device.Capabilities); + + Assert.Equal(1536, host.MaxImageDimension2D); + Assert.Equal(2, host.MaxImageArrayLayers); + Assert.Equal(64L * MiB, host.MaxPackResidentBytes); + Assert.Equal(64L * MiB, host.MaxPackTransientBytes); + Assert.Contains( + RenderCapability.AuthoredCelestialDirectionalLight, + host.Available); + Assert.Contains("one eighth", host.MemoryPolicyDescription, StringComparison.Ordinal); + } + + [Fact] + public void PresetCompatibilityNamesTheExactArrayLimitAndKeepsLowAvailable() + { + RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor; + var host = new RenderPackHostCapabilities( + Enum.GetValues().ToHashSet(), + MaxImageDimension2D: 4096, + MaxImageArrayLayers: 2, + MaxPackResidentBytes: 256L * MiB); + + RenderPackValidationResult low = RenderPackValidator.ValidatePresetCompatibility( + descriptor, + descriptor.QualityPresets.Single(value => value.Semantic == RenderQualitySemantic.Low), + host); + RenderPackValidationResult medium = RenderPackValidator.ValidatePresetCompatibility( + descriptor, + descriptor.QualityPresets.Single(value => value.Semantic == RenderQualitySemantic.Medium), + host); + + Assert.True(low.Success, low.Reason); + Assert.False(medium.Success); + Assert.Equal( + "Preset 'medium' resource 'directional-shadow-depth' needs 3 image-array layers; " + + "this device provides 2.", + medium.Reason); + } + + [Fact] + public void RuntimeBudgetRejectsResolvedRelativeExtentBeforeAllocation() + { + RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor; + RenderQualityPreset low = descriptor.QualityPresets.Single(value => + value.Semantic == RenderQualitySemantic.Low); + var host = new RenderPackHostCapabilities( + Enum.GetValues().ToHashSet(), + MaxImageDimension2D: 1024, + MaxImageArrayLayers: 2, + MaxPackResidentBytes: 256L * MiB); + + NotSupportedException error = Assert.Throws(() => + RenderPackResourceBudgetPlanner.RequireWithinHost( + descriptor, + low, + 1920, + 1080, + sampleCount: 1, + host)); + + Assert.Contains("1920x1080", error.Message, StringComparison.Ordinal); + Assert.Contains("maximum 2-D image edge is 1024", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void CatalogKeepsPackVisibleAndPublishesPerPresetUnavailableReasons() + { + RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor; + using var registry = new BufferedRenderPackRegistry(); + using IDisposable registration = registry.Register(descriptor, new EmptyAssets()); + var host = new RenderPackHostCapabilities( + Enum.GetValues().ToHashSet(), + MaxImageDimension2D: 4096, + MaxImageArrayLayers: 2, + MaxPackResidentBytes: 64L * MiB, + MemoryPolicyDescription: "test 512-MiB adapter policy"); + + RenderPackCatalog catalog = RenderPackCatalog.Build(registry.Snapshot(), host); + Assert.True(catalog.TryGet(descriptor.Id, out RenderPackCatalogEntry entry)); + Assert.True(entry.IsCompatible, entry.IncompatibilityReason); + Assert.Null(entry.PresetIncompatibilityReasons["low"]); + Assert.Contains( + "declares a 134217728-byte resident GPU ceiling", + entry.PresetIncompatibilityReasons["medium"], + StringComparison.Ordinal); + Assert.Contains( + "declares a 268435456-byte resident GPU ceiling", + entry.PresetIncompatibilityReasons["high"], + StringComparison.Ordinal); + } + + [Fact] + public void MissingGpuTimestampsDisablesOnlyAutoAndLeavesExplicitLowAvailable() + { + RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor; + HashSet available = Enum.GetValues().ToHashSet(); + available.Remove(RenderCapability.GpuTimestampQueries); + var host = new RenderPackHostCapabilities(available, 4096, 4, 256L * MiB); + + RenderPackValidationResult low = RenderPackValidator.ValidatePresetCompatibility( + descriptor, + descriptor.QualityPresets.Single(value => value.Semantic == RenderQualitySemantic.Low), + host); + RenderPackValidationResult auto = RenderPackValidator.ValidatePresetCompatibility( + descriptor, + descriptor.QualityPresets.Single(value => value.Semantic == RenderQualitySemantic.Automatic), + host); + + Assert.True(low.Success, low.Reason); + Assert.False(auto.Success); + Assert.Contains("asynchronous GPU timestamp queries", auto.Reason, StringComparison.Ordinal); + Assert.Contains("explicit Low remains available", auto.Reason, StringComparison.Ordinal); + } + + [Fact] + public void MissingMultiviewMakesHintedLowUnavailableButLeavesOrdinaryMediumAvailable() + { + RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor; + HashSet available = Enum.GetValues().ToHashSet(); + available.Remove(RenderCapability.MultiviewDirectionalShadowCascades); + var host = new RenderPackHostCapabilities(available, 4096, 4, 256L * MiB); + + RenderPackValidationResult low = RenderPackValidator.ValidatePresetCompatibility( + descriptor, + descriptor.QualityPresets.Single(value => value.Semantic == RenderQualitySemantic.Low), + host); + RenderPackValidationResult medium = RenderPackValidator.ValidatePresetCompatibility( + descriptor, + descriptor.QualityPresets.Single(value => value.Semantic == RenderQualitySemantic.Medium), + host); + + Assert.False(low.Success); + Assert.Contains("MultiviewDirectionalShadowCascades", low.Reason, StringComparison.Ordinal); + Assert.True(medium.Success, medium.Reason); + } + + private sealed class EmptyAssets : IRenderPackAssets + { + public Stream OpenRead(string assetKey) => Stream.Null; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Packs/RenderPackControllerTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackControllerTests.cs new file mode 100644 index 00000000..98f0ea56 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackControllerTests.cs @@ -0,0 +1,1171 @@ +using System.Numerics; +using AcDream.App.Plugins; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Vk; +using AcDream.App.Rendering.Packs; +using AcDream.App.Settings; +using AcDream.Plugin.Abstractions.Rendering; +using AcDream.UI.Abstractions.Panels.Settings; +using Silk.NET.Vulkan; + +namespace AcDream.App.Tests.Rendering.Packs; + +public sealed class RenderPackControllerTests +{ + private static RenderPackActivationExtent Extent => new(1280, 720, 4); + + [Fact] + public void Discovery_buffers_descriptor_without_opening_assets() + { + var assets = new StubAssets(); + using var registry = new BufferedRenderPackRegistry(); + + using IDisposable registration = registry.Register(Descriptor(), assets); + IReadOnlyList snapshot = registry.Snapshot(); + + Assert.Single(snapshot); + Assert.Equal("test.pack", snapshot[0].Descriptor.Id); + Assert.Equal(0, assets.OpenCount); + } + + [Fact] + public void Withdrawing_registration_removes_catalog_entry() + { + using var registry = new BufferedRenderPackRegistry(); + IDisposable registration = registry.Register(Descriptor(), new StubAssets()); + Assert.Single(registry.Snapshot()); + + registration.Dispose(); + + Assert.Empty(registry.Snapshot()); + } + + [Fact] + public void Discovery_rejects_a_missing_user_facing_feature_summary() + { + RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor( + Descriptor() with { FeatureSummary = " " }, + RenderPackHostCapabilities.Conformance); + + Assert.False(result.Success); + Assert.Equal("Pack 'test.pack' has no feature summary.", result.Reason); + } + + [Fact] + public void UnifiedShadowSubmissionHintRequiresTheLowDirectionalShadowGraph() + { + RenderQualityPreset low = Preset() with + { + Semantic = RenderQualitySemantic.Low, + ExecutionHints = + RenderQualityExecutionHints.MultiviewDirectionalShadowCascades, + }; + RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor( + Descriptor() with { QualityPresets = [low] }, + RenderPackHostCapabilities.Conformance); + + Assert.False(result.Success); + Assert.Contains( + "without the directional-shadow graph", + result.Reason, + StringComparison.Ordinal); + } + + [Fact] + public void Retail_request_builds_nothing_and_keeps_no_runtime() + { + using var registry = new BufferedRenderPackRegistry(); + var factory = new StubFactory(); + using var controller = Controller(registry, factory); + + controller.Request(RenderPackSelectionSettings.Retail); + RenderPackActivationSnapshot snapshot = controller.ApplyAtFrameBoundary(Extent); + + Assert.Equal(RenderPackActivationState.Retail, snapshot.State); + Assert.Null(controller.ActiveRuntime); + Assert.Equal(0, factory.BuildCount); + } + + [Fact] + public void Selected_noop_pack_validates_assets_then_activates_atomically() + { + var assets = new StubAssets(); + using var registry = new BufferedRenderPackRegistry(); + using IDisposable registration = registry.Register(Descriptor(), assets); + var factory = new StubFactory(); + using var controller = Controller(registry, factory); + controller.Request(Selection()); + + RenderPackActivationSnapshot snapshot = controller.ApplyAtFrameBoundary(Extent); + + Assert.Equal(RenderPackActivationState.Active, snapshot.State); + Assert.NotNull(controller.ActiveRuntime); + Assert.Equal(1, factory.BuildCount); + // The conformance descriptor has no shaders, so selected validation + // still has no asset to open. + Assert.Equal(0, assets.OpenCount); + } + + [Fact] + public void PipelineCreationConsumesTheSingleImmutableValidatedAssetSnapshot() + { + const string vertexKey = "stateful.vert.spv"; + const string fragmentKey = "stateful.frag.spv"; + string shaderDirectory = Path.Combine( + RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", "spv"); + byte[] expectedVertex = File.ReadAllBytes( + Path.Combine(shaderDirectory, "atmospheric_filmic.vert.spv")); + byte[] expectedFragment = File.ReadAllBytes( + Path.Combine(shaderDirectory, "atmospheric_filmic.frag.spv")); + var assets = new StatefulAssets(new Dictionary + { + [vertexKey] = expectedVertex, + [fragmentKey] = expectedFragment, + }); + RenderPackDescriptor descriptor = Descriptor() with + { + Id = "stateful.pack", + RequiredCapabilities = + [ + RenderCapability.MainWorldColorIntermediate, + RenderCapability.FullscreenPasses, + ], + Passes = + [ + new RenderPassDeclaration( + "filmic", + RenderPassHook.ToneMap, + vertexKey, + fragmentKey, + [RenderSemanticInput.WorldColor, RenderSemanticInput.FrameTime], + [], + []), + ], + }; + using var registry = new BufferedRenderPackRegistry(); + using IDisposable registration = registry.Register(descriptor, assets); + var device = new RecordingGpuDevice(); + var factory = new AtmosphericRenderPackRuntimeFactory(device); + using var controller = new RenderPackController( + () => RenderPackCatalog.Build( + registry.Snapshot(), + RenderPackHostCapabilities.Conformance), + factory, + preparationScheduler: InlineRenderPackPreparationScheduler.Instance); + controller.Request(new RenderPackSelectionSettings( + descriptor.Id, + descriptor.PackVersion.ToString(), + "low")); + + RenderPackActivationSnapshot snapshot = controller.ApplyAtFrameBoundary(Extent); + + Assert.Equal(RenderPackActivationState.Active, snapshot.State); + Assert.Equal(1, assets.OpenCount(vertexKey)); + Assert.Equal(1, assets.OpenCount(fragmentKey)); + RecordingGpuPipeline pipeline = Assert.Single(device.CreatedPipelines); + Assert.Equal(expectedVertex, pipeline.Description.Shaders.VertexSpirv.ToArray()); + Assert.Equal(expectedFragment, pipeline.Description.Shaders.FragmentSpirv.ToArray()); + } + + [Fact] + public void Valid_user_setting_overrides_are_forwarded_by_stable_id() + { + RenderPackDescriptor descriptor = Descriptor() with + { + Settings = Settings(), + }; + using var registry = new BufferedRenderPackRegistry(); + using IDisposable registration = registry.Register(descriptor, new StubAssets()); + var factory = new StubFactory(); + using var controller = Controller(registry, factory); + RenderPackSettingOverrides overrides = new Dictionary + { + ["enabled"] = "true", + ["exposure"] = "1.25", + ["samples"] = "4", + ["quality"] = "high", + }.ToRenderPackOverrides(); + + controller.Request(Selection() with { SettingOverrides = overrides }); + RenderPackActivationSnapshot snapshot = controller.ApplyAtFrameBoundary(Extent); + + Assert.Equal(RenderPackActivationState.Active, snapshot.State); + Assert.Equal(overrides, factory.LastUserSettingOverrides); + } + + [Fact] + public void Unknown_user_setting_override_atomically_retires_to_retail() + { + RenderPackDescriptor descriptor = Descriptor() with { Settings = Settings() }; + using var registry = new BufferedRenderPackRegistry(); + using IDisposable registration = registry.Register(descriptor, new StubAssets()); + var factory = new StubFactory(); + using var controller = Controller(registry, factory); + controller.Request(Selection()); + controller.ApplyAtFrameBoundary(Extent); + StubRuntime active = Assert.IsType(controller.ActiveRuntime); + + controller.Request(Selection() with + { + SettingOverrides = new RenderPackSettingOverrides( + new Dictionary { ["removed-setting"] = "1" }), + }); + RenderPackActivationSnapshot snapshot = controller.ApplyAtFrameBoundary(Extent); + + Assert.Equal(RenderPackActivationState.FailedToRetail, snapshot.State); + Assert.Equal( + "Render pack 'test.pack' has a user override for unknown setting 'removed-setting'.", + snapshot.Reason); + Assert.True(active.Disposed); + Assert.Null(controller.ActiveRuntime); + Assert.Equal(1, factory.BuildCount); + } + + [Theory] + [InlineData("enabled", "yes", "Boolean")] + [InlineData("exposure", "1,25", "Float")] + [InlineData("exposure", "1.1", "Float")] + [InlineData("samples", "3", "Integer")] + [InlineData("samples", "12", "Integer")] + [InlineData("quality", "ultra", "Choice")] + public void Invalid_user_setting_value_fails_to_retail_without_building( + string settingId, + string value, + string kind) + { + RenderPackDescriptor descriptor = Descriptor() with { Settings = Settings() }; + using var registry = new BufferedRenderPackRegistry(); + using IDisposable registration = registry.Register(descriptor, new StubAssets()); + var factory = new StubFactory(); + using var controller = Controller(registry, factory); + controller.Request(Selection() with + { + SettingOverrides = new RenderPackSettingOverrides( + new Dictionary { [settingId] = value }), + }); + + RenderPackActivationSnapshot snapshot = controller.ApplyAtFrameBoundary(Extent); + + Assert.Equal(RenderPackActivationState.FailedToRetail, snapshot.State); + Assert.Equal( + $"Render pack 'test.pack' user override '{settingId}' has invalid {kind} value '{value}'.", + snapshot.Reason); + Assert.Null(controller.ActiveRuntime); + Assert.Equal(0, factory.BuildCount); + } + + [Fact] + public void Invalid_spirv_fails_complete_pack_to_retail_and_does_not_retry() + { + RenderPackDescriptor descriptor = Descriptor() with + { + Passes = + [ + new RenderPassDeclaration( + "tone-map", + RenderPassHook.ToneMap, + "shaders/fullscreen.vert.spv", + "shaders/tone-map.frag.spv", + [RenderSemanticInput.WorldColor], + [], + []), + ], + }; + var assets = new StubAssets([1, 2, 3, 4]); + using var registry = new BufferedRenderPackRegistry(); + using IDisposable registration = registry.Register(descriptor, assets); + var factory = new StubFactory(); + using var controller = Controller(registry, factory); + + controller.Request(Selection()); + RenderPackActivationSnapshot first = controller.ApplyAtFrameBoundary(Extent); + controller.Request(Selection()); + RenderPackActivationSnapshot second = controller.ApplyAtFrameBoundary(Extent); + + Assert.Equal(RenderPackActivationState.FailedToRetail, first.State); + Assert.Contains("not valid SPIR-V", first.Reason, StringComparison.Ordinal); + Assert.Equal(RenderPackActivationState.FailedToRetail, second.State); + Assert.Contains("will not be retried", second.Reason, StringComparison.Ordinal); + Assert.Equal(0, factory.BuildCount); + Assert.Equal(1, assets.OpenCount); + } + + [Fact] + public void Arbitrary_plugin_asset_exception_is_contained_as_a_retail_fallback() + { + RenderPackDescriptor descriptor = Descriptor() with + { + Passes = + [ + new RenderPassDeclaration( + "tone-map", + RenderPassHook.ToneMap, + "fullscreen.vert.spv", + "tone-map.frag.spv", + [], + [], + []), + ], + }; + using var registry = new BufferedRenderPackRegistry(); + using IDisposable registration = registry.Register( + descriptor, + new ThrowingAssets(new InvalidOperationException("plugin stream failed"))); + var factory = new StubFactory(); + using var controller = Controller(registry, factory); + + controller.Request(Selection()); + RenderPackActivationSnapshot snapshot = controller.ApplyAtFrameBoundary(Extent); + + Assert.Equal(RenderPackActivationState.FailedToRetail, snapshot.State); + Assert.Contains("plugin stream failed", snapshot.Reason, StringComparison.Ordinal); + Assert.Null(controller.ActiveRuntime); + Assert.Equal(0, factory.BuildCount); + } + + [Fact] + public void Candidate_build_failure_disposes_old_runtime_and_returns_to_retail() + { + using var registry = new BufferedRenderPackRegistry(); + using IDisposable registration = registry.Register(Descriptor(), new StubAssets()); + var factory = new StubFactory(); + using var controller = Controller(registry, factory); + controller.Request(Selection()); + controller.ApplyAtFrameBoundary(Extent); + StubRuntime first = Assert.IsType(controller.ActiveRuntime); + factory.Failure = new InvalidOperationException("pipeline rejected"); + + controller.Request(Selection() with { PresetId = "medium" }); + RenderPackActivationSnapshot failed = controller.ApplyAtFrameBoundary(Extent); + + Assert.Equal(RenderPackActivationState.FailedToRetail, failed.State); + Assert.Contains("pipeline rejected", failed.Reason, StringComparison.Ordinal); + Assert.True(first.Disposed); + Assert.Null(controller.ActiveRuntime); + } + + [Theory] + [InlineData(Result.ErrorDeviceLost)] + [InlineData(Result.ErrorOutOfHostMemory)] + [InlineData(Result.ErrorOutOfDeviceMemory)] + public void FatalVulkanCandidatePreparationDisposesCandidateAndRethrows(Result result) + { + RenderPackDescriptor descriptor = Descriptor(); + using var registry = new BufferedRenderPackRegistry(); + using IDisposable registration = registry.Register(descriptor, new StubAssets()); + var failure = new VulkanCallException("candidate target", result); + var runtime = new ThrowingGraphRuntime(descriptor, Preset(), failure); + var factory = new StubFactory + { + RuntimeFactory = (_, _) => runtime, + }; + using var controller = Controller(registry, factory); + controller.Request(Selection()); + + VulkanCallException thrown = Assert.Throws( + () => controller.ApplyAtFrameBoundary(Extent)); + + Assert.Same(failure, thrown); + Assert.True(runtime.Disposed); + Assert.Null(controller.ActiveRuntime); + } + + [Fact] + public void NonTerminalVulkanCandidateFailureDisposesCandidateAndFallsBack() + { + RenderPackDescriptor descriptor = Descriptor(); + using var registry = new BufferedRenderPackRegistry(); + using IDisposable registration = registry.Register(descriptor, new StubAssets()); + var runtime = new ThrowingGraphRuntime( + descriptor, + Preset(), + new VulkanCallException("candidate target", Result.ErrorFormatNotSupported)); + var factory = new StubFactory + { + RuntimeFactory = (_, _) => runtime, + }; + using var controller = Controller(registry, factory); + controller.Request(Selection()); + + RenderPackActivationSnapshot snapshot = controller.ApplyAtFrameBoundary(Extent); + + Assert.Equal(RenderPackActivationState.FailedToRetail, snapshot.State); + Assert.Contains("ErrorFormatNotSupported", snapshot.Reason, StringComparison.Ordinal); + Assert.True(runtime.Disposed); + Assert.Null(controller.ActiveRuntime); + } + + [Fact] + public void Active_pack_remains_renderable_until_completed_candidate_publishes_at_boundary() + { + using var registry = new BufferedRenderPackRegistry(); + using IDisposable registration = registry.Register(Descriptor(), new StubAssets()); + var factory = new StubFactory(); + var scheduler = new ControlledPreparationScheduler(); + using var controller = new RenderPackController( + () => RenderPackCatalog.Build( + registry.Snapshot(), + RenderPackHostCapabilities.Conformance), + factory, + preparationScheduler: scheduler); + + controller.Request(Selection()); + RenderPackActivationSnapshot initialPending = + controller.ApplyAtFrameBoundary(Extent); + Assert.Equal(RenderPackActivationState.CandidatePending, initialPending.State); + Assert.Null(controller.ActiveRuntime); + scheduler.CompleteNext(); + Assert.Null(controller.ActiveRuntime); + controller.ApplyAtFrameBoundary(Extent); + StubRuntime first = Assert.IsType(controller.ActiveRuntime); + + controller.Request(Selection() with { PresetId = "medium" }); + RenderPackActivationSnapshot replacementPending = + controller.ApplyAtFrameBoundary(Extent); + + Assert.Equal(RenderPackActivationState.CandidatePending, replacementPending.State); + Assert.Same(first, controller.ActiveRuntime); + Assert.False(first.Disposed); + scheduler.CompleteNext(); + // Worker completion owns no publication authority. + Assert.Same(first, controller.ActiveRuntime); + Assert.False(first.Disposed); + + RenderPackActivationSnapshot published = controller.ApplyAtFrameBoundary(Extent); + + Assert.Equal(RenderPackActivationState.Active, published.State); + StubRuntime second = Assert.IsType(controller.ActiveRuntime); + Assert.NotSame(first, second); + Assert.Equal("medium", second.Preset.Id); + Assert.True(first.Disposed); + } + + [Fact] + public void Preparation_failure_is_diagnostic_and_only_falls_back_at_a_boundary() + { + using var registry = new BufferedRenderPackRegistry(); + using IDisposable registration = registry.Register(Descriptor(), new StubAssets()); + var factory = new StubFactory(); + var scheduler = new ControlledPreparationScheduler(); + using var controller = new RenderPackController( + () => RenderPackCatalog.Build( + registry.Snapshot(), + RenderPackHostCapabilities.Conformance), + factory, + preparationScheduler: scheduler); + + controller.Request(Selection()); + controller.ApplyAtFrameBoundary(Extent); + scheduler.CompleteNext(); + controller.ApplyAtFrameBoundary(Extent); + StubRuntime first = Assert.IsType(controller.ActiveRuntime); + + factory.Failure = new InvalidOperationException("background pipeline rejected"); + controller.Request(Selection() with { PresetId = "medium" }); + controller.ApplyAtFrameBoundary(Extent); + scheduler.CompleteNext(); + + Assert.Same(first, controller.ActiveRuntime); + Assert.False(first.Disposed); + Assert.Equal(RenderPackActivationState.CandidatePending, controller.Snapshot.State); + + RenderPackActivationSnapshot failed = controller.ApplyAtFrameBoundary(Extent); + + Assert.Equal(RenderPackActivationState.FailedToRetail, failed.State); + Assert.Contains("could not be prepared", failed.Reason, StringComparison.Ordinal); + Assert.Contains("background pipeline rejected", failed.Reason, StringComparison.Ordinal); + Assert.True(first.Disposed); + Assert.Null(controller.ActiveRuntime); + } + + [Fact] + public void Descriptor_validation_reports_exact_missing_capability() + { + RenderPackDescriptor descriptor = Descriptor() with + { + RequiredCapabilities = [RenderCapability.DirectionalShadowMaps], + }; + var capabilities = new RenderPackHostCapabilities( + new HashSet(), + 4096, + 4, + 64L * 1024 * 1024); + + RenderPackValidationResult result = + RenderPackValidator.ValidateDescriptor(descriptor, capabilities); + + Assert.False(result.Success); + Assert.Equal( + "Pack 'test.pack' requires unsupported capability 'DirectionalShadowMaps'.", + result.Reason); + } + + [Fact] + public void Malformed_null_preset_list_remains_a_precisely_incompatible_catalog_entry() + { + RenderPackDescriptor malformed = Descriptor() with + { + QualityPresets = null!, + }; + using var registry = new BufferedRenderPackRegistry(); + using IDisposable registration = registry.Register(malformed, new StubAssets()); + + RenderPackCatalog catalog = RenderPackCatalog.Build( + registry.Snapshot(), + RenderPackHostCapabilities.Conformance); + + RenderPackCatalogEntry entry = Assert.Single(catalog.Entries); + Assert.False(entry.IsCompatible); + Assert.Equal( + "Pack 'test.pack' has a null quality-preset declaration list.", + entry.IncompatibilityReason); + Assert.Empty(entry.PresetIncompatibilityReasons); + } + + [Fact] + public void Diagnostics_record_retail_failure_and_active_pack_ownership_without_gpu_waits() + { + using var registry = new BufferedRenderPackRegistry(); + using IDisposable registration = registry.Register(Descriptor(), new StubAssets()); + var factory = new StubFactory(); + using var controller = Controller(registry, factory); + + RenderPackDiagnosticsSnapshot retail = controller.CaptureDiagnostics(); + controller.Request(Selection()); + controller.ApplyAtFrameBoundary(Extent); + RenderPackDiagnosticsSnapshot active = controller.CaptureDiagnostics(); + + Assert.True(retail.IsRetail); + Assert.Equal(RenderPackActivationState.Retail, retail.State); + Assert.Equal("test.pack", active.PackId); + Assert.Equal("low", active.PresetId); + Assert.Equal("low", active.EffectiveQuality); + Assert.Equal(RenderPackActivationState.Active, active.State); + Assert.Contains("pack=test.pack@1.0.0", RenderPackDiagnosticsFormatter.Format(active)); + } + + [Theory] + [InlineData( + AuthoredCelestialShadowSourceKind.Sun, + 5, + 0x01001348u, + 0.25f, + -0.5f, + 0.8291562f, + 0.8291562f, + "shadowSource=Sun/obj5/0x01001348/dir(0.2500,-0.5000,0.8292)/elevSin=0.8292")] + [InlineData( + AuthoredCelestialShadowSourceKind.DominantMoon, + 3, + 0x01001F6Au, + -0.6f, + 0.2f, + 0.7745967f, + 0.7745967f, + "shadowSource=DominantMoon/obj3/0x01001F6A/dir(-0.6000,0.2000,0.7746)/elevSin=0.7746")] + [InlineData( + AuthoredCelestialShadowSourceKind.SecondaryMoon, + 2, + 0x01001F67u, + 0.4f, + 0.8f, + 0.4472136f, + 0.4472136f, + "shadowSource=SecondaryMoon/obj2/0x01001F67/dir(0.4000,0.8000,0.4472)/elevSin=0.4472")] + [InlineData( + AuthoredCelestialShadowSourceKind.None, + -1, + 0u, + 0f, + 0f, + 1f, + 0f, + "shadowSource=None/obj-1/0x00000000/dir(0.0000,0.0000,1.0000)/elevSin=0.0000")] + internal void DiagnosticsPropagateSunMoonAndNoneMetadataToStableFormattedOutput( + AuthoredCelestialShadowSourceKind sourceKind, + int sourceObjectIndex, + uint sourceGfxObjId, + float directionX, + float directionY, + float directionZ, + float elevationSin, + string expectedFormattedSource) + { + var direction = new Vector3(directionX, directionY, directionZ); + RenderPackRuntimeDiagnostics runtimeDiagnostics = + RenderPackRuntimeDiagnostics.Empty("low") with + { + DirectionalShadowSourceKind = sourceKind, + DirectionalShadowSourceObjectIndex = sourceObjectIndex, + DirectionalShadowSourceGfxObjId = sourceGfxObjId, + DirectionalShadowSurfaceToLightDirection = direction, + DirectionalShadowLightElevationSin = elevationSin, + }; + using var registry = new BufferedRenderPackRegistry(); + using IDisposable registration = registry.Register( + Descriptor(), + new StubAssets()); + var factory = new StubFactory + { + RuntimeFactory = (descriptor, preset) => + new DiagnosticStubRuntime( + descriptor, + preset, + runtimeDiagnostics), + }; + using var controller = Controller(registry, factory); + controller.Request(Selection()); + controller.ApplyAtFrameBoundary(Extent); + + RenderPackDiagnosticsSnapshot snapshot = controller.CaptureDiagnostics(); + string formatted = RenderPackDiagnosticsFormatter.Format(snapshot); + + Assert.Equal(sourceKind, snapshot.DirectionalShadowSourceKind); + Assert.Equal( + sourceObjectIndex, + snapshot.DirectionalShadowSourceObjectIndex); + Assert.Equal(sourceGfxObjId, snapshot.DirectionalShadowSourceGfxObjId); + Assert.Equal( + direction, + snapshot.DirectionalShadowSurfaceToLightDirection); + Assert.Equal(elevationSin, snapshot.DirectionalShadowLightElevationSin); + Assert.Contains(expectedFormattedSource, formatted, StringComparison.Ordinal); + } + + [Fact] + public void Deferred_diagnostics_are_retail_before_bind_and_after_owner_release() + { + var deferred = new DeferredRenderPackDiagnosticsSource(); + using var registry = new BufferedRenderPackRegistry(); + using IDisposable registration = registry.Register(Descriptor(), new StubAssets()); + var factory = new StubFactory(); + using var controller = Controller(registry, factory); + + Assert.True(deferred.CaptureDiagnostics().IsRetail); + using (deferred.BindOwned(controller)) + { + controller.Request(Selection()); + controller.ApplyAtFrameBoundary(Extent); + Assert.Equal("test.pack", deferred.CaptureDiagnostics().PackId); + } + + Assert.True(deferred.CaptureDiagnostics().IsRetail); + } + + [Fact] + public void Selection_binding_activates_only_at_boundary_and_persists_failed_fallback() + { + var storage = new SelectionStorage(); + var settings = new RuntimeSettingsController(storage, log: static _ => { }); + using var registry = new BufferedRenderPackRegistry(); + using IDisposable registration = registry.Register(Descriptor(), new StubAssets()); + var factory = new StubFactory(); + using var controller = Controller(registry, factory); + using var binding = new RenderPackSelectionBinding(settings, controller); + + binding.ApplyAtFrameBoundary(Extent); + settings.SaveDisplay(settings.Display with { RenderPack = Selection() }); + Assert.Null(controller.ActiveRuntime); + + binding.ApplyAtFrameBoundary(Extent); + Assert.NotNull(controller.ActiveRuntime); + + factory.Failure = new InvalidOperationException("pipeline rejected"); + settings.SaveDisplay(settings.Display with + { + RenderPack = Selection() with { PresetId = "medium" }, + }); + RenderPackActivationSnapshot failed = binding.ApplyAtFrameBoundary(Extent); + + Assert.Equal(RenderPackActivationState.FailedToRetail, failed.State); + Assert.True(settings.Display.RenderPack.IsRetail); + Assert.Contains("pipeline rejected", controller.Snapshot.Reason, StringComparison.Ordinal); + Assert.Null(controller.ActiveRuntime); + } + + [Fact] + public void Built_in_atmospheric_pack_is_a_public_contract_conformant_tier2plus_pack() + { + RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor( + BuiltInAtmosphericRenderPack.Descriptor, + RenderPackHostCapabilities.Conformance); + + Assert.True(result.Success, result.Reason); + Assert.Equal(RenderPackTier.Tier2Plus, BuiltInAtmosphericRenderPack.Descriptor.HighestTier); + Assert.Contains( + BuiltInAtmosphericRenderPack.Descriptor.SceneReplays, + replay => replay.CasterClasses.HasFlag(RenderCasterClass.Terrain) + && replay.CasterClasses.HasFlag(RenderCasterClass.OpaqueWorld) + && replay.CasterClasses.HasFlag(RenderCasterClass.AlphaCutoutWorld) + && replay.CasterClasses.HasFlag(RenderCasterClass.AnimatedOpaque) + && replay.CasterClasses.HasFlag(RenderCasterClass.AnimatedAlphaCutout)); + Assert.Contains(BuiltInAtmosphericRenderPack.Descriptor.QualityPresets, value => value.Id == "low"); + Assert.Contains(BuiltInAtmosphericRenderPack.Descriptor.QualityPresets, value => value.Id == "medium"); + Assert.Contains(BuiltInAtmosphericRenderPack.Descriptor.QualityPresets, value => value.Id == "high"); + Assert.Contains(BuiltInAtmosphericRenderPack.Descriptor.QualityPresets, value => value.Id == "auto"); + Assert.Equal( + "0.8", + BuiltInAtmosphericRenderPack + .Descriptor + .Settings + .Single(value => value.Semantic == RenderSettingSemantic.Exposure) + .DefaultValue); + Assert.Equal(0.25, ResourceScale("low", "volumetric")); + Assert.Equal(0.25, ResourceScale("medium", "volumetric")); + Assert.Equal(0.5, ResourceScale("high", "volumetric")); + Assert.Equal(0.25, ResourceScale("low", "sun-rays")); + Assert.Equal(0.5, ResourceScale("medium", "sun-rays")); + Assert.Equal(0.5, ResourceScale("high", "sun-rays")); + Assert.Equal(0.25, ResourceScale("low", "sun-mask")); + Assert.Equal(0.5, ResourceScale("medium", "sun-mask")); + Assert.Equal(0.5, ResourceScale("high", "sun-mask")); + + double ResourceScale(string presetId, string resourceId) => + BuiltInAtmosphericRenderPack + .Descriptor + .QualityPresets + .Single(value => value.Id == presetId) + .ResourceOverrides + .Single(value => value.ResourceId == resourceId) + .Extent! + .Width; + } + + [Fact] + public void Atmospheric_executor_rejects_a_resource_semantic_with_the_wrong_shape() + { + RenderPackDescriptor source = BuiltInAtmosphericRenderPack.Descriptor; + RenderPackDescriptor descriptor = source with + { + Resources = source.Resources.Select(value => + value.Semantic == RenderResourceSemantic.DirectionalShadowDepth + ? value with { Kind = RenderResourceKind.Image2D } + : value).ToArray(), + }; + + RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor( + descriptor, + RenderPackHostCapabilities.Conformance); + + Assert.False(result.Success); + Assert.Contains( + "does not match the fixed atmospheric executor's kind, format, extent, usage, and lifetime contract", + result.Reason, + StringComparison.Ordinal); + } + + [Fact] + public void Atmospheric_executor_rejects_a_variant_semantic_with_the_wrong_shape() + { + RenderPackDescriptor source = BuiltInAtmosphericRenderPack.Descriptor; + RenderPackDescriptor descriptor = source with + { + PipelineVariants = source.PipelineVariants.Select(value => + value.Semantic == RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster + ? value with { CompatibleMaterials = RenderMaterialClass.Opaque } + : value).ToArray(), + }; + + RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor( + descriptor, + RenderPackHostCapabilities.Conformance); + + Assert.False(result.Success); + Assert.Contains( + "does not match the fixed atmospheric executor's base, material, and input contract", + result.Reason, + StringComparison.Ordinal); + } + + [Fact] + public void Atmospheric_executor_rejects_a_replay_that_drops_a_headline_caster_class() + { + RenderPackDescriptor source = BuiltInAtmosphericRenderPack.Descriptor; + RenderPackDescriptor descriptor = source with + { + SceneReplays = source.SceneReplays.Select(value => value with + { + CasterClasses = value.CasterClasses & ~RenderCasterClass.AnimatedAlphaCutout, + }).ToArray(), + }; + + RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor( + descriptor, + RenderPackHostCapabilities.Conformance); + + Assert.False(result.Success); + Assert.Contains("all five headline caster classes", result.Reason, StringComparison.Ordinal); + } + + [Fact] + public void Api_v1_rejects_publicly_reserved_buffer_and_storage_declarations() + { + RenderPackDescriptor descriptor = Descriptor() with + { + Resources = + [ + new RenderResourceDeclaration( + "future-buffer", + RenderResourceKind.Buffer, + RenderFormatClass.StructuredData, + Extent: null, + SizeBytes: 64, + RenderResourceUsage.Storage, + RenderResourceLifetime.ActivePack, + EstimatedResidentBytes: 64), + ], + }; + + RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor( + descriptor, + RenderPackHostCapabilities.Conformance); + + Assert.False(result.Success); + Assert.Contains("reserved for a future render-pack API", result.Reason, StringComparison.Ordinal); + } + + [Fact] + public void Api_v1_rejects_a_pass_that_exceeds_four_ordered_texture_slots() + { + RenderResourceDeclaration Resource(string id) => new( + id, + RenderResourceKind.Image2D, + RenderFormatClass.HdrColor, + new RenderExtentDeclaration(RenderExtentMode.RelativeToMainWorld, 0.5, 0.5), + SizeBytes: 0, + RenderResourceUsage.Sampled | RenderResourceUsage.ColorAttachment, + RenderResourceLifetime.ActivePack, + EstimatedResidentBytes: 1024); + RenderPassDeclaration Writer(string id) => new( + $"write-{id}", + RenderPassHook.AtmosphereBeforeToneMap, + "fullscreen.vert.spv", + "write.frag.spv", + [], + [], + [id]); + string[] resourceIds = ["a", "b", "c", "d"]; + RenderPackDescriptor descriptor = Descriptor() with + { + Resources = resourceIds.Select(Resource).ToArray(), + Passes = + [ + .. resourceIds.Select(Writer), + new RenderPassDeclaration( + "too-many-inputs", + RenderPassHook.ToneMap, + "fullscreen.vert.spv", + "tone.frag.spv", + [RenderSemanticInput.WorldColor], + resourceIds, + []), + ], + }; + + RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor( + descriptor, + RenderPackHostCapabilities.Conformance); + + Assert.False(result.Success); + Assert.Contains("provides four ordered texture slots", result.Reason, StringComparison.Ordinal); + } + + [Fact] + public void Built_in_filmic_pass_maps_exactly_to_texture_slots_a_through_d() + { + RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor; + RenderPassDeclaration pass = descriptor.Passes.Single(value => + value.Id == "filmic-composite"); + IReadOnlyList slots = RenderPackTextureBindingResolver.Resolve( + pass, + descriptor.Resources.ToDictionary(static value => value.Id)); + + Assert.Equal(4, slots.Count); + Assert.Equal(RenderSemanticInput.WorldColor, slots[0].Semantic); + Assert.Equal("bloom-a", slots[1].ResourceId); + Assert.Equal("sun-rays", slots[2].ResourceId); + Assert.Equal("volumetric", slots[3].ResourceId); + } + + [Theory] + [InlineData("../escape.spv")] + [InlineData("/absolute.spv")] + [InlineData("shaders\\escape.spv")] + public void Selected_asset_validation_rejects_unsafe_keys(string key) + { + RenderPackDescriptor descriptor = Descriptor() with + { + Passes = + [ + new RenderPassDeclaration( + "tone-map", + RenderPassHook.ToneMap, + key, + "safe.frag.spv", + [], + [], + []), + ], + }; + var assets = new StubAssets([3, 2, 35, 7]); + + RenderPackValidationResult result = + RenderPackValidator.ValidateSelectedAssets(descriptor, assets); + + Assert.False(result.Success); + Assert.Contains("unsafe asset key", result.Reason, StringComparison.Ordinal); + Assert.Equal(0, assets.OpenCount); + } + + private static RenderPackController Controller( + BufferedRenderPackRegistry registry, + StubFactory factory) => new( + () => RenderPackCatalog.Build( + registry.Snapshot(), + RenderPackHostCapabilities.Conformance), + factory, + preparationScheduler: InlineRenderPackPreparationScheduler.Instance); + + private static RenderPackSelectionSettings Selection() => new( + "test.pack", + "1.0.0", + "low"); + + private static RenderPackDescriptor Descriptor() => new( + "test.pack", + "Test Pack", + new Version(1, 0, 0), + RenderPackApi.Current, + RenderPackTier.Tier1, + [], + [], + [], + [], + [], + [], + [Preset(), Preset() with { Id = "medium", DisplayName = "Medium" }], + [], + null) + { + FeatureSummary = "Test render pack.", + }; + + private static RenderQualityPreset Preset() => new( + "low", + "Low", + [], + [], + [], + 64L * 1024 * 1024, + 2.0, + 3.0, + 0.15, + 0.50); + + private static string RepositoryRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + throw new InvalidOperationException("Could not locate the repository root."); + } + + private static RenderSettingDeclaration[] Settings() => + [ + new("enabled", "Enabled", RenderSettingKind.Boolean, "false", null, null, null, []), + new("exposure", "Exposure", RenderSettingKind.Float, "1", 0, 2, 0.25, []), + new("samples", "Samples", RenderSettingKind.Integer, "2", 0, 10, 2, []), + new("quality", "Quality", RenderSettingKind.Choice, "low", null, null, null, + ["low", "high"]), + ]; + + private sealed class StubAssets(byte[]? bytes = null) : IRenderPackAssets + { + private readonly byte[] _bytes = bytes ?? []; + + internal int OpenCount { get; private set; } + + public Stream OpenRead(string assetKey) + { + OpenCount++; + return new MemoryStream(_bytes, writable: false); + } + } + + private sealed class ThrowingAssets(Exception error) : IRenderPackAssets + { + public Stream OpenRead(string assetKey) => throw error; + } + + private sealed class StatefulAssets(IReadOnlyDictionary firstValues) + : IRenderPackAssets + { + private readonly Dictionary _openCounts = + new(StringComparer.Ordinal); + + internal int OpenCount(string key) => _openCounts.GetValueOrDefault(key); + + public Stream OpenRead(string assetKey) + { + int count = _openCounts.GetValueOrDefault(assetKey) + 1; + _openCounts[assetKey] = count; + if (count != 1) + { + // A second read is intentionally different, exposing any + // validate-then-reopen TOCTOU path deterministically. + return new MemoryStream([3, 2, 35, 7], writable: false); + } + return new MemoryStream(firstValues[assetKey], writable: false); + } + } + + private sealed class SelectionStorage : IRuntimeSettingsStorage + { + public SettingsStore? LayoutStore => null; + + public string Location => "memory://render-pack"; + + public DisplaySettings Display { get; private set; } = DisplaySettings.Default; + + public DisplaySettings LoadDisplay() => Display; + + public AudioSettings LoadAudio() => AudioSettings.Default; + + public ChatSettings LoadChat() => ChatSettings.Default; + + public CharacterSettings LoadCharacter(string toonKey) => CharacterSettings.Default; + + public CameraTurningSettings LoadCameraTurning() => CameraTurningSettings.Default; + + public void SaveDisplay(DisplaySettings display) => Display = display; + + public void SaveAudio(AudioSettings audio) + { + } + + public void SaveChat(ChatSettings chat) + { + } + + public void SaveCameraTurning(CameraTurningSettings cameraTurning) + { + } + } + + private sealed class StubFactory : IRenderPackRuntimeFactory + { + internal int BuildCount { get; private set; } + + internal IReadOnlyDictionary? LastUserSettingOverrides { get; private set; } + + internal Exception? Failure { get; set; } + + internal Func? + RuntimeFactory { get; init; } + + public IRenderPackRuntime Build( + RenderPackDescriptor descriptor, + ValidatedRenderPackShaderAssets assets, + RenderQualityPreset preset, + IReadOnlyDictionary userSettingOverrides) + { + BuildCount++; + LastUserSettingOverrides = new Dictionary( + userSettingOverrides, + StringComparer.OrdinalIgnoreCase); + if (Failure is { } failure) + throw failure; + if (RuntimeFactory is { } create) + return create(descriptor, preset); + return new StubRuntime(descriptor, preset); + } + } + + private sealed class ControlledPreparationScheduler : IRenderPackPreparationScheduler + { + private readonly Queue<(Action Work, TaskCompletionSource Completion)> _pending = []; + + public Task Schedule(Action preparation) + { + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + _pending.Enqueue((preparation, completion)); + return completion.Task; + } + + internal void CompleteNext() + { + (Action work, TaskCompletionSource completion) = _pending.Dequeue(); + try + { + work(); + completion.SetResult(); + } + catch (Exception error) + { + completion.SetException(error); + } + } + } + + private sealed class StubRuntime( + RenderPackDescriptor descriptor, + RenderQualityPreset preset) : IRenderPackRuntime + { + public RenderPackDescriptor Descriptor { get; } = descriptor; + + public RenderQualityPreset Preset { get; } = preset; + + internal bool Disposed { get; private set; } + + public void Dispose() => Disposed = true; + } + + private sealed class DiagnosticStubRuntime( + RenderPackDescriptor descriptor, + RenderQualityPreset preset, + RenderPackRuntimeDiagnostics diagnostics) + : IRenderPackRuntime, IRenderPackRuntimeDiagnosticsSource + { + public RenderPackDescriptor Descriptor { get; } = descriptor; + + public RenderQualityPreset Preset { get; } = preset; + + public RenderPackRuntimeDiagnostics CaptureDiagnostics() => diagnostics; + + public void Dispose() + { + } + } + + private sealed class ThrowingGraphRuntime( + RenderPackDescriptor descriptor, + RenderQualityPreset preset, + Exception failure) : IAtmosphericWorldGraphRuntime + { + public RenderPackDescriptor Descriptor { get; } = descriptor; + + public RenderQualityPreset Preset { get; } = preset; + + internal bool Disposed { get; private set; } + + public IGpuRenderTarget PrepareWorldTarget(int width, int height, int sampleCount) => + throw failure; + + public void RenderPostProcess(IGpuFrame frame, in AtmosphericFrameInputs inputs) => + throw new InvalidOperationException("The candidate never activates."); + + public void Dispose() => Disposed = true; + } +} + +file static class RenderPackControllerTestExtensions +{ + internal static RenderPackSettingOverrides ToRenderPackOverrides( + this IReadOnlyDictionary values) => new(values); +} diff --git a/tests/AcDream.App.Tests/Rendering/Packs/RenderPackLongCycleConvergenceTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackLongCycleConvergenceTests.cs new file mode 100644 index 00000000..ea988c2a --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackLongCycleConvergenceTests.cs @@ -0,0 +1,636 @@ +using System.Numerics; +using AcDream.App.Plugins; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Packs; +using AcDream.App.Rendering.Scene; +using AcDream.App.Rendering.Wb; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.Core.World; +using AcDream.Plugin.Abstractions.Rendering; +using AcDream.UI.Abstractions.Panels.Settings; +using DatReaderWriter.Enums; + +namespace AcDream.App.Tests.Rendering.Packs; + +/// +/// Recording-RHI lifetime gate for the complete optional renderer. This is +/// deliberately longer and more compositional than the focused owner tests: +/// one fixture repeatedly crosses pack, preset, size, frame-flight, topology, +/// failure, and terminal renderer-lifetime boundaries without a physical GPU. +/// +public sealed class RenderPackLongCycleConvergenceTests +{ + private const int LongCycleCount = 12; + + [Fact] + public void RepeatedPackResizeFailureGenerationAndFlightCyclesConvergeExactly() + { + using var device = new RecordingGpuDevice(); + PrimeDeviceOwnedSamplerCache(device); + LiveGpuLedger baseline = LiveGpuLedger.Capture(device); + var lifetime = new RecordingRendererLifetime(device); + + try + { + AtmosphericPostProcessGraph low = lifetime.Activate("low", 640, 360); + ExerciseResizeFlightAndGenerationReplacement(device, low); + lifetime.SelectRetail(640, 360); + AssertConverged(device, baseline, lifetime); + + device.PipelineFailure = description => + string.Equals(description.Name, "atmospheric-filmic", StringComparison.Ordinal) + ? new InvalidOperationException("injected candidate pipeline failure") + : null; + RenderPackActivationSnapshot failed = lifetime.Request( + Selection("medium") with + { + SettingOverrides = RenderPackSettingOverrides.Empty.Set("exposure", "1.05"), + }, + 704, + 396); + Assert.Equal(RenderPackActivationState.FailedToRetail, failed.State); + Assert.Contains("injected candidate pipeline failure", failed.Reason, StringComparison.Ordinal); + AssertConverged(device, baseline, lifetime); + + device.PipelineFailure = null; + AtmosphericPostProcessGraph recovered = lifetime.Activate("medium", 704, 396); + RenderOnePostFrame(device, recovered, 704, 396); + lifetime.SelectRetail(704, 396); + AssertConverged(device, baseline, lifetime); + + string[] presets = ["low", "medium", "high"]; + for (int cycle = 0; cycle < LongCycleCount; cycle++) + { + foreach (string preset in presets) + { + int width = 640 + cycle % 3 * 64; + int height = 360 + cycle % 3 * 36; + AtmosphericPostProcessGraph graph = lifetime.Activate( + preset, + width, + height); + + RecordingGpuRenderTarget initial = Assert.IsType( + graph.PrepareWorldTarget(width, height, sampleCount: 1)); + RecordingGpuRenderTarget resized = Assert.IsType( + graph.PrepareWorldTarget(width + 16, height + 9, sampleCount: 1)); + Assert.True(initial.IsDisposed); + RecordingGpuRenderTarget restored = Assert.IsType( + graph.PrepareWorldTarget(width, height, sampleCount: 1)); + Assert.True(resized.IsDisposed); + + RenderOnePostFrame(device, graph, width, height); + lifetime.SelectRetail(width, height); + Assert.True(restored.IsDisposed); + AssertConverged(device, baseline, lifetime); + } + } + + _ = lifetime.Activate("high", 800, 450); + Assert.NotEqual(baseline, LiveGpuLedger.Capture(device)); + } + finally + { + lifetime.Dispose(); + } + + Assert.Equal(0, lifetime.RegisteredPackCount); + AssertConverged(device, baseline, lifetime); + Assert.All( + device.CreatedBuffers.Where(static value => + value.Name.StartsWith("directional-shadow-", StringComparison.Ordinal)), + static value => Assert.True(value.IsDisposed)); + } + + [Fact] + public void DeviceRecreationIsFullRendererTeardownThenANewContextAndDevice() + { + RecordingGpuDevice firstDevice = new(); + PrimeDeviceOwnedSamplerCache(firstDevice); + LiveGpuLedger firstBaseline = LiveGpuLedger.Capture(firstDevice); + var firstLifetime = new RecordingRendererLifetime(firstDevice); + AtmosphericPostProcessGraph firstGraph = firstLifetime.Activate("low", 640, 360); + RenderOnePostFrame(firstDevice, firstGraph, 640, 360); + Assert.Equal(1, firstLifetime.ActivationGeneration); + RecordingGpuPipeline firstPipeline = Assert.Single( + firstDevice.CreatedPipelines, + static value => string.Equals( + value.Description.Name, + "atmospheric-filmic", + StringComparison.Ordinal)); + + firstLifetime.Dispose(); + Assert.Equal(0, firstLifetime.RegisteredPackCount); + AssertConverged(firstDevice, firstBaseline, firstLifetime); + Assert.True(firstPipeline.IsDisposed); + firstDevice.Dispose(); + Assert.Throws(() => firstDevice.BeginFrame()); + + using var secondDevice = new RecordingGpuDevice(); + PrimeDeviceOwnedSamplerCache(secondDevice); + LiveGpuLedger secondBaseline = LiveGpuLedger.Capture(secondDevice); + var secondLifetime = new RecordingRendererLifetime(secondDevice); + try + { + AtmosphericPostProcessGraph secondGraph = secondLifetime.Activate( + "low", + 640, + 360); + RenderOnePostFrame(secondDevice, secondGraph, 640, 360); + Assert.Equal(1, secondLifetime.ActivationGeneration); + RecordingGpuPipeline secondPipeline = Assert.Single( + secondDevice.CreatedPipelines, + static value => string.Equals( + value.Description.Name, + "atmospheric-filmic", + StringComparison.Ordinal)); + Assert.NotSame(firstPipeline, secondPipeline); + Assert.Equal(1, secondBaseline.TextureSlots); + Assert.True(secondDevice.LiveTextureSlotCount > secondBaseline.TextureSlots); + } + finally + { + secondLifetime.Dispose(); + } + + Assert.Equal(0, secondLifetime.RegisteredPackCount); + AssertConverged(secondDevice, secondBaseline, secondLifetime); + } + + private static void ExerciseResizeFlightAndGenerationReplacement( + RecordingGpuDevice device, + AtmosphericPostProcessGraph graph) + { + var retainedTransforms = new DirectionalShadowTransformBufferSet(device); + DirectionalShadowPreparedDraws world = CreateWorldDraws( + device.DefaultTextureSlot, + RenderSceneGeneration.FromRaw(1), + casterBuildSequence: 1); + DirectionalShadowTerrainPreparedDraws terrain = CreateTerrainDraws(frameSequence: 1); + using IGpuBuffer worldVertices = Buffer(device, "lifetime-world-v", GpuBufferUsage.Vertex); + using IGpuBuffer worldIndices = Buffer(device, "lifetime-world-i", GpuBufferUsage.Index); + using IGpuBuffer terrainVertices = Buffer(device, "lifetime-terrain-v", GpuBufferUsage.Vertex); + using IGpuBuffer terrainIndices = Buffer(device, "lifetime-terrain-i", GpuBufferUsage.Index); + var worldGeometry = new DirectionalShadowMeshGeometry(worldVertices, worldIndices); + var terrainGeometry = new DirectionalShadowTerrainGeometry( + terrainVertices, + terrainIndices); + + RenderShadowFrame( + device, + graph, + retainedTransforms, + world, + terrain, + worldGeometry, + terrainGeometry, + 640, + 360); + RenderShadowFrame( + device, + graph, + retainedTransforms, + world, + terrain, + worldGeometry, + terrainGeometry, + 640, + 360); + + RecordingGpuBuffer[] firstTopologyBuffers = device.CreatedBuffers + .Where(static value => value.Name.StartsWith( + "directional-shadow-", + StringComparison.Ordinal)) + .ToArray(); + Assert.Equal(5, firstTopologyBuffers.Length); + Assert.All(firstTopologyBuffers, static value => Assert.False(value.IsDisposed)); + + RebuildWorldDraws( + world, + device.DefaultTextureSlot, + RenderSceneGeneration.FromRaw(2), + casterBuildSequence: 2); + RebuildTerrainDraws(terrain, frameSequence: 2); + RenderShadowFrame( + device, + graph, + retainedTransforms, + world, + terrain, + worldGeometry, + terrainGeometry, + 640, + 360); + RenderShadowFrame( + device, + graph, + retainedTransforms, + world, + terrain, + worldGeometry, + terrainGeometry, + 640, + 360); + + Assert.All(firstTopologyBuffers, static value => Assert.True(value.IsDisposed)); + Assert.Equal( + 5, + device.CreatedBuffers.Count(static value => + value.Name.StartsWith("directional-shadow-", StringComparison.Ordinal) + && !value.IsDisposed)); + retainedTransforms.Dispose(); + Assert.All( + device.CreatedBuffers.Where(static value => value.Name.Contains( + "directional-shadow-transforms-", + StringComparison.Ordinal)), + static value => Assert.True(value.IsDisposed)); + } + + private static void RenderShadowFrame( + RecordingGpuDevice device, + AtmosphericPostProcessGraph graph, + DirectionalShadowTransformBufferSet retainedTransforms, + DirectionalShadowPreparedDraws world, + DirectionalShadowTerrainPreparedDraws terrain, + DirectionalShadowMeshGeometry worldGeometry, + DirectionalShadowTerrainGeometry terrainGeometry, + int width, + int height) + { + using IGpuFrame frame = device.BeginFrame(); + WorldTransformFrameSlice transforms = retainedTransforms.Publish( + frame, + world.BuildSequence, + world.Transforms, + world.DynamicTransformSlots, + world.AllDynamicTransformSlots); + var shadows = Assert.IsType( + graph.DirectionalShadowReceivers); + DirectionalSunShadowDiagnostics diagnostics = shadows.RenderPrepared( + frame, + EnabledEnvironment(), + Matrix4x4.Identity, + Matrix4x4.CreatePerspectiveFieldOfView(1f, 16f / 9f, 0.1f, 500f), + cameraNearMeters: 0.1f, + casterDepthPaddingMeters: 48f, + world, + terrain, + worldGeometry, + terrainGeometry, + transforms); + Assert.Equal(2, diagnostics.CascadeCount); + + IGpuRenderTarget target = graph.PrepareWorldTarget(width, height, sampleCount: 1); + RecordWorldPass(frame, target); + AtmosphericFrameInputs inputs = Inputs(width, height, isOutdoor: true); + graph.RenderPostProcess(frame, in inputs); + } + + private static void RenderOnePostFrame( + RecordingGpuDevice device, + AtmosphericPostProcessGraph graph, + int width, + int height) + { + using IGpuFrame frame = device.BeginFrame(); + IGpuRenderTarget target = graph.PrepareWorldTarget(width, height, sampleCount: 1); + RecordWorldPass(frame, target); + AtmosphericFrameInputs inputs = Inputs(width, height, isOutdoor: false); + graph.RenderPostProcess(frame, in inputs); + } + + private static AtmosphericFrameInputs Inputs( + int width, + int height, + bool isOutdoor) => new( + new Vector2(0.5f, 0.35f), + SunIsOnScreen: true, + SunElevationDegrees: isOutdoor ? 20f : -10f, + new Vector3(1f, 0.85f, 0.65f), + Vector3.Normalize(new Vector3(0.2f, 0.5f, 0.8f)), + SunDirectionalBrightness: 1f, + Matrix4x4.Identity, + ActiveDayGroup: isOutdoor ? 0 : -1, + WeatherKind.Clear, + WeatherIntensity: 0f, + DeltaSeconds: 1d / 60d, + width, + height, + IsOutdoor: isOutdoor); + + private static void RecordWorldPass(IGpuFrame frame, IGpuRenderTarget world) + { + using IGpuPassEncoder _ = frame.BeginPass(new GpuPassDescription + { + Name = "lifetime-world-hdr", + Color = new GpuColorAttachment( + world, + GpuLoadOp.Clear, + GpuStoreOp.Store, + Vector4.Zero), + Depth = new GpuDepthAttachment( + GpuLoadOp.Clear, + GpuStoreOp.Store, + 1f, + 0), + SampleCount = 1, + }); + } + + private static DirectionalShadowPreparedDraws CreateWorldDraws( + GpuTextureSlot cutoutSlot, + RenderSceneGeneration generation, + ulong casterBuildSequence) + { + var draws = new DirectionalShadowPreparedDraws(); + RebuildWorldDraws(draws, cutoutSlot, generation, casterBuildSequence); + return draws; + } + + private static void RebuildWorldDraws( + DirectionalShadowPreparedDraws draws, + GpuTextureSlot cutoutSlot, + RenderSceneGeneration generation, + ulong casterBuildSequence) + { + Assert.True(draws.TryBegin(generation, casterBuildSequence, estimatedInstances: 2)); + Matrix4x4 opaque = Matrix4x4.CreateTranslation(1f, 2f, 3f); + Matrix4x4 cutout = Matrix4x4.CreateRotationZ(0.3f) + * Matrix4x4.CreateTranslation(4f, 5f, 6f); + draws.Add( + 0, + 0, + 6, + GpuTextureSlot.Unassigned, + 0, + CullMode.CounterClockwise, + DirectionalShadowCasterMaterial.Opaque, + in opaque); + draws.Add( + 6, + 4, + 12, + cutoutSlot, + 2, + CullMode.None, + DirectionalShadowCasterMaterial.AlphaCutout, + in cutout); + DirectionalShadowPreparationStats stats = default; + draws.Complete(generation, casterBuildSequence, in stats); + } + + private static DirectionalShadowTerrainPreparedDraws CreateTerrainDraws( + long frameSequence) + { + var draws = new DirectionalShadowTerrainPreparedDraws(); + RebuildTerrainDraws(draws, frameSequence); + return draws; + } + + private static void RebuildTerrainDraws( + DirectionalShadowTerrainPreparedDraws draws, + long frameSequence) + { + Assert.True(draws.TryBegin(frameSequence, estimatedCommands: 1)); + var range = new DirectionalShadowTerrainRange(20, 60); + draws.Add(in range); + draws.Complete(frameSequence); + } + + private static DirectionalShadowEnvironmentState EnabledEnvironment() => new( + DirectionalShadowGateReason.Enabled, + Vector3.Normalize(new Vector3(0.2f, 0.3f, 1f)), + LightElevationSin: 0.94f, + Strength: 0.8f, + SoftnessMultiplier: 1.25f, + SourceKind: AuthoredCelestialShadowSourceKind.Sun); + + private static IGpuBuffer Buffer( + RecordingGpuDevice device, + string name, + GpuBufferUsage usage) => device.CreateBuffer(new GpuBufferDescription( + name, + 4096, + usage | GpuBufferUsage.TransferDestination, + GpuMemoryResidency.DeviceLocal)); + + private static RenderPackSelectionSettings Selection(string preset) => new( + BuiltInAtmosphericRenderPack.Descriptor.Id, + BuiltInAtmosphericRenderPack.Descriptor.PackVersion.ToString(), + preset); + + private static void AssertConverged( + RecordingGpuDevice device, + LiveGpuLedger baseline, + RecordingRendererLifetime lifetime) + { + Assert.Equal(baseline, LiveGpuLedger.Capture(device)); + Assert.Equal(0, device.OpenFrameCount); + Assert.Empty(device.PipelineFormatLeases); + Assert.Equal(0, lifetime.LiveReceiverCandidates); + Assert.Equal(lifetime.IsDisposed ? 0 : 1, lifetime.RegisteredPackCount); + Assert.Null(lifetime.ActiveRuntime); + } + + private static void PrimeDeviceOwnedSamplerCache(RecordingGpuDevice device) + { + // Vulkan samplers are description-keyed device objects and intentionally + // survive individual pack runtimes. UiNearest is created with the device; + // prime WorldClamp so the fixture baseline includes the complete cache. + _ = device.CreateSampler(GpuSamplerDescription.WorldClamp); + } + + private static IRenderPackAssets BuiltInAssets() => + BuiltInAtmosphericRenderPack.CreateAssets(Path.Combine( + RepositoryRoot(), + "src", + "AcDream.App", + "Rendering", + "Shaders", + "spv")); + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null + && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + directory = directory.Parent; + } + return directory?.FullName + ?? throw new InvalidOperationException("Could not locate repository root."); + } + + private readonly record struct LiveGpuLedger( + int Buffers, + int Pipelines, + int Samplers, + int Textures, + int RenderTargets, + int DirectionalDepthTargets, + int TextureSlots, + int PipelineFormatLeases) + { + internal static LiveGpuLedger Capture(RecordingGpuDevice device) => new( + device.CreatedBuffers.Count(static value => !value.IsDisposed), + device.CreatedPipelines.Count(static value => !value.IsDisposed), + device.CreatedSamplers.Count(static value => !value.IsDisposed), + device.CreatedTextures.Count(static value => !value.IsDisposed), + device.CreatedRenderTargets.Count(static value => !value.IsDisposed), + device.CreatedDirectionalDepthTargets.Count(static value => !value.IsDisposed), + device.LiveTextureSlotCount, + device.PipelineFormatLeases.Values.Sum()); + } + + private sealed class RecordingRendererLifetime : IDisposable + { + private readonly BufferedRenderPackRegistry _registry = new(); + private readonly IDisposable _registration; + private readonly RecordingReceiverCoordinator _receivers = new(); + private bool _disposed; + + internal RecordingRendererLifetime(RecordingGpuDevice device) + { + _registration = _registry.Register( + BuiltInAtmosphericRenderPack.Descriptor, + BuiltInAssets()); + Controller = new RenderPackController( + () => RenderPackCatalog.Build( + _registry.Snapshot(), + RenderPackCapabilityResolver.Resolve(device.Capabilities)), + new AtmosphericRenderPackRuntimeFactory(device), + _receivers, + InlineRenderPackPreparationScheduler.Instance); + } + + private RenderPackController Controller { get; } + + internal long ActivationGeneration => Controller.Snapshot.ActivationGeneration; + + internal IRenderPackRuntime? ActiveRuntime => Controller.ActiveRuntime; + + internal int LiveReceiverCandidates => _receivers.LiveCandidateCount; + + internal bool IsDisposed => _disposed; + + internal int RegisteredPackCount => _disposed ? 0 : _registry.Snapshot().Count; + + internal AtmosphericPostProcessGraph Activate( + string preset, + int width, + int height) + { + RenderPackActivationSnapshot snapshot = Request( + Selection(preset), + width, + height); + Assert.Equal(RenderPackActivationState.Active, snapshot.State); + Assert.Null(snapshot.Reason); + Assert.Equal(1, LiveReceiverCandidates); + return Assert.IsType(Controller.ActiveRuntime); + } + + internal RenderPackActivationSnapshot Request( + RenderPackSelectionSettings selection, + int width, + int height) + { + Controller.Request(selection); + return Controller.ApplyAtFrameBoundary( + new RenderPackActivationExtent(width, height, 1)); + } + + internal void SelectRetail(int width, int height) + { + RenderPackActivationSnapshot snapshot = Request( + RenderPackSelectionSettings.Retail, + width, + height); + Assert.Equal(RenderPackActivationState.Retail, snapshot.State); + Assert.True(snapshot.Selection.IsRetail); + } + + public void Dispose() + { + if (_disposed) + return; + Controller.Dispose(); + _registration.Dispose(); + Assert.Empty(_registry.Snapshot()); + _registry.Dispose(); + _disposed = true; + } + } + + private sealed class RecordingReceiverCoordinator : + IRenderPackReceiverPipelineCoordinator + { + private Candidate? _active; + + internal int LiveCandidateCount { get; private set; } + + public IRenderPackReceiverPipelineCandidate Prepare( + IDirectionalShadowReceiverSource? source, + int sampleCount) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleCount); + var candidate = new Candidate(this, source is not null); + LiveCandidateCount++; + return candidate; + } + + public void Publish(IRenderPackReceiverPipelineCandidate candidate) + { + if (candidate is not Candidate prepared + || !ReferenceEquals(prepared.Owner, this)) + { + throw new ArgumentException( + "Receiver candidate belongs to another coordinator.", + nameof(candidate)); + } + prepared.Publish(); + _active?.Dispose(); + _active = prepared; + } + + public void Clear() + { + _active?.Dispose(); + _active = null; + } + + private void Released() => LiveCandidateCount--; + + private sealed class Candidate( + RecordingReceiverCoordinator owner, + bool hasDirectionalSource) : IRenderPackReceiverPipelineCandidate + { + private bool _disposed; + private bool _published; + + internal RecordingReceiverCoordinator Owner { get; } = owner; + + internal void Publish() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!hasDirectionalSource) + { + throw new InvalidOperationException( + "The atmospheric candidate lost its directional receiver source."); + } + if (_published) + throw new InvalidOperationException("Receiver candidate was published twice."); + _published = true; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + Owner.Released(); + } + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Packs/RenderPackPerformanceWindowTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackPerformanceWindowTests.cs new file mode 100644 index 00000000..0ebb5fde --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackPerformanceWindowTests.cs @@ -0,0 +1,78 @@ +using AcDream.App.Rendering.Packs; + +namespace AcDream.App.Tests.Rendering.Packs; + +public sealed class RenderPackPerformanceWindowTests +{ + [Fact] + public void Snapshot_reports_independent_cpu_and_delayed_gpu_percentiles() + { + var window = new RenderPackPerformanceWindow(capacity: 8); + window.Observe(1, 11, false, 0, 10, 2); + window.Observe(2, 12, true, 4, 11, 3); + window.Observe(3, 13, true, 6, 12, 4); + window.Observe(4, 14, true, 8, 13, 5); + + RenderPackPerformanceSnapshot value = window.Snapshot(); + + Assert.Equal(4, value.CpuSampleCount); + Assert.Equal(4, value.AbsoluteReceiverCpuSampleCount); + Assert.Equal(3, value.GpuSampleCount); + Assert.Equal(2, value.IncrementalCpuMillisecondsP50); + Assert.Equal(4, value.IncrementalCpuMillisecondsP95); + Assert.Equal(4, value.IncrementalCpuMillisecondsP99); + Assert.Equal(12, value.AbsoluteReceiverCpuMillisecondsP50); + Assert.Equal(14, value.AbsoluteReceiverCpuMillisecondsP95); + Assert.Equal(14, value.AbsoluteReceiverCpuMillisecondsP99); + Assert.Equal(6, value.InclusiveGpuMillisecondsP50); + Assert.Equal(8, value.InclusiveGpuMillisecondsP95); + Assert.Equal(8, value.InclusiveGpuMillisecondsP99); + Assert.Equal(13, value.ResidentGpuBytes); + Assert.Equal(5, value.TransientGpuBytes); + Assert.False(value.HasStableAutoWindow(4)); + Assert.True(value.HasStableAutoWindow(3)); + } + + [Fact] + public void Capacity_is_a_rolling_window_and_reset_removes_mixed_quality_data() + { + var window = new RenderPackPerformanceWindow(capacity: 3); + for (int i = 1; i <= 4; i++) + window.Observe(i, i * 10, true, i * 2, i, i); + + RenderPackPerformanceSnapshot rolled = window.Snapshot(); + Assert.Equal(3, rolled.CpuSampleCount); + Assert.Equal(3, rolled.GpuSampleCount); + Assert.Equal(3, rolled.IncrementalCpuMillisecondsP50); + Assert.Equal(4, rolled.IncrementalCpuMillisecondsP99); + Assert.Equal(30, rolled.AbsoluteReceiverCpuMillisecondsP50); + Assert.Equal(40, rolled.AbsoluteReceiverCpuMillisecondsP99); + Assert.Equal(6, rolled.InclusiveGpuMillisecondsP50); + Assert.Equal(8, rolled.InclusiveGpuMillisecondsP99); + + window.Reset(); + Assert.Equal(default, window.Snapshot()); + } + + [Theory] + [InlineData(-1, 0, false, 0, 0, 0)] + [InlineData(double.NaN, 0, false, 0, 0, 0)] + [InlineData(0, -1, false, 0, 0, 0)] + [InlineData(0, double.NaN, false, 0, 0, 0)] + [InlineData(0, 0, true, -1, 0, 0)] + [InlineData(0, 0, true, double.PositiveInfinity, 0, 0)] + [InlineData(0, 0, false, 0, -1, 0)] + [InlineData(0, 0, false, 0, 0, -1)] + public void Invalid_measurements_are_rejected( + double cpu, + double receiverCpu, + bool hasGpu, + double gpu, + long resident, + long transient) + { + var window = new RenderPackPerformanceWindow(); + Assert.Throws(() => + window.Observe(cpu, receiverCpu, hasGpu, gpu, resident, transient)); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Packs/RenderPackResourceBudgetPlannerTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackResourceBudgetPlannerTests.cs new file mode 100644 index 00000000..3fe0bfbd --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackResourceBudgetPlannerTests.cs @@ -0,0 +1,99 @@ +using AcDream.App.Rendering.Packs; +using AcDream.App.Rendering.Wb; + +namespace AcDream.App.Tests.Rendering.Packs; + +public sealed class RenderPackResourceBudgetPlannerTests +{ + private const long MiB = 1024L * 1024L; + + [Fact] + public void Low_1080p_resolves_actual_images_below_its_64_mib_ceiling() + { + var descriptor = BuiltInAtmosphericRenderPack.Descriptor; + var preset = descriptor.QualityPresets.Single(value => value.Id == "low"); + + RenderPackResourceBudget budget = RenderPackResourceBudgetPlanner + .RequireWithinPreset(descriptor, preset, 1920, 1080, sampleCount: 1); + + Assert.InRange(budget.RetainedGpuBytes, 40L * MiB, 42L * MiB); + Assert.Equal(0, budget.MultisampleGpuBytes); + Assert.Equal(2, budget.LargestImageLayerCount); + Assert.Equal(1920, budget.LargestImageWidth); + } + + [Fact] + public void Low_1440pFundsQuarterResolutionBloomAndPreAdmitsBothTransformFlights() + { + var descriptor = BuiltInAtmosphericRenderPack.Descriptor; + var preset = descriptor.QualityPresets.Single(value => value.Id == "low"); + + RenderPackResourceBudget budget = RenderPackResourceBudgetPlanner + .RequireWithinPreset(descriptor, preset, 2560, 1440, sampleCount: 1); + + long expected = checked( + 2560L * 1440L * 12L + + 768L * 768L * 2L * sizeof(float) + // Bloom ping/pong, sun mask/rays, and the declared optional + // volumetric target all remain inside the conservative admission + // ledger even though Low keeps volumetrics disabled at runtime. + + 640L * 360L * (8L + 8L + 4L + 8L + 8L) + + 2L * WorldTransformCapacityPolicy.InitialBindingSizeBytes); + Assert.Equal(expected, budget.RetainedGpuBytes); + Assert.InRange(budget.RetainedGpuBytes, 62L * MiB, 64L * MiB); + Assert.True(budget.RetainedGpuBytes <= preset.MaxResidentGpuBytes); + Assert.Equal(2560, budget.LargestImageWidth); + Assert.Equal(1440, budget.LargestImageHeight); + } + + [Fact] + public void Medium_1080p_tracks_multisample_bytes_separately_from_resident_ceiling() + { + var descriptor = BuiltInAtmosphericRenderPack.Descriptor; + var preset = descriptor.QualityPresets.Single(value => value.Id == "medium"); + + RenderPackResourceBudget budget = RenderPackResourceBudgetPlanner + .RequireWithinPreset(descriptor, preset, 1920, 1080, sampleCount: 2); + + Assert.True(budget.RetainedGpuBytes < 128L * MiB); + Assert.Equal(1920L * 1080L * 12L * 2L, budget.MultisampleGpuBytes); + Assert.Equal( + checked(budget.RetainedGpuBytes + budget.MultisampleGpuBytes), + budget.TotalGpuBytes); + Assert.Equal(3, budget.LargestImageLayerCount); + } + + [Fact] + public void Four_k_rejects_low_before_size_dependent_allocation() + { + var descriptor = BuiltInAtmosphericRenderPack.Descriptor; + var preset = descriptor.QualityPresets.Single(value => value.Id == "low"); + + NotSupportedException error = Assert.Throws(() => + RenderPackResourceBudgetPlanner.RequireWithinPreset( + descriptor, + preset, + 3840, + 2160, + sampleCount: 4)); + + Assert.Contains("low", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("3840x2160", error.Message, StringComparison.Ordinal); + Assert.Contains("resident GPU bytes", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void Four_k_high_remains_available_and_records_all_four_cascades() + { + var descriptor = BuiltInAtmosphericRenderPack.Descriptor; + var preset = descriptor.QualityPresets.Single(value => value.Id == "high"); + + RenderPackResourceBudget budget = RenderPackResourceBudgetPlanner + .RequireWithinPreset(descriptor, preset, 3840, 2160, sampleCount: 4); + + Assert.True(budget.RetainedGpuBytes <= 256L * MiB); + Assert.Equal(4, budget.LargestImageLayerCount); + Assert.Equal(3840, budget.LargestImageWidth); + Assert.Equal(2160, budget.LargestImageHeight); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Packs/RenderPackRuntimeFailureRecoveryTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackRuntimeFailureRecoveryTests.cs new file mode 100644 index 00000000..ed8f5ccf --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackRuntimeFailureRecoveryTests.cs @@ -0,0 +1,341 @@ +using AcDream.App.Plugins; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Vk; +using AcDream.App.Rendering.Packs; +using AcDream.App.Rendering.Wb; +using AcDream.App.Tests.Architecture; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.Plugin.Abstractions.Rendering; +using AcDream.UI.Abstractions.Panels.Settings; +using Silk.NET.Vulkan; + +namespace AcDream.App.Tests.Rendering.Packs; + +public sealed class RenderPackRuntimeFailureRecoveryTests +{ + [Fact] + public void DirectionalShadowFailureCancelsBorrowedTransformsBeforePackRetirementAndRetailReplay() + { + var render = typeof(VulkanWorldScenePhase).GetMethod( + nameof(VulkanWorldScenePhase.Render))!; + string[] lifetimeCalls = CompiledCallGraph.Read(render) + .Where(call => + (call.Target.DeclaringType == typeof(WbDrawDispatcher) + && call.Target.Name == nameof( + WbDrawDispatcher.CancelDirectionalShadowTransformFrame)) + || (call.Target.DeclaringType == typeof(RenderPackController) + && call.Target.Name == nameof(RenderPackController.OnRuntimeFailure)) + || (call.Target.DeclaringType == typeof(VulkanWorldScenePhase) + && call.Target.Name == "RenderRetail")) + .Select(call => call.Target.Name) + .ToArray(); + + bool hasSafeLateFailureHandoff = Enumerable.Range( + 0, + Math.Max(0, lifetimeCalls.Length - 2)) + .Any(index => + lifetimeCalls[index] + == nameof(WbDrawDispatcher.CancelDirectionalShadowTransformFrame) + && lifetimeCalls[index + 1] + == nameof(RenderPackController.OnRuntimeFailure) + && lifetimeCalls[index + 2] == "RenderRetail"); + + Assert.True( + hasSafeLateFailureHandoff, + "A post-publication directional-shadow failure must cancel the " + + "borrowed transform frame before retiring the pack-owned buffer " + + "and replaying the frame through retail rendering."); + } + + [Fact] + public void EnhancedWorldFailureQuarantinesPackAndNextFrameUsesDefaultRenderer() + { + using var rig = new FailureRig(failEnhancedWorld: true, failPostProcess: false); + + WorldRenderFrameOutcome failedFrame = rig.RenderFrame(); + + Assert.Equal(default, failedFrame); + Assert.Equal(RenderPackActivationState.FailedToRetail, rig.Controller.Snapshot.State); + Assert.Contains( + "Atmospheric world rendering failed: enhanced world failed", + rig.Controller.Snapshot.Reason, + StringComparison.Ordinal); + Assert.Null(rig.Controller.ActiveRuntime); + Assert.True(rig.Factory.Runtime.Disposed); + + WorldRenderFrameOutcome recovered = rig.RenderFrame(); + + Assert.Equal(FailingWorldPhase.Success, recovered); + Assert.Equal(2, rig.World.RenderCount); + Assert.Contains( + rig.Device.Calls.OfType(), + call => call.Name == "vk-world"); + Assert.Equal(RenderPackActivationState.FailedToRetail, rig.Controller.Snapshot.State); + Assert.Contains("enhanced world failed", rig.Controller.Snapshot.Reason); + + rig.Controller.Request(rig.Selection); + Assert.Equal(FailingWorldPhase.Success, rig.RenderFrame()); + Assert.Equal(1, rig.Factory.BuildCount); + Assert.Contains("will not be retried", rig.Controller.Snapshot.Reason); + } + + [Fact] + public void PostProcessFailureKeepsWorldOutcomeAndNextFrameUsesDefaultRenderer() + { + using var rig = new FailureRig(failEnhancedWorld: false, failPostProcess: true); + + WorldRenderFrameOutcome failedFrame = rig.RenderFrame(); + + Assert.Equal(FailingWorldPhase.Success, failedFrame); + Assert.Equal(RenderPackActivationState.FailedToRetail, rig.Controller.Snapshot.State); + Assert.Contains( + "Atmospheric post-processing failed: post process failed", + rig.Controller.Snapshot.Reason, + StringComparison.Ordinal); + Assert.Null(rig.Controller.ActiveRuntime); + Assert.True(rig.Factory.Runtime.Disposed); + + WorldRenderFrameOutcome recovered = rig.RenderFrame(); + + Assert.Equal(FailingWorldPhase.Success, recovered); + Assert.Equal(2, rig.World.RenderCount); + Assert.Contains( + rig.Device.Calls.OfType(), + call => call.Name == "vk-world"); + Assert.Equal(RenderPackActivationState.FailedToRetail, rig.Controller.Snapshot.State); + Assert.Contains("post process failed", rig.Controller.Snapshot.Reason); + } + + [Theory] + [InlineData(Result.ErrorDeviceLost)] + [InlineData(Result.ErrorOutOfHostMemory)] + [InlineData(Result.ErrorOutOfDeviceMemory)] + public void FatalVulkanPostProcessFailureIsRethrownWithoutPretendingRetailCanRecover( + Result result) + { + var failure = new VulkanCallException("pack post process", result); + using var rig = new FailureRig( + enhancedWorldFailure: null, + postProcessFailure: failure); + + VulkanCallException thrown = Assert.Throws( + () => rig.RenderFrame()); + + Assert.Same(failure, thrown); + Assert.Equal(RenderPackActivationState.Active, rig.Controller.Snapshot.State); + Assert.False(rig.Factory.Runtime.Disposed); + } + + [Fact] + public void NonTerminalVulkanPostProcessFailureQuarantinesPackAndFallsBack() + { + using var rig = new FailureRig( + enhancedWorldFailure: null, + postProcessFailure: new VulkanCallException( + "pack post process", + Result.ErrorFormatNotSupported)); + + WorldRenderFrameOutcome failedFrame = rig.RenderFrame(); + + Assert.Equal(FailingWorldPhase.Success, failedFrame); + Assert.Equal(RenderPackActivationState.FailedToRetail, rig.Controller.Snapshot.State); + Assert.Null(rig.Controller.ActiveRuntime); + Assert.True(rig.Factory.Runtime.Disposed); + Assert.Contains("ErrorFormatNotSupported", rig.Controller.Snapshot.Reason, + StringComparison.Ordinal); + } + + private sealed class FailureRig : IDisposable + { + private readonly BufferedRenderPackRegistry _registry = new(); + private readonly IDisposable _registration; + private readonly GpuDeviceFrameLifetime _lifetime; + private readonly VulkanWorldScenePhase _phase; + + internal FailureRig(bool failEnhancedWorld, bool failPostProcess) + : this( + failEnhancedWorld + ? new InvalidOperationException("enhanced world failed") + : null, + failPostProcess + ? new InvalidOperationException("post process failed") + : null) + { + } + + internal FailureRig( + Exception? enhancedWorldFailure, + Exception? postProcessFailure) + { + RenderPackDescriptor descriptor = Descriptor(); + _registration = _registry.Register(descriptor, EmptyAssets.Instance); + Device = new RecordingGpuDevice(); + Factory = new FailingGraphFactory(Device, postProcessFailure); + Controller = new RenderPackController( + () => RenderPackCatalog.Build( + _registry.Snapshot(), + RenderPackHostCapabilities.Conformance), + Factory, + preparationScheduler: InlineRenderPackPreparationScheduler.Instance); + Selection = new RenderPackSelectionSettings( + descriptor.Id, + descriptor.PackVersion.ToString(), + "default"); + Controller.Request(Selection); + _lifetime = new GpuDeviceFrameLifetime(Device); + var scope = new VulkanWorldPassScope(sampleCount: 1); + World = new FailingWorldPhase(enhancedWorldFailure); + _phase = new VulkanWorldScenePhase( + _lifetime, + new VulkanBackbufferClearState(), + sampleCount: static () => 1, + scope, + World, + Controller, + new AtmosphericFrameInputState()); + } + + internal RecordingGpuDevice Device { get; } + + internal FailingGraphFactory Factory { get; } + + internal RenderPackController Controller { get; } + + internal RenderPackSelectionSettings Selection { get; } + + internal FailingWorldPhase World { get; } + + internal WorldRenderFrameOutcome RenderFrame() + { + _lifetime.BeginFrame(); + try + { + return _phase.Render(new RenderFrameInput(1.0 / 60.0, 1280, 720)); + } + finally + { + _lifetime.EndFrame(); + } + } + + public void Dispose() + { + Controller.Dispose(); + _registration.Dispose(); + _registry.Dispose(); + Device.Dispose(); + } + } + + private sealed class FailingGraphFactory( + RecordingGpuDevice device, + Exception? postProcessFailure) : IRenderPackRuntimeFactory + { + internal FailingGraphRuntime Runtime { get; private set; } = null!; + + internal int BuildCount { get; private set; } + + public IRenderPackRuntime Build( + RenderPackDescriptor descriptor, + ValidatedRenderPackShaderAssets assets, + RenderQualityPreset preset, + IReadOnlyDictionary userSettingOverrides) + { + BuildCount++; + Runtime = new FailingGraphRuntime(device, descriptor, preset, postProcessFailure); + return Runtime; + } + } + + private sealed class FailingGraphRuntime : IAtmosphericWorldGraphRuntime + { + private readonly RecordingGpuDevice _device; + private readonly Exception? _postProcessFailure; + private IGpuRenderTarget? _target; + + internal FailingGraphRuntime( + RecordingGpuDevice device, + RenderPackDescriptor descriptor, + RenderQualityPreset preset, + Exception? postProcessFailure) + { + _device = device; + Descriptor = descriptor; + Preset = preset; + _postProcessFailure = postProcessFailure; + } + + public RenderPackDescriptor Descriptor { get; } + + public RenderQualityPreset Preset { get; } + + internal bool Disposed { get; private set; } + + public IGpuRenderTarget PrepareWorldTarget(int width, int height, int sampleCount) => + _target ??= _device.CreateRenderTarget(new GpuRenderTargetDescription( + "failing-pack-target", + width, + height, + GpuTextureFormat.Rgba16FloatRenderTarget, + GpuTextureFormat.Depth24Stencil8, + sampleCount)); + + public void RenderPostProcess(IGpuFrame frame, in AtmosphericFrameInputs inputs) + { + if (_postProcessFailure is not null) + throw _postProcessFailure; + } + + public void Dispose() + { + if (Disposed) + return; + Disposed = true; + _target?.Dispose(); + _target = null; + } + } + + private sealed class FailingWorldPhase(Exception? firstFailure) : IWorldSceneFramePhase + { + internal static WorldRenderFrameOutcome Success { get; } = new(5, 9, true); + + internal int RenderCount { get; private set; } + + public WorldRenderFrameOutcome Render(RenderFrameInput input) + { + RenderCount++; + if (firstFailure is not null && RenderCount == 1) + throw firstFailure; + return Success; + } + } + + private static RenderPackDescriptor Descriptor() => new( + "failure.pack", + "Failure pack", + new Version(1, 0, 0), + RenderPackApi.Current, + RenderPackTier.Tier1, + [], + [], + [], + [], + [], + [], + [new RenderQualityPreset("default", "Default", [], [], [], 0, 0, 0, 0, 0)], + [], + null) + { + FeatureSummary = "Runtime failure test pack.", + }; + + private sealed class EmptyAssets : IRenderPackAssets + { + internal static EmptyAssets Instance { get; } = new(); + + public Stream OpenRead(string assetKey) => + throw new InvalidOperationException("The failure test pack declares no assets."); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Packs/RenderPackSpirvValidatorTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackSpirvValidatorTests.cs new file mode 100644 index 00000000..a62f1647 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackSpirvValidatorTests.cs @@ -0,0 +1,448 @@ +using AcDream.App.Rendering.Packs; +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.App.Tests.Rendering.Packs; + +public sealed class RenderPackSpirvValidatorTests +{ + [Fact] + public void BuiltInDescriptorValidatesSelectedCelestialContract() + { + RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor( + BuiltInAtmosphericRenderPack.Descriptor, + RenderPackHostCapabilities.Conformance); + + Assert.True(result.Success, result.Reason); + } + + [Fact] + public void SelectedCelestialSemanticRequiresAuthoredCelestialCapability() + { + RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor; + descriptor = descriptor with + { + RequiredCapabilities = descriptor.RequiredCapabilities + .Where(static capability => + capability != RenderCapability.AuthoredCelestialDirectionalLight) + .ToArray(), + }; + + RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor( + descriptor, + RenderPackHostCapabilities.Conformance); + + Assert.False(result.Success); + Assert.Equal( + $"Pack '{descriptor.Id}' declares semantic " + + $"'{RenderSemanticInput.SelectedCelestialDirectionalLight}' but does not " + + $"require capability '{RenderCapability.AuthoredCelestialDirectionalLight}'.", + result.Reason); + } + + [Fact] + public void DirectionalShadowDepthRejectsSunDirectionAlias() + { + RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor; + RenderPassDeclaration shadowPass = descriptor.Passes.Single(static pass => + pass.Semantic == RenderPassSemantic.DirectionalShadowDepth); + descriptor = descriptor with + { + Passes = descriptor.Passes.Select(pass => ReferenceEquals(pass, shadowPass) + ? pass with + { + SemanticInputs = pass.SemanticInputs + .Append(RenderSemanticInput.SunDirection) + .ToArray(), + } + : pass).ToArray(), + }; + + RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor( + descriptor, + RenderPackHostCapabilities.Conformance); + + Assert.False(result.Success); + Assert.Equal( + $"Directional-shadow pass '{shadowPass.Id}' must declare " + + $"'{RenderSemanticInput.SelectedCelestialDirectionalLight}' and must not " + + "alias the sun-specific atmospheric direction.", + result.Reason); + } + + [Fact] + public void BuiltInPackPassesBinaryShaderInterfaceValidation() + { + RenderPackValidationResult result = RenderPackValidator.ValidateSelectedAssets( + BuiltInAtmosphericRenderPack.Descriptor, + BuiltInAtmosphericRenderPack.CreateAssets(SpirvDirectory())); + + Assert.True(result.Success, result.Reason); + } + + [Fact] + public void DirectionalShadowUniformRequiresSixMembersAndSelectedSourceAtOffset320() + { + byte[] valid = Shader("directional_shadow_world_opaque.vert.spv"); + PipelineVariantDeclaration variant = BuiltInAtmosphericRenderPack.Descriptor + .PipelineVariants.Single(static value => + value.Semantic + == RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster); + + Assert.Equal(336, RenderPackShaderAbi.DirectionalShadowSizeBytes); + Assert.Equal( + 6, + BlockMemberCount( + valid, + RenderPackShaderAbi.UniformDescriptorSet, + RenderPackShaderAbi.DirectionalShadowBinding)); + Assert.Equal( + 320u, + BlockMemberOffset( + valid, + RenderPackShaderAbi.UniformDescriptorSet, + RenderPackShaderAbi.DirectionalShadowBinding, + member: 5)); + RenderPackSpirvValidationResult baseline = + RenderPackSpirvValidator.ValidatePipelineVariantShader( + valid, + RenderPackShaderStage.Vertex, + variant); + Assert.True(baseline.Success, baseline.Reason); + + byte[] wrongOffset = valid.ToArray(); + MutateBlockMemberOffset( + wrongOffset, + RenderPackShaderAbi.UniformDescriptorSet, + RenderPackShaderAbi.DirectionalShadowBinding, + member: 5, + replacement: 304); + RenderPackSpirvValidationResult offsetResult = + RenderPackSpirvValidator.ValidatePipelineVariantShader( + wrongOffset, + RenderPackShaderStage.Vertex, + variant); + Assert.False(offsetResult.Success); + Assert.Contains("336-byte ABI v1", offsetResult.Reason, StringComparison.Ordinal); + + byte[] fiveMembers = RemoveLastBlockMember( + valid, + RenderPackShaderAbi.UniformDescriptorSet, + RenderPackShaderAbi.DirectionalShadowBinding); + RenderPackSpirvValidationResult memberCountResult = + RenderPackSpirvValidator.ValidatePipelineVariantShader( + fiveMembers, + RenderPackShaderStage.Vertex, + variant); + Assert.False(memberCountResult.Success); + Assert.Contains("336-byte ABI v1", memberCountResult.Reason, StringComparison.Ordinal); + } + + [Fact] + public void FullscreenShaderCannotReadReservedSetZero() + { + RenderPackSpirvValidationResult result = + RenderPackSpirvValidator.ValidatePassShader( + Shader("directional_shadow_world_opaque.vert.spv"), + RenderPackShaderStage.Vertex, + Pass()); + + Assert.False(result.Success); + Assert.Contains("set 0 binding 0", result.Reason, StringComparison.Ordinal); + } + + [Fact] + public void FullscreenShaderCannotAliasRetailSetOne() + { + RenderPackSpirvValidationResult result = + RenderPackSpirvValidator.ValidatePassShader( + Shader("terrain_atmospheric.vert.spv"), + RenderPackShaderStage.Vertex, + Pass(inputs: + [ + RenderSemanticInput.Weather, + RenderSemanticInput.SelectedCelestialDirectionalLight, + ])); + + Assert.False(result.Success); + Assert.Contains("set 1 binding", result.Reason, StringComparison.Ordinal); + } + + [Theory] + [InlineData(0, 4u)] + [InlineData(6, 112u)] + public void WrongAtmosphericUniformOffsetOrSizeIsRejected(int member, uint replacement) + { + byte[] spirv = Shader("atmospheric_sun_occlusion.frag.spv"); + MutateBlockMemberOffset( + spirv, + RenderPackShaderAbi.UniformDescriptorSet, + RenderPackShaderAbi.AtmosphericFrameBinding, + member, + replacement); + + RenderPackSpirvValidationResult result = + RenderPackSpirvValidator.ValidatePassShader( + spirv, + RenderPackShaderStage.Fragment, + Pass(inputs: + [ + RenderSemanticInput.SceneDepth, + RenderSemanticInput.SunScreenPosition, + RenderSemanticInput.Weather, + ])); + + Assert.False(result.Success); + Assert.Contains("AtmosphericFrame", result.Reason, StringComparison.Ordinal); + } + + [Fact] + public void DeclaredStageAndMainEntryPointAreEnforced() + { + RenderPackSpirvValidationResult result = + RenderPackSpirvValidator.ValidatePassShader( + Shader("atmospheric_sun_rays.frag.spv"), + RenderPackShaderStage.Vertex, + Pass(inputs: [RenderSemanticInput.FrameTime], reads: ["mask"])); + + Assert.False(result.Success); + Assert.Contains("entry point 'main'", result.Reason, StringComparison.Ordinal); + } + + [Fact] + public void SampledTextureTableRequiresDeclaredSemanticOrResourceInput() + { + RenderPackSpirvValidationResult result = + RenderPackSpirvValidator.ValidatePassShader( + Shader("atmospheric_bloom_downsample.frag.spv"), + RenderPackShaderStage.Fragment, + Pass()); + + Assert.False(result.Success); + Assert.Contains("sampled without a declared", result.Reason, StringComparison.Ordinal); + } + + [Fact] + public void WritableRendererStorageIsRejectedEvenForAnAllowedBaseRole() + { + byte[] spirv = Shader("directional_shadow_world_opaque.vert.spv"); + RemoveNonWritableDecoration(spirv, set: 0, binding: 0); + var variant = new PipelineVariantDeclaration( + "world-caster", + RenderPipelineBaseSemantic.WorldMesh, + "world.vert.spv", + "world.frag.spv", + RenderMaterialClass.Opaque, + [RenderSemanticInput.ShadowCasterTransforms]) + { + Semantic = RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster, + }; + + RenderPackSpirvValidationResult result = + RenderPackSpirvValidator.ValidatePipelineVariantShader( + spirv, + RenderPackShaderStage.Vertex, + variant); + + Assert.False(result.Success); + Assert.Contains("storage writes are forbidden", result.Reason, StringComparison.Ordinal); + } + + private static RenderPassDeclaration Pass( + IReadOnlyList? inputs = null, + IReadOnlyList? reads = null) => new( + "pass", + RenderPassHook.ToneMap, + "pass.vert.spv", + "pass.frag.spv", + inputs ?? [], + reads ?? [], + []); + + private static byte[] Shader(string name) => File.ReadAllBytes(Path.Combine(SpirvDirectory(), name)); + + private static string SpirvDirectory() => Path.Combine( + RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", "spv"); + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + directory = directory.Parent; + return directory?.FullName + ?? throw new InvalidOperationException("Could not locate the repository root."); + } + + private static void MutateBlockMemberOffset( + byte[] spirv, + uint set, + uint binding, + int member, + uint replacement) + { + uint[] words = Words(spirv); + uint variable = DescriptorVariable(words, set, binding); + uint pointer = VariableResultType(words, variable); + uint structure = PointerPointee(words, pointer); + for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16))) + { + int count = checked((int)(words[index] >> 16)); + uint opcode = words[index] & 0xffff; + if (opcode == 72 && count >= 5 + && words[index + 1] == structure + && words[index + 2] == (uint)member + && words[index + 3] == 35) + { + words[index + 4] = replacement; + CopyBack(words, spirv); + return; + } + } + throw new InvalidOperationException("Target block member offset was not found."); + } + + private static void RemoveNonWritableDecoration(byte[] spirv, uint set, uint binding) + { + uint[] words = Words(spirv); + uint variable = DescriptorVariable(words, set, binding); + uint pointer = VariableResultType(words, variable); + uint structure = PointerPointee(words, pointer); + bool mutated = false; + for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16))) + { + int count = checked((int)(words[index] >> 16)); + uint opcode = words[index] & 0xffff; + if (opcode == 71 && count >= 3 + && words[index + 1] == variable + && words[index + 2] == 24) + { + words[index + 2] = 23; // Coherent, so the descriptor is no longer readonly. + mutated = true; + } + else if (opcode == 72 && count >= 4 + && words[index + 1] == structure + && words[index + 3] == 24) + { + words[index + 3] = 23; + mutated = true; + } + } + if (!mutated) + throw new InvalidOperationException("Target NonWritable decoration was not found."); + CopyBack(words, spirv); + } + + private static int BlockMemberCount(byte[] spirv, uint set, uint binding) + { + uint[] words = Words(spirv); + uint structure = DescriptorStructure(words, set, binding); + for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16))) + { + int count = checked((int)(words[index] >> 16)); + if ((words[index] & 0xffff) == 30 && count >= 2 && words[index + 1] == structure) + return count - 2; + } + throw new InvalidOperationException("Descriptor block structure was not found."); + } + + private static uint BlockMemberOffset( + byte[] spirv, + uint set, + uint binding, + int member) + { + uint[] words = Words(spirv); + uint structure = DescriptorStructure(words, set, binding); + for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16))) + { + int count = checked((int)(words[index] >> 16)); + if ((words[index] & 0xffff) == 72 + && count >= 5 + && words[index + 1] == structure + && words[index + 2] == (uint)member + && words[index + 3] == 35) + { + return words[index + 4]; + } + } + throw new InvalidOperationException("Descriptor block member offset was not found."); + } + + private static byte[] RemoveLastBlockMember(byte[] spirv, uint set, uint binding) + { + uint[] words = Words(spirv); + uint structure = DescriptorStructure(words, set, binding); + for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16))) + { + int count = checked((int)(words[index] >> 16)); + if ((words[index] & 0xffff) != 30 + || count < 3 + || words[index + 1] != structure) + { + continue; + } + + var mutated = words.ToList(); + mutated[index] = ((uint)(count - 1) << 16) | 30u; + mutated.RemoveAt(index + count - 1); + var bytes = new byte[mutated.Count * sizeof(uint)]; + Buffer.BlockCopy(mutated.ToArray(), 0, bytes, 0, bytes.Length); + return bytes; + } + throw new InvalidOperationException("Descriptor block structure was not found."); + } + + private static uint DescriptorStructure(uint[] words, uint set, uint binding) + { + uint variable = DescriptorVariable(words, set, binding); + uint pointer = VariableResultType(words, variable); + return PointerPointee(words, pointer); + } + + private static uint DescriptorVariable(uint[] words, uint set, uint binding) + { + var sets = new Dictionary(); + var bindings = new Dictionary(); + for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16))) + { + int count = checked((int)(words[index] >> 16)); + uint opcode = words[index] & 0xffff; + if (opcode != 71 || count < 4) + continue; + if (words[index + 2] == 34) sets[words[index + 1]] = words[index + 3]; + if (words[index + 2] == 33) bindings[words[index + 1]] = words[index + 3]; + } + return sets.Keys.Single(id => sets[id] == set && bindings.GetValueOrDefault(id) == binding); + } + + private static uint VariableResultType(uint[] words, uint variable) + { + for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16))) + { + int count = checked((int)(words[index] >> 16)); + if ((words[index] & 0xffff) == 59 && count >= 4 && words[index + 2] == variable) + return words[index + 1]; + } + throw new InvalidOperationException("Descriptor variable was not found."); + } + + private static uint PointerPointee(uint[] words, uint pointer) + { + for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16))) + { + int count = checked((int)(words[index] >> 16)); + if ((words[index] & 0xffff) == 32 && count >= 4 && words[index + 1] == pointer) + return words[index + 3]; + } + throw new InvalidOperationException("Descriptor pointer type was not found."); + } + + private static uint[] Words(byte[] bytes) + { + var words = new uint[bytes.Length / 4]; + Buffer.BlockCopy(bytes, 0, words, 0, bytes.Length); + return words; + } + + private static void CopyBack(uint[] words, byte[] bytes) => + Buffer.BlockCopy(words, 0, bytes, 0, bytes.Length); +} diff --git a/tests/AcDream.App.Tests/Rendering/Packs/VolumetricShaftRendererTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/VolumetricShaftRendererTests.cs new file mode 100644 index 00000000..3964c8f1 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Packs/VolumetricShaftRendererTests.cs @@ -0,0 +1,369 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Packs; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.Core.World; +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.App.Tests.Rendering.Packs; + +public sealed class VolumetricShaftRendererTests +{ + [Fact] + public void MediumConsumesCurrentB5B6B8AndWritesQuarterResolutionHdr() + { + var device = new RecordingGpuDevice(); + using var renderer = Renderer(device, "medium"); + GpuTextureSlot depth = TextureSlot(device, "scene-depth"); + using IGpuFrame frame = device.BeginFrame(); + DirectionalShadowFrameBinding shadow = Shadow(frame, device.DefaultTextureSlot); + AtmosphericFrameInputs inputs = Inputs(800, 600); + device.Clear(); + + VolumetricShaftOutput output = renderer.Render(frame, in inputs, in shadow, depth); + frame.End(); + + Assert.True(output.HasTexture); + Assert.Equal(VolumetricShaftGateReason.Rendered, output.Diagnostics.GateReason); + Assert.Equal(200, output.Diagnostics.Width); + Assert.Equal(150, output.Diagnostics.Height); + Assert.Equal(40, output.Diagnostics.RayMarchSteps); + Assert.Equal(200L * 150L * 8L, output.Diagnostics.RetainedGpuBytes); + Assert.Equal( + [ + GpuBindingModel.UniformAtmosphericFrame, + GpuBindingModel.UniformDirectionalShadow, + GpuBindingModel.UniformPackPass, + GpuBindingModel.UniformPackSettings, + ], + device.OfKind().Select(call => call.Binding)); + Assert.Equal(depth.Index, + Assert.Single(device.OfKind()).Constants.TextureIndexA); + Assert.Equal(1, renderer.Performance.CpuSampleCount); + Assert.Equal(0, renderer.Performance.GpuSampleCount); + } + + [Fact] + public void LowDefaultsOffWithoutAllocatingTargetOrRecordingPass() + { + var device = new RecordingGpuDevice(); + using var renderer = Renderer(device, "low"); + using IGpuFrame frame = device.BeginFrame(); + DirectionalShadowFrameBinding shadow = Shadow(frame, device.DefaultTextureSlot); + AtmosphericFrameInputs inputs = Inputs(800, 600); + int targets = device.CreatedRenderTargets.Count; + + VolumetricShaftOutput output = renderer.Render( + frame, + in inputs, + in shadow, + device.DefaultTextureSlot); + frame.End(); + + Assert.False(output.HasTexture); + Assert.Equal(VolumetricShaftGateReason.DisabledByPreset, output.Diagnostics.GateReason); + Assert.Equal(targets, device.CreatedRenderTargets.Count); + Assert.Empty(device.OfKind()); + Assert.Equal(default, renderer.Performance); + } + + [Fact] + public void LowUserOverrideEnablesQuarterResolutionTwentyFourStepShafts() + { + var device = new RecordingGpuDevice(); + RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor; + RenderQualityPreset low = Assert.Single( + descriptor.QualityPresets, + value => string.Equals(value.Id, "low", StringComparison.Ordinal)); + using var renderer = new VolumetricShaftRenderer( + device, + descriptor, + Assets(), + low, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["volumetric-strength"] = "0.25", + }); + GpuTextureSlot depth = TextureSlot(device, "depth"); + + RenderOne(renderer, device, Inputs(800, 600), depth); + + Assert.Equal(VolumetricShaftGateReason.Rendered, renderer.LastDiagnostics.GateReason); + Assert.Equal(200, renderer.LastDiagnostics.Width); + Assert.Equal(150, renderer.LastDiagnostics.Height); + Assert.Equal(24, renderer.LastDiagnostics.RayMarchSteps); + Assert.True(renderer.LastDiagnostics.Strength > 0f); + } + + [Fact] + public void StaleShadowBindingAndIndoorFrameFailClosedWithoutSamplingOldOutput() + { + var device = new RecordingGpuDevice(); + using var renderer = Renderer(device, "high"); + AtmosphericFrameInputs inputs = Inputs(1280, 720); + using IGpuFrame frame = device.BeginFrame(); + var stale = new DirectionalShadowFrameBinding( + frame.Serial - 1, + true, + device.RingBuffer, + 0, + DirectionalShadowUniforms.SizeInBytes, + device.DefaultTextureSlot, + 4); + + VolumetricShaftOutput staleOutput = renderer.Render( + frame, + in inputs, + in stale, + device.DefaultTextureSlot); + DirectionalShadowFrameBinding current = Shadow(frame, device.DefaultTextureSlot); + AtmosphericFrameInputs indoor = inputs with { IsOutdoor = false }; + VolumetricShaftOutput indoorOutput = renderer.Render( + frame, + in indoor, + in current, + device.DefaultTextureSlot); + frame.End(); + + Assert.Equal(VolumetricShaftGateReason.NoCurrentDirectionalShadow, + staleOutput.Diagnostics.GateReason); + Assert.Equal(VolumetricShaftGateReason.Indoor, indoorOutput.Diagnostics.GateReason); + Assert.False(staleOutput.HasTexture); + Assert.False(indoorOutput.HasTexture); + Assert.Empty(device.CreatedRenderTargets); + } + + [Fact] + public void ResizeAtomicallyReplacesTargetAndResetsMixedResolutionPerformanceWindow() + { + var device = new RecordingGpuDevice(); + using var renderer = Renderer(device, "high"); + GpuTextureSlot depth = TextureSlot(device, "depth"); + + RenderOne(renderer, device, Inputs(800, 600), depth); + RecordingGpuRenderTarget first = Assert.Single(device.CreatedRenderTargets); + Assert.Equal(400, first.Description.Width); + Assert.Equal(1, renderer.Performance.CpuSampleCount); + + RenderOne(renderer, device, Inputs(1200, 800), depth); + + Assert.True(first.IsDisposed); + Assert.Equal(600, device.CreatedRenderTargets[^1].Description.Width); + Assert.Equal(400, device.CreatedRenderTargets[^1].Description.Height); + Assert.Equal(1, renderer.Performance.CpuSampleCount); + } + + [Fact] + public void TargetFailureRollsBackAndRetryPublishesOneOwnedTexture() + { + var device = new RecordingGpuDevice(); + using var renderer = Renderer(device, "medium"); + GpuTextureSlot depth = TextureSlot(device, "depth"); + int baselineSlots = device.LiveTextureSlotCount; + device.RenderTargetFailure = _ => new InvalidOperationException("volumetric allocation failed"); + + Assert.Throws(() => RenderOne( + renderer, + device, + Inputs(800, 600), + depth)); + Assert.Equal(baselineSlots, device.LiveTextureSlotCount); + Assert.Empty(device.CreatedRenderTargets); + + device.RenderTargetFailure = null; + RenderOne(renderer, device, Inputs(800, 600), depth); + Assert.Equal(baselineSlots + 1, device.LiveTextureSlotCount); + Assert.Single(device.CreatedRenderTargets); + } + + [Fact] + public void DisposeReleasesOutputSlotTargetAndPipeline() + { + var device = new RecordingGpuDevice(); + GpuTextureSlot depth = TextureSlot(device, "depth"); + int baselineSlots = device.LiveTextureSlotCount; + var renderer = Renderer(device, "medium"); + RenderOne(renderer, device, Inputs(800, 600), depth); + RecordingGpuRenderTarget target = Assert.Single(device.CreatedRenderTargets); + RecordingGpuPipeline pipeline = Assert.Single(device.CreatedPipelines); + + renderer.Dispose(); + + Assert.Equal(baselineSlots, device.LiveTextureSlotCount); + Assert.True(target.IsDisposed); + Assert.True(pipeline.IsDisposed); + } + + [Fact] + public void AuthoredWeatherAndSunElevationContinuouslyScaleTheSameFramePolicy() + { + var device = new RecordingGpuDevice(); + using var renderer = Renderer(device, "medium"); + GpuTextureSlot depth = TextureSlot(device, "depth"); + + RenderOne(renderer, device, Inputs(800, 600), depth); + float clearLowSun = renderer.LastDiagnostics.Strength; + AtmosphericFrameInputs overcast = Inputs(800, 600) with + { + Weather = WeatherKind.Overcast, + WeatherIntensity = 1f, + }; + RenderOne(renderer, device, overcast, depth); + float overcastLowSun = renderer.LastDiagnostics.Strength; + AtmosphericFrameInputs noon = Inputs(800, 600) with + { + SunElevationDegrees = 70f, + }; + RenderOne(renderer, device, noon, depth); + + Assert.True(clearLowSun > overcastLowSun); + Assert.True(clearLowSun > renderer.LastDiagnostics.Strength); + Assert.True(overcastLowSun > 0f); + } + + [Fact] + public void DeclaredActiveDayGroupMultiplierScalesAuthoredPolicy() + { + var device = new RecordingGpuDevice(); + using var renderer = Renderer(device, "medium"); + GpuTextureSlot depth = TextureSlot(device, "depth"); + + RenderOne(renderer, device, Inputs(800, 600) with { ActiveDayGroup = 0 }, depth); + float groupZero = renderer.LastDiagnostics.Strength; + RenderOne(renderer, device, Inputs(800, 600) with { ActiveDayGroup = 1 }, depth); + float groupOne = renderer.LastDiagnostics.Strength; + + Assert.Equal(groupZero * 0.35f, groupOne, 5); + } + + [Fact] + public void DeclaredVolumetricElevationCurveControlsShaftStrength() + { + var device = new RecordingGpuDevice(); + RenderPackDescriptor source = BuiltInAtmosphericRenderPack.Descriptor; + RenderPackDescriptor changed = source with + { + AtmospherePolicy = source.AtmospherePolicy! with + { + VolumetricShaftSunElevationResponse = + [ + new SunElevationResponsePoint(-90, 0.25), + new SunElevationResponsePoint(90, 0.25), + ], + }, + }; + RenderQualityPreset medium = Assert.Single(changed.QualityPresets, value => + value.Semantic == RenderQualitySemantic.Medium); + using var renderer = new VolumetricShaftRenderer( + device, + changed, + Assets(), + medium); + GpuTextureSlot depth = TextureSlot(device, "depth"); + + RenderOne(renderer, device, Inputs(800, 600), depth); + + Assert.Equal(0.25f * 0.35f, renderer.LastDiagnostics.Strength, 5); + } + + [Fact] + public void ShaderUsesVulkanYFlipWorldMetreBiasAndShadowStrengthMix() + { + string source = File.ReadAllText(Path.Combine( + RepositoryRoot(), + "src", "AcDream.App", "Rendering", "Shaders", "atmospheric_volumetric.frag")); + + Assert.Contains("0.5 - ndc.y * 0.5", source, StringComparison.Ordinal); + Assert.Contains("surfaceToSun * max(uShadowBiasMeters.x, 0.0)", source, + StringComparison.Ordinal); + Assert.Contains("mix(1.0, visible, clamp(uShadowControl.x, 0.0, 1.0))", source, + StringComparison.Ordinal); + Assert.DoesNotContain("ndc.z -", source, StringComparison.Ordinal); + } + + private static void RenderOne( + VolumetricShaftRenderer renderer, + RecordingGpuDevice device, + AtmosphericFrameInputs inputs, + GpuTextureSlot depth) + { + using IGpuFrame frame = device.BeginFrame(); + DirectionalShadowFrameBinding shadow = Shadow(frame, device.DefaultTextureSlot); + renderer.Render(frame, in inputs, in shadow, depth); + frame.End(); + } + + private static DirectionalShadowFrameBinding Shadow( + IGpuFrame frame, + GpuTextureSlot shadowTexture) + { + GpuRingAllocation allocation = frame.AllocateRing( + DirectionalShadowUniforms.SizeInBytes, + GpuRingUsage.Uniform); + allocation.Data.Clear(); + return new DirectionalShadowFrameBinding( + frame.Serial, + true, + allocation.Buffer, + allocation.OffsetBytes, + DirectionalShadowUniforms.SizeInBytes, + shadowTexture, + 3); + } + + private static GpuTextureSlot TextureSlot(RecordingGpuDevice device, string name) + { + IGpuTexture texture = device.CreateTexture(new GpuTextureDescription( + name, + GpuTextureKind.Texture2D, + GpuTextureFormat.Rgba8Unorm, + 1, + 1, + 1, + 1)); + return device.RegisterTexture(texture, device.CreateSampler(GpuSamplerDescription.WorldClamp)); + } + + private static VolumetricShaftRenderer Renderer( + RecordingGpuDevice device, + string presetId) + { + RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor; + RenderQualityPreset preset = Assert.Single( + descriptor.QualityPresets, + value => string.Equals(value.Id, presetId, StringComparison.Ordinal)); + return new VolumetricShaftRenderer(device, descriptor, Assets(), preset); + } + + private static AtmosphericFrameInputs Inputs(int width, int height) => new( + new Vector2(0.5f, 0.4f), + true, + 12f, + new Vector3(1f, 0.85f, 0.7f), + Vector3.Normalize(new Vector3(0.2f, 0.5f, 0.8f)), + 1f, + Matrix4x4.Identity, + 0, + WeatherKind.Clear, + 0f, + 1d / 60d, + width, + height, + true); + + private static IRenderPackAssets Assets() => + BuiltInAtmosphericRenderPack.CreateAssets(Path.Combine( + RepositoryRoot(), + "src", "AcDream.App", "Rendering", "Shaders", "spv")); + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null + && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + directory = directory.Parent; + return directory?.FullName + ?? throw new InvalidOperationException("Could not locate repository root."); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/RetailDetailTextureContractTests.cs b/tests/AcDream.App.Tests/Rendering/RetailDetailTextureContractTests.cs new file mode 100644 index 00000000..8b488724 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RetailDetailTextureContractTests.cs @@ -0,0 +1,90 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RetailDetailTextureContractTests +{ + private static readonly TerrainAtlas.RetailDetailTextureBinding Available = new( + new GpuTextureSlot(7), + Tiling: 4f, + SurfaceTextureId: 0x05001787, + RenderSurfaceId: 0x06006D58, + Width: 256, + Height: 256); + + [Fact] + public void ExistingBuildingDetailSettingIsTheLivePassGate() + { + Assert.False(RetailDetailTextureContract.ShouldRender(false, Available)); + Assert.True(RetailDetailTextureContract.ShouldRender(true, Available)); + Assert.False(RetailDetailTextureContract.ShouldRender(true, default)); + } + + [Fact] + public void OpaqueDetailUsesDepthEqualityWhileTransparentDetailDoesNot() + { + Assert.Equal( + GpuCompareOp.Equal, + RetailDetailTextureContract.DetailDepthCompare(transparent: false)); + Assert.Equal( + GpuCompareOp.LessOrEqual, + RetailDetailTextureContract.DetailDepthCompare(transparent: true)); + } + + [Theory] + [InlineData(0f, 1f)] + [InlineData(10f, 1f)] + [InlineData(30f, 0.5f)] + [InlineData(50f, 0f)] + [InlineData(80f, 0f)] + public void DistanceFadeUsesPositiveViewDepthInMetres( + float depthMetres, + float expected) + { + Assert.Equal( + expected, + RetailDetailTextureContract.FadeForPositiveViewDepthMetres(depthMetres), + precision: 5); + } + + [Fact] + public void FadeZeroIsExactNoOpAndNeutralRgbEqualsAlpha() + { + var brighteningSample = new Vector4(0.459f, 0.459f, 0.459f, 0.282f); + Assert.Equal( + Vector3.One, + RetailDetailTextureContract.FramebufferFactor(brighteningSample, fade: 0f)); + + var neutral = new Vector4(0.4f, 0.4f, 0.4f, 0.4f); + Vector3 neutralFactor = RetailDetailTextureContract.FramebufferFactor(neutral, fade: 1f); + Assert.Equal(1f, neutralFactor.X, precision: 5); + Assert.Equal(1f, neutralFactor.Y, precision: 5); + Assert.Equal(1f, neutralFactor.Z, precision: 5); + } + + [Fact] + public void RetailBlendPreservesMeasuredBrightening() + { + var measured = new Vector4(0.459f, 0.459f, 0.459f, 0.282f); + Vector3 factor = RetailDetailTextureContract.FramebufferFactor(measured, fade: 1f); + + Assert.Equal(1.177f, factor.X, precision: 5); + Assert.Equal(1.177f, factor.Y, precision: 5); + Assert.Equal(1.177f, factor.Z, precision: 5); + } + + [Fact] + public void EnabledDerethCategoryTextureKeepsItsMeasuredBrightening() + { + var derethCategory = new Vector4(0.165f, 0.165f, 0.165f, 0.132f); + Vector3 factor = RetailDetailTextureContract.FramebufferFactor( + derethCategory, + fade: 1f); + + Assert.Equal(1.033f, factor.X, precision: 5); + Assert.Equal(1.033f, factor.Y, precision: 5); + Assert.Equal(1.033f, factor.Z, precision: 5); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs b/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs index 88675706..fbb8a25e 100644 --- a/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs +++ b/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs @@ -59,6 +59,27 @@ public sealed class StaticRenderProjectionJournalTests Assert.Equal(expected.Geometry, projected.Source.GeometryFingerprint); Assert.Equal(expected.Appearance, projected.Source.AppearanceFingerprint); Assert.Equal(LandblockId, projected.Residency.OwnerLandblockId); + Assert.Equal( + RenderCasterIdentityKind.OutdoorStatic, + projected.EntityPayload.CasterIdentity); + } + + [Fact] + public void Reconcile_RetainsAuthoritativeBuildingIdentity() + { + var journal = new RenderProjectionJournal(Generation(21)); + var statics = new StaticRenderProjectionJournal(journal); + LandblockBuild build = Build( + LandblockId, + [Entity(9, building: true)], + includeShell: false); + + statics.Reconcile(build, Publication(build)); + + Assert.Equal( + RenderCasterIdentityKind.Building, + Assert.Single(journal.Pending.ToArray()) + .Record.EntityPayload.CasterIdentity); } [Fact] @@ -442,7 +463,8 @@ public sealed class StaticRenderProjectionJournalTests private static WorldEntity Entity( uint id, uint serverGuid = 0, - Vector3 position = default) => + Vector3 position = default, + bool building = false) => new() { Id = id, @@ -450,6 +472,7 @@ public sealed class StaticRenderProjectionJournalTests SourceGfxObjOrSetupId = 0x01000000u + id, Position = position, Rotation = Quaternion.Identity, + IsBuildingShell = building, MeshRefs = [ new MeshRef( diff --git a/tests/AcDream.App.Tests/Rendering/VolumetricShaftQualityTests.cs b/tests/AcDream.App.Tests/Rendering/VolumetricShaftQualityTests.cs new file mode 100644 index 00000000..79ecf87d --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/VolumetricShaftQualityTests.cs @@ -0,0 +1,106 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Packs; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Rendering; + +public sealed class VolumetricShaftQualityTests +{ + [Fact] + public void Weak_hardware_preserves_contract_but_defaults_volumetrics_off() + { + VolumetricShaftQuality low = VolumetricShaftQuality.For(DirectionalShadowPreset.Low); + VolumetricShaftQuality medium = VolumetricShaftQuality.For(DirectionalShadowPreset.Medium); + VolumetricShaftQuality high = VolumetricShaftQuality.For(DirectionalShadowPreset.High); + + Assert.False(low.EnabledByDefault); + Assert.Equal(0.25f, medium.ResolutionScale); + Assert.Equal(0.50f, high.ResolutionScale); + Assert.True(low.RayMarchSteps < medium.RayMarchSteps); + Assert.True(medium.RayMarchSteps < high.RayMarchSteps); + } + + [Fact] + public void Clear_raking_sun_is_stronger_than_noon_and_overcast() + { + VolumetricShaftQuality quality = + VolumetricShaftQuality.For(DirectionalShadowPreset.High); + DirectionalShadowEnvironmentState raking = Enabled(elevationSin: 0.20f); + DirectionalShadowEnvironmentState noon = Enabled(elevationSin: 0.95f); + + VolumetricShaftFrameParameters clear = VolumetricShaftPolicy.Evaluate( + in quality, + userEnabled: true, + in raking, + WeatherKind.Clear, + Vector3.One, + 1f); + VolumetricShaftFrameParameters highSun = VolumetricShaftPolicy.Evaluate( + in quality, + userEnabled: true, + in noon, + WeatherKind.Clear, + Vector3.One, + 1f); + VolumetricShaftFrameParameters overcast = VolumetricShaftPolicy.Evaluate( + in quality, + userEnabled: true, + in raking, + WeatherKind.Overcast, + Vector3.One, + 1f); + + Assert.True(clear.Enabled); + Assert.True(clear.Strength > highSun.Strength); + Assert.True(clear.Strength > overcast.Strength); + } + + [Fact] + public void Missing_shadow_indoor_or_user_off_cannot_leave_shafts_enabled() + { + VolumetricShaftQuality quality = + VolumetricShaftQuality.For(DirectionalShadowPreset.Medium); + DirectionalShadowEnvironmentState indoor = new( + DirectionalShadowGateReason.Indoor, + Vector3.UnitZ, + 0.5f, + 0f, + 1f); + DirectionalShadowEnvironmentState outdoor = Enabled(0.2f); + + Assert.False(VolumetricShaftPolicy.Evaluate( + in quality, true, in indoor, WeatherKind.Clear, Vector3.One, 1f).Enabled); + Assert.False(VolumetricShaftPolicy.Evaluate( + in quality, false, in outdoor, WeatherKind.Clear, Vector3.One, 1f).Enabled); + } + + [Fact] + public void Moon_shadow_source_never_manufactures_sun_shafts() + { + VolumetricShaftQuality quality = + VolumetricShaftQuality.For(DirectionalShadowPreset.High); + DirectionalShadowEnvironmentState moon = Enabled(0.35f) with + { + SourceKind = AuthoredCelestialShadowSourceKind.DominantMoon, + }; + + VolumetricShaftFrameParameters result = VolumetricShaftPolicy.Evaluate( + in quality, + userEnabled: true, + in moon, + WeatherKind.Clear, + Vector3.One, + authoredSunBrightness: 1f); + + Assert.False(result.Enabled); + } + + private static DirectionalShadowEnvironmentState Enabled(float elevationSin) => new( + DirectionalShadowGateReason.Enabled, + Vector3.Normalize(new Vector3(0.5f, 0.5f, elevationSin)), + elevationSin, + Strength: 1f, + SoftnessMultiplier: 1f, + SourceKind: AuthoredCelestialShadowSourceKind.Sun); +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowPreparedDrawTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowPreparedDrawTests.cs new file mode 100644 index 00000000..6dcdd461 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowPreparedDrawTests.cs @@ -0,0 +1,501 @@ +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Scene; +using AcDream.App.Rendering.Wb; +using AcDream.Core.Meshing; +using AcDream.Core.World; +using DatReaderWriter.Enums; + +namespace AcDream.App.Tests.Rendering.Wb; + +public sealed class DirectionalShadowPreparedDrawTests +{ + [Theory] + [InlineData(TranslucencyKind.Opaque, true, DirectionalShadowCasterMaterial.Opaque)] + [InlineData(TranslucencyKind.ClipMap, true, DirectionalShadowCasterMaterial.AlphaCutout)] + [InlineData(TranslucencyKind.AlphaBlend, false, DirectionalShadowCasterMaterial.Opaque)] + [InlineData(TranslucencyKind.Additive, false, DirectionalShadowCasterMaterial.Opaque)] + [InlineData(TranslucencyKind.InvAlpha, false, DirectionalShadowCasterMaterial.Opaque)] + internal void MaterialPolicy_PreservesCutoutsAndExcludesTrueTransparency( + TranslucencyKind source, + bool expectedAccepted, + DirectionalShadowCasterMaterial expectedMaterial) + { + bool accepted = DirectionalShadowPreparedDraws.TryClassifyMaterial( + source, + out DirectionalShadowCasterMaterial material); + + Assert.Equal(expectedAccepted, accepted); + if (accepted) + Assert.Equal(expectedMaterial, material); + } + + [Theory] + [InlineData(0f, false)] + [InlineData(0.0001f, true)] + [InlineData(1f, true)] + [InlineData(float.NaN, true)] + internal void FadePolicy_OnlyExactOpaquePartsCast( + float translucency, + bool excluded) + { + Assert.Equal( + excluded, + DirectionalShadowPreparedDraws.FadeExcludesCaster(translucency)); + } + + [Fact] + public void Complete_GroupsCommandsAndRetainsExactCurrentTransforms() + { + var product = new DirectionalShadowPreparedDraws(); + RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(4); + Assert.True(product.TryBegin(generation, 7, estimatedInstances: 3)); + Matrix4x4 first = Matrix4x4.CreateRotationZ(0.25f) + * Matrix4x4.CreateTranslation(10f, 20f, 30f); + Matrix4x4 second = Matrix4x4.CreateScale(1.25f) + * Matrix4x4.CreateTranslation(-2f, 3f, 7f); + Matrix4x4 leaves = Matrix4x4.CreateRotationX(0.5f) + * Matrix4x4.CreateTranslation(100f, 200f, 12f); + + product.Add( + firstIndex: 40, + baseVertex: 3, + indexCount: 18, + GpuTextureSlot.Unassigned, + textureLayer: 0, + CullMode.CounterClockwise, + DirectionalShadowCasterMaterial.Opaque, + in first); + product.Add( + firstIndex: 40, + baseVertex: 3, + indexCount: 18, + GpuTextureSlot.Unassigned, + textureLayer: 0, + CullMode.CounterClockwise, + DirectionalShadowCasterMaterial.Opaque, + in second); + product.Add( + firstIndex: 90, + baseVertex: 11, + indexCount: 24, + new GpuTextureSlot(17), + textureLayer: 6, + CullMode.None, + DirectionalShadowCasterMaterial.AlphaCutout, + in leaves); + var inputStats = new DirectionalShadowPreparationStats( + SourceCasters: 2, + SourceMeshRefs: 3, + SourceParts: 3, + SourceBatches: 5, + PreparedInstances: 0, + PreparedOpaqueCommands: 0, + PreparedAlphaCutoutCommands: 0, + RejectedTransparentBatches: 2, + RejectedFadedParts: 1, + MissingMeshes: 0, + UnresolvedAlphaCutoutTextures: 0); + + product.Complete(generation, 7, in inputStats); + + Assert.Equal(3, product.Transforms.Length); + Assert.Equal(2, product.Commands.Length); + Assert.Equal(1, product.OpaqueCommandCount); + Assert.Equal(1, product.AlphaCutoutCommandCount); + Assert.Single(product.OpaqueCommands.ToArray()); + Assert.Single(product.AlphaCutoutCommands.ToArray()); + Assert.Single(product.OpaqueBatches.ToArray()); + Assert.Single(product.AlphaCutoutBatches.ToArray()); + Assert.Single(product.OpaqueRuns.ToArray()); + Assert.Single(product.AlphaCutoutRuns.ToArray()); + Assert.Equal(0, product.OpaqueRuns[0].StartCommand); + Assert.Equal(1, product.AlphaCutoutRuns[0].StartCommand); + Assert.Equal(2u, product.Commands[0].InstanceCount); + Assert.Equal(0u, product.Commands[0].BaseInstance); + Assert.Equal(1u, product.Commands[1].InstanceCount); + Assert.Equal(2u, product.Commands[1].BaseInstance); + Assert.Equal(DirectionalShadowCasterMaterial.Opaque, product.Batches[0].Material); + Assert.False(product.Batches[0].TextureSlot.IsAssigned); + Assert.Equal(DirectionalShadowCasterMaterial.AlphaCutout, product.Batches[1].Material); + Assert.Equal(17u, product.Batches[1].TextureSlot.Index); + Assert.Equal(6u, product.Batches[1].TextureLayer); + Assert.Contains(first, product.Transforms.ToArray()); + Assert.Contains(second, product.Transforms.ToArray()); + Assert.Equal(leaves, product.Transforms[2]); + Assert.Equal(3, product.Stats.PreparedInstances); + Assert.Equal(2, product.Stats.RejectedTransparentBatches); + Assert.Equal(1, product.Stats.RejectedFadedParts); + } + + [Fact] + public void SameCasterBuild_ReplaysWithoutReclassificationOrStorageGrowth() + { + var product = new DirectionalShadowPreparedDraws(); + RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(8); + Matrix4x4 exactMeshRefTransform = new( + 1, 2, 3, 4, + 5, 6, 7, 8, + 9, 10, 11, 12, + 13, 14, 15, 16); + Assert.True(product.TryBegin(generation, 20, estimatedInstances: 1)); + product.Add( + 1, + 2, + 3, + GpuTextureSlot.Unassigned, + 0, + CullMode.Clockwise, + DirectionalShadowCasterMaterial.Opaque, + in exactMeshRefTransform); + DirectionalShadowPreparationStats stats = default; + product.Complete(generation, 20, in stats); + long retained = product.RetainedScratchBytes; + ulong buildSequence = product.BuildSequence; + + Assert.False(product.TryBegin(generation, 20, estimatedInstances: 100)); + + Assert.Equal(buildSequence, product.BuildSequence); + Assert.Equal(retained, product.RetainedScratchBytes); + Assert.Equal(exactMeshRefTransform, product.Transforms[0]); + Assert.Single(product.Commands.ToArray()); + } + + [Fact] + public void StableTopology_ComposesSlimPoseWithoutMutatingCasterOrAllocating() + { + RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(10); + Matrix4x4 originalRoot = Matrix4x4.CreateTranslation(1f, 2f, 3f); + Matrix4x4 originalPart = Matrix4x4.CreateTranslation(4f, 5f, 6f); + Matrix4x4 setupPart = Matrix4x4.CreateRotationX(0.25f) + * Matrix4x4.CreateTranslation(7f, 8f, 9f); + DirectionalShadowCaster[] casters = + [ + new DirectionalShadowCaster( + Projection(originalRoot, originalPart), + DirectionalShadowCasterKind.LiveDynamic), + ]; + var product = new DirectionalShadowPreparedDraws(); + Assert.True(product.TryBegin( + generation, + casterBuildSequence: 12, + estimatedInstances: 2, + renderDataAvailabilityVersion: 5, + translucencyFadeRevision: 7)); + product.MapCasterIdentity( + 0, + casters[0].Projection.Id, + casters[0].Projection.ProjectionClass); + DirectionalShadowTransformSource direct = + DirectionalShadowTransformSource.Dynamic( + casterIndex: 0, + meshIndex: 0, + isSetupPart: false, + in setupPart); + DirectionalShadowTransformSource setup = + DirectionalShadowTransformSource.Dynamic( + casterIndex: 0, + meshIndex: 0, + isSetupPart: true, + in setupPart); + Matrix4x4 originalDirect = originalPart * originalRoot; + Matrix4x4 originalSetup = setupPart * originalPart * originalRoot; + product.Add( + 10, + 0, + 3, + GpuTextureSlot.Unassigned, + 0, + CullMode.Clockwise, + DirectionalShadowCasterMaterial.Opaque, + in originalDirect, + in direct); + product.Add( + 20, + 0, + 3, + GpuTextureSlot.Unassigned, + 0, + CullMode.Clockwise, + DirectionalShadowCasterMaterial.Opaque, + in originalSetup, + in setup); + DirectionalShadowPreparationStats stats = default; + product.Complete( + generation, + 12, + in stats, + renderDataAvailabilityVersion: 5, + translucencyFadeRevision: 7); + ulong topologyBuild = product.BuildSequence; + + float exactRootX = BitConverter.Int32BitsToSingle(0x41234567); + float exactPartY = BitConverter.Int32BitsToSingle(0x40ABCDEF); + Matrix4x4 currentRoot = Matrix4x4.CreateRotationZ(0.15f) + * Matrix4x4.CreateTranslation(exactRootX, 12f, 13f); + Matrix4x4 currentPart = Matrix4x4.CreateRotationY(0.35f) + * Matrix4x4.CreateTranslation(14f, exactPartY, 16f); + RenderProjectionRecord current = Projection(currentRoot, currentPart); + DirectionalShadowTransformSnapshot snapshot = + DirectionalShadowTransformSnapshot.Capture(in current); + DirectionalShadowChangedPose[] changed = [new(0, snapshot)]; + + product.RefreshDynamicTransforms(changed); + + AssertMatrixBitsEqual(currentPart * currentRoot, product.Transforms[0]); + AssertMatrixBitsEqual( + setupPart * currentPart * currentRoot, + product.Transforms[1]); + Assert.Equal(topologyBuild, product.BuildSequence); + Assert.Equal(2, product.LastDynamicTransformRefreshCount); + Assert.False(product.RequiresTopologyBuild(generation, 12, 5, 7)); + AssertMatrixBitsEqual( + originalRoot, + casters[0].Projection.Transform.LocalToWorld); + AssertMatrixBitsEqual( + originalPart, + casters[0].Projection.EntityPayload.MeshRefs[0].PartTransform); + + product.RefreshDynamicTransforms( + ReadOnlySpan.Empty); + Assert.Empty(product.DynamicTransformSlots.ToArray()); + Assert.Equal(0, product.LastDynamicTransformRefreshCount); + AssertMatrixBitsEqual(currentPart * currentRoot, product.Transforms[0]); + AssertMatrixBitsEqual( + setupPart * currentPart * currentRoot, + product.Transforms[1]); + + product.RefreshDynamicTransforms(changed, denseRefresh: true); + Assert.True(product.LastDynamicTransformRefreshWasDense); + Assert.Equal([0, 1], product.DynamicTransformSlots.ToArray()); + + product.RefreshDynamicTransforms(changed); + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int iteration = 0; iteration < 256; iteration++) + product.RefreshDynamicTransforms(changed); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.Equal(0, allocated); + + RenderProjectionRecord wrongId = current with + { + Id = RenderProjectionId.FromRaw(99), + }; + DirectionalShadowTransformSnapshot wrongSnapshot = + DirectionalShadowTransformSnapshot.Capture(in wrongId); + DirectionalShadowChangedPose[] wrongIdentity = [new(0, wrongSnapshot)]; + InvalidOperationException identityFailure = Assert.Throws< + InvalidOperationException>( + () => product.RefreshDynamicTransforms(wrongIdentity)); + Assert.Contains("does not match", identityFailure.Message); + + DirectionalShadowChangedPose[] staleSlot = [new(1, snapshot)]; + InvalidOperationException slotFailure = Assert.Throws< + InvalidOperationException>( + () => product.RefreshDynamicTransforms(staleSlot)); + Assert.Contains("stale or unmapped caster index", slotFailure.Message); + } + + [Fact] + public void StableTopology_MapsChangedCasterToOnlyItsRetainedTransformSlots() + { + RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(12); + Matrix4x4 firstRoot = Matrix4x4.CreateTranslation(1f, 2f, 3f); + Matrix4x4 secondRoot = Matrix4x4.CreateTranslation(4f, 5f, 6f); + Matrix4x4 firstPart = Matrix4x4.CreateTranslation(7f, 8f, 9f); + Matrix4x4 secondPart = Matrix4x4.CreateTranslation(10f, 11f, 12f); + DirectionalShadowCaster[] casters = + [ + new DirectionalShadowCaster( + Projection(firstRoot, firstPart), + DirectionalShadowCasterKind.LiveDynamic), + new DirectionalShadowCaster( + Projection(secondRoot, secondPart) with + { + Id = RenderProjectionId.FromRaw(2), + }, + DirectionalShadowCasterKind.EquippedChild), + ]; + var product = new DirectionalShadowPreparedDraws(); + Assert.True(product.TryBegin(generation, 13, estimatedInstances: 2)); + product.MapCasterIdentity( + 0, + casters[0].Projection.Id, + casters[0].Projection.ProjectionClass); + product.MapCasterIdentity( + 1, + casters[1].Projection.Id, + casters[1].Projection.ProjectionClass); + Matrix4x4 setupPart = Matrix4x4.Identity; + DirectionalShadowTransformSource firstSource = + DirectionalShadowTransformSource.Dynamic( + 0, + 0, + false, + in setupPart); + DirectionalShadowTransformSource secondSource = + DirectionalShadowTransformSource.Dynamic( + 1, + 0, + false, + in setupPart); + Matrix4x4 firstWorld = firstPart * firstRoot; + Matrix4x4 secondWorld = secondPart * secondRoot; + product.Add( + 1, 0, 3, GpuTextureSlot.Unassigned, 0, + CullMode.Clockwise, + DirectionalShadowCasterMaterial.Opaque, + in firstWorld, + in firstSource); + product.Add( + 2, 0, 3, GpuTextureSlot.Unassigned, 0, + CullMode.Clockwise, + DirectionalShadowCasterMaterial.Opaque, + in secondWorld, + in secondSource); + DirectionalShadowPreparationStats stats = default; + product.Complete(generation, 13, in stats); + + Matrix4x4 movedRoot = Matrix4x4.CreateTranslation(40f, 50f, 60f); + Matrix4x4 movedPart = Matrix4x4.CreateTranslation(70f, 80f, 90f); + casters[1] = casters[1] with + { + Projection = Projection(movedRoot, movedPart) with + { + Id = RenderProjectionId.FromRaw(2), + }, + }; + product.RefreshDynamicTransforms(casters, [1]); + + Assert.Equal([0, 1], product.AllDynamicTransformSlots.ToArray()); + Assert.Equal([1], product.DynamicTransformSlots.ToArray()); + AssertMatrixBitsEqual(firstWorld, product.Transforms[0]); + AssertMatrixBitsEqual(movedPart * movedRoot, product.Transforms[1]); + Assert.Equal(1, product.LastDynamicTransformRefreshCount); + + product.RefreshDenseDynamicTransforms(casters); + Assert.True(product.LastDynamicTransformRefreshWasDense); + Assert.Equal([0, 1], product.DynamicTransformSlots.ToArray()); + Assert.Equal(2, product.LastDynamicTransformRefreshCount); + AssertMatrixBitsEqual(firstWorld, product.Transforms[0]); + AssertMatrixBitsEqual(movedPart * movedRoot, product.Transforms[1]); + + Matrix4x4 denseFirstRoot = Matrix4x4.CreateTranslation(100f, 101f, 102f); + Matrix4x4 denseFirstPart = Matrix4x4.CreateTranslation(103f, 104f, 105f); + casters[0] = casters[0] with + { + Projection = Projection(denseFirstRoot, denseFirstPart), + }; + RenderProjectionRecord firstProjection = casters[0].Projection; + RenderProjectionRecord secondProjection = casters[1].Projection; + DirectionalShadowTransformSnapshot firstPose = + DirectionalShadowTransformSnapshot.Capture(in firstProjection); + DirectionalShadowTransformSnapshot secondPose = + DirectionalShadowTransformSnapshot.Capture(in secondProjection); + DirectionalShadowChangedPose[] reversedDenseChanges = + [ + new(1, secondPose), + new(0, firstPose), + ]; + + product.RefreshDynamicTransforms(reversedDenseChanges, denseRefresh: true); + + Assert.True(product.LastDynamicTransformRefreshWasDense); + Assert.Equal([0, 1], product.DynamicTransformSlots.ToArray()); + Assert.Equal(2, product.LastDynamicTransformRefreshCount); + AssertMatrixBitsEqual( + denseFirstPart * denseFirstRoot, + product.Transforms[0]); + AssertMatrixBitsEqual(movedPart * movedRoot, product.Transforms[1]); + + long beforeDense = GC.GetAllocatedBytesForCurrentThread(); + for (int iteration = 0; iteration < 256; iteration++) + { + product.RefreshDynamicTransforms( + reversedDenseChanges, + denseRefresh: true); + } + long denseAllocated = + GC.GetAllocatedBytesForCurrentThread() - beforeDense; + Assert.Equal(0, denseAllocated); + + Assert.True(product.TryBegin(generation, 14, estimatedInstances: 0)); + product.Complete(generation, 14, in stats); + product.RefreshDynamicTransforms(casters, [0]); + Assert.Empty(product.DynamicTransformSlots.ToArray()); + Assert.Equal(0, product.LastDynamicTransformRefreshCount); + } + + [Fact] + public void ResourceAvailabilityFadeAndPendingTextureInvalidateRetainedTopology() + { + RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(11); + var product = new DirectionalShadowPreparedDraws(); + Assert.True(product.TryBegin(generation, 2, 0, 20, 30)); + var pending = new DirectionalShadowPreparationStats( + SourceCasters: 1, + SourceMeshRefs: 1, + SourceParts: 1, + SourceBatches: 1, + PreparedInstances: 0, + PreparedOpaqueCommands: 0, + PreparedAlphaCutoutCommands: 0, + RejectedTransparentBatches: 0, + RejectedFadedParts: 0, + MissingMeshes: 1, + UnresolvedAlphaCutoutTextures: 0); + product.Complete(generation, 2, in pending, 20, 30); + + Assert.False(product.RequiresTopologyBuild(generation, 2, 20, 30)); + Assert.True(product.RequiresTopologyBuild(generation, 2, 21, 30)); + Assert.True(product.RequiresTopologyBuild(generation, 2, 20, 31)); + + Assert.True(product.TryBegin(generation, 2, 0, 21, 30)); + pending = pending with { UnresolvedAlphaCutoutTextures = 1 }; + product.Complete(generation, 2, in pending, 21, 30); + Assert.True(product.RequiresTopologyBuild(generation, 2, 21, 30)); + } + + [Fact] + public void SetupComposition_UsesPublishedMeshRefTransformWithoutAnotherPose() + { + Matrix4x4 root = Matrix4x4.CreateRotationZ(0.1f) + * Matrix4x4.CreateTranslation(50f, 60f, 70f); + Matrix4x4 currentMeshRef = Matrix4x4.CreateRotationY(0.2f) + * Matrix4x4.CreateTranslation(4f, 5f, 6f); + Matrix4x4 authoredSetupPart = Matrix4x4.CreateRotationX(0.3f) + * Matrix4x4.CreateTranslation(1f, 2f, 3f); + + Matrix4x4 actual = WbDrawDispatcher.ComposePartWorldMatrix( + root, + currentMeshRef, + authoredSetupPart); + + Assert.Equal(authoredSetupPart * currentMeshRef * root, actual); + } + + private static RenderProjectionRecord Projection( + in Matrix4x4 root, + in Matrix4x4 part) => + new RenderProjectionRecord() with + { + Id = RenderProjectionId.FromRaw(1), + ProjectionClass = RenderProjectionClass.LiveDynamicRoot, + Transform = new RenderTransform(root), + EntityPayload = new RenderEntityPayload( + [new MeshRef(0x01000001, part)], + PaletteOverride: null, + IsBuildingShell: false), + }; + + private static void AssertMatrixBitsEqual( + Matrix4x4 expected, + Matrix4x4 actual) + { + ReadOnlySpan expectedBits = MemoryMarshal.AsBytes( + MemoryMarshal.CreateReadOnlySpan(ref expected, 1)); + ReadOnlySpan actualBits = MemoryMarshal.AsBytes( + MemoryMarshal.CreateReadOnlySpan(ref actual, 1)); + Assert.True(expectedBits.SequenceEqual(actualBits)); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowTerrainPreparedDrawTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowTerrainPreparedDrawTests.cs new file mode 100644 index 00000000..aaa88e2c --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowTerrainPreparedDrawTests.cs @@ -0,0 +1,75 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering.Wb; + +public sealed class DirectionalShadowTerrainPreparedDrawTests +{ + [Fact] + public void Product_ContainsEveryResidentRangeWithoutVisibilityClassification() + { + var product = new DirectionalShadowTerrainPreparedDraws(); + Assert.True(product.TryBegin(frameSequence: 10, estimatedCommands: 3)); + DirectionalShadowTerrainRange[] resident = + [ + new(FirstIndex: 0, IndexCount: 384), + new(FirstIndex: 384, IndexCount: 384), + new(FirstIndex: 768, IndexCount: 384), + ]; + foreach (DirectionalShadowTerrainRange range in resident) + product.Add(in range); + + product.Complete(frameSequence: 10); + + Assert.Equal(3, product.Commands.Length); + Assert.Equal(0u, product.Commands[0].FirstIndex); + Assert.Equal(384u, product.Commands[1].FirstIndex); + Assert.Equal(768u, product.Commands[2].FirstIndex); + Assert.All( + product.Commands.ToArray(), + static command => + { + Assert.Equal(384u, command.Count); + Assert.Equal(1u, command.InstanceCount); + Assert.Equal(0, command.BaseVertex); + }); + } + + [Fact] + public void SameFrame_AllCascadesReuseOneTerrainBuild() + { + var product = new DirectionalShadowTerrainPreparedDraws(); + Assert.True(product.TryBegin(frameSequence: 1, estimatedCommands: 1)); + var range = new DirectionalShadowTerrainRange(100, 24); + product.Add(in range); + product.Complete(frameSequence: 1); + long retained = product.RetainedScratchBytes; + + Assert.False(product.TryBegin(frameSequence: 1, estimatedCommands: 500)); + + Assert.Equal(1ul, product.BuildSequence); + Assert.Equal(retained, product.RetainedScratchBytes); + Assert.Single(product.Commands.ToArray()); + } + + [Fact] + public void NextFrame_RebuildsIntoRetainedStorage() + { + var product = new DirectionalShadowTerrainPreparedDraws(); + Assert.True(product.TryBegin(frameSequence: 1, estimatedCommands: 2)); + var first = new DirectionalShadowTerrainRange(10, 3); + var second = new DirectionalShadowTerrainRange(20, 6); + product.Add(in first); + product.Add(in second); + product.Complete(frameSequence: 1); + long retained = product.RetainedScratchBytes; + + Assert.True(product.TryBegin(frameSequence: 2, estimatedCommands: 1)); + product.Add(in second); + product.Complete(frameSequence: 2); + + Assert.Equal(2ul, product.BuildSequence); + Assert.Equal(retained, product.RetainedScratchBytes); + Assert.Single(product.Commands.ToArray()); + Assert.Equal(20u, product.Commands[0].FirstIndex); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs index d7626792..d2ea6e24 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs @@ -73,6 +73,62 @@ public class EnvCellRendererTests new WbFrustum()); } + [Fact] + public void EnvironmentDetailCategory_BindsValidStorageDescriptorNine() + { + using var device = new RecordingGpuDevice(); + device.Clear(); + + using IGpuFrame frame = device.BeginFrame(); + using IGpuPassEncoder pass = frame.BeginPass( + GpuPassDescription.BackbufferClear( + "envcell-detail-binding", + Vector4.Zero, + sampleCount: 1)); + + EnvCellRenderer.BindEnvironmentDetailCategory(pass, frame); + + GpuRecordedStorageBind storageBind = Assert.Single( + device.Calls.OfType()); + Assert.Equal(GpuBindingModel.StorageInstanceDetailCategory, storageBind.Binding); + Assert.Equal((uint)sizeof(uint), storageBind.SizeBytes); + Assert.Equal( + 1u, + MemoryMarshal.Read( + device.RingBytes.Slice((int)storageBind.OffsetBytes, sizeof(uint)))); + } + + [Fact] + public void RetailDetailPipelinesPreserveOpaqueAndTransparentDepthWriteContracts() + { + using var device = new RecordingGpuDevice(); + using var meshManager = CreateMeshManager(device); + using var renderer = new EnvCellRenderer( + device, + new GpuDeviceFrameLifetime(device), + new VulkanWorldPassScope(sampleCount: 1), + meshManager, + new WbFrustum()); + + GpuPipelineDescription opaqueDetail = Assert.Single( + device.CreatedPipelines, + pipeline => pipeline.Description.Name == "envcell-retail-detail") + .Description; + GpuPipelineDescription transparentDetail = Assert.Single( + device.CreatedPipelines, + pipeline => pipeline.Description.Name == "envcell-retail-detail-alpha") + .Description; + + Assert.Equal(GpuBlendMode.RetailDetail, opaqueDetail.Blend); + Assert.True(opaqueDetail.Depth.Write); + Assert.Equal(GpuCompareOp.Equal, opaqueDetail.Depth.Compare); + Assert.False(opaqueDetail.AlphaToCoverage); + Assert.Equal(GpuBlendMode.RetailDetail, transparentDetail.Blend); + Assert.False(transparentDetail.Depth.Write); + Assert.Equal(GpuCompareOp.LessOrEqual, transparentDetail.Depth.Compare); + Assert.False(transparentDetail.AlphaToCoverage); + } + [Fact] public void OrderedMdiRanges_CoalesceAdjacentCellsWithIdenticalState() { diff --git a/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs index ae606e47..d015244a 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs @@ -169,6 +169,45 @@ public class InstanceGroupClearTests Assert.Equal(expected, actual); } + [Fact] + public void DispatcherFingerprint_IncludesRetailDetailCategory() + { + WbDrawDispatcher.InstanceGroup ordinary = MakeCompleteGroup( + textureSlot: 0xAA, + submissionOrder: 0); + WbDrawDispatcher.InstanceGroup building = MakeCompleteGroup( + textureSlot: 0xAA, + submissionOrder: 0); + building.DetailCategories[0] = 1u; + var scratch = new List(); + + CurrentRenderDispatcherSubmission ordinarySubmission = + WbDrawDispatcher.CreateDispatcherSubmission( + visibleInstanceCount: 1, + immediateInstanceCount: 0, + deferTransparent: true, + opaque: [], + transparent: [ordinary], + cameraWorldPosition: Vector3.Zero, + alphaScratch: scratch); + CurrentRenderDispatcherSubmission buildingSubmission = + WbDrawDispatcher.CreateDispatcherSubmission( + visibleInstanceCount: 1, + immediateInstanceCount: 0, + deferTransparent: true, + opaque: [], + transparent: [building], + cameraWorldPosition: Vector3.Zero, + alphaScratch: scratch); + + Assert.NotEqual( + ordinarySubmission.TransparentDigest, + buildingSubmission.TransparentDigest); + Assert.NotEqual( + ordinarySubmission.TransparentSetDigest, + buildingSubmission.TransparentSetDigest); + } + [Fact] public void CachedGroupHandle_RequiresLiveMatchingRegistration() { @@ -314,15 +353,16 @@ public class InstanceGroupClearTests group.Slots.Add(0u); group.LightSets.Add(WbDrawDispatcher.InstanceLightSet.Disabled); group.IndoorFlags.Add(0u); + group.DetailCategories.Add(0u); group.Opacities.Add(1f); group.SelectionLighting.Add(new Vector2(0f, 1f)); return group; } // #193 (regression from #188, 2026-07-09): WbDrawDispatcher's InstanceGroup holds - // eight per-instance parallel lists — Matrices, LocalSortCenters, + // nine per-instance parallel lists — Matrices, LocalSortCenters, // SubmissionOrders, Slots, LightSets, IndoorFlags, Opacities, and - // SelectionLighting — appended in lockstep + // DetailCategories, SelectionLighting — appended in lockstep // (one entry per drawn instance) every frame. The // per-frame reset must clear ALL of them. #188 added Opacities but left it out of // the inline clear loop, so it grew one float per instance per frame forever; as @@ -339,6 +379,7 @@ public class InstanceGroupClearTests grp.Slots.Add(1u); grp.LightSets.Add(WbDrawDispatcher.InstanceLightSet.Disabled); grp.IndoorFlags.Add(0u); + grp.DetailCategories.Add(1u); grp.Opacities.Add(1.0f); grp.SelectionLighting.Add(new Vector2(0f, 1f)); @@ -350,6 +391,7 @@ public class InstanceGroupClearTests Assert.Empty(grp.Slots); Assert.Empty(grp.LightSets); Assert.Empty(grp.IndoorFlags); + Assert.Empty(grp.DetailCategories); Assert.Empty(grp.Opacities); // #193 — the list that leaked Assert.Empty(grp.SelectionLighting); } diff --git a/tests/AcDream.App.Tests/Rendering/Wb/PackedDispatcherOracleTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/PackedDispatcherOracleTests.cs index 3fc4df8f..4e48e92a 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/PackedDispatcherOracleTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/PackedDispatcherOracleTests.cs @@ -1,9 +1,42 @@ using AcDream.App.Rendering.Wb; +using System.Numerics; namespace AcDream.App.Tests.Rendering.Wb; public sealed class PackedDispatcherOracleTests { + [Theory] + [InlineData(false, 0u)] + [InlineData(true, 1u)] + public void PackedInstanceWriter_AppendsDetailCategoryInParallel( + bool buildingDetail, + uint expectedCategory) + { + var group = new WbDrawDispatcher.InstanceGroup(); + + WbDrawDispatcher.AppendPackedInstance( + group, + Matrix4x4.Identity, + Vector3.One, + submissionOrder: 7, + slot: 3u, + lights: WbDrawDispatcher.InstanceLightSet.Disabled, + indoor: true, + buildingDetail: buildingDetail, + opacity: 0.5f, + selectionLighting: new Vector2(0.25f, 0.75f)); + + Assert.Single(group.Matrices); + Assert.Single(group.LocalSortCenters); + Assert.Single(group.SubmissionOrders); + Assert.Single(group.Slots); + Assert.Single(group.LightSets); + Assert.Single(group.IndoorFlags); + Assert.Equal(expectedCategory, Assert.Single(group.DetailCategories)); + Assert.Single(group.Opacities); + Assert.Single(group.SelectionLighting); + } + [Theory] [InlineData(0u, false, false)] [InlineData(0u, true, false)] diff --git a/tests/AcDream.App.Tests/Rendering/Wb/WorldTransformFrameArenaTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/WorldTransformFrameArenaTests.cs new file mode 100644 index 00000000..5e7530b0 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/WorldTransformFrameArenaTests.cs @@ -0,0 +1,225 @@ +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Wb; +using AcDream.App.Tests.Rendering.Gpu; + +namespace AcDream.App.Tests.Rendering.Wb; + +public sealed class WorldTransformFrameArenaTests +{ + [Fact] + public void ShadowPrefixAndOrdinaryWorldAppendShareExactBindingAndBaseInstanceSpace() + { + using var device = new RecordingGpuDevice(); + device.Clear(); + using IGpuFrame frame = device.BeginFrame(); + var arena = new WorldTransformFrameArena(); + Matrix4x4[] shadow = + [ + Matrix4x4.CreateTranslation(1f, 2f, 3f), + Matrix4x4.CreateRotationZ(0.4f), + ]; + Matrix4x4[] ordinary = + [ + Matrix4x4.CreateScale(2f), + Matrix4x4.CreateTranslation(9f, 8f, 7f), + ]; + + WorldTransformFrameSlice shadowSlice = arena.Begin(frame, shadow); + WorldTransformFrameSlice ordinarySlice = arena.Append(frame, ordinary); + + Assert.Same(shadowSlice.Buffer, ordinarySlice.Buffer); + Assert.Equal(shadowSlice.BaseOffsetBytes, ordinarySlice.BaseOffsetBytes); + Assert.Equal(shadowSlice.BindingSizeBytes, ordinarySlice.BindingSizeBytes); + Assert.Equal(0u, shadowSlice.FirstInstance); + Assert.Equal((uint)shadow.Length, ordinarySlice.FirstInstance); + Assert.Equal((uint)ordinary.Length, ordinarySlice.InstanceCount); + Assert.Equal(4u, arena.UsedInstances); + GpuRecordedRingAllocation allocation = Assert.Single( + device.OfKind()); + Assert.Equal(GpuRingUsage.Storage, allocation.Usage); + Assert.Equal( + checked((int)WorldTransformCapacityPolicy.InitialBindingSizeBytes), + allocation.ByteCount); + + ReadOnlySpan uploaded = MemoryMarshal.Cast( + device.RingBytes.Slice( + checked((int)shadowSlice.BaseOffsetBytes), + checked((shadow.Length + ordinary.Length) * 64))); + Assert.Equal(shadow[0], uploaded[0]); + Assert.Equal(shadow[1], uploaded[1]); + Assert.Equal(ordinary[0], uploaded[2]); + Assert.Equal(ordinary[1], uploaded[3]); + } + + [Fact] + public void CapacityPolicyGrowsPastTwiceTheFormerCeiling_AndHonorsDeviceLimit() + { + const uint moreThanTwiceFormerCapacity = 131_073u; + uint bytes = WorldTransformCapacityPolicy.ResolveBindingSizeBytes( + moreThanTwiceFormerCapacity, + WorldTransformCapacityPolicy.VulkanGuaranteedMaxStorageBufferRangeBytes); + + Assert.True(bytes > 8u * 1024u * 1024u); + Assert.True(bytes >= moreThanTwiceFormerCapacity * 64u); + NotSupportedException failure = Assert.Throws(() => + WorldTransformCapacityPolicy.ResolveBindingSizeBytes( + moreThanTwiceFormerCapacity, + 8u * 1024u * 1024u)); + Assert.Contains("fail safe", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void RetainedShadowPrefix_AndOrdinaryAppendUseTheExactSamePoseBuffer() + { + using var device = new RecordingGpuDevice(); + using IGpuBuffer retained = device.CreateBuffer(new GpuBufferDescription( + "retained-shadow-slot-0", + WorldTransformCapacityPolicy.InitialBindingSizeBytes, + GpuBufferUsage.Storage | GpuBufferUsage.TransferDestination, + GpuMemoryResidency.HostWritable)); + using IGpuFrame frame = device.BeginFrame(); + Matrix4x4[] shadow = + [ + Matrix4x4.CreateTranslation(1f, 2f, 3f), + Matrix4x4.CreateRotationZ(0.4f), + ]; + retained.Upload(0, MemoryMarshal.AsBytes(shadow.AsSpan())); + var shadowSlice = new WorldTransformFrameSlice( + frame.Serial, + retained, + BaseOffsetBytes: 0, + WorldTransformCapacityPolicy.InitialBindingSizeBytes, + FirstInstance: 0, + InstanceCount: 2); + var arena = new WorldTransformFrameArena(); + + WorldTransformFrameSlice published = arena.BeginRetained( + frame, + in shadowSlice); + Matrix4x4[] ordinary = [Matrix4x4.CreateTranslation(9f, 8f, 7f)]; + WorldTransformFrameSlice appended = arena.Append(frame, ordinary); + + Assert.Same(retained, published.Buffer); + Assert.Same(retained, appended.Buffer); + Assert.Equal(2u, appended.FirstInstance); + Assert.Equal(WorldTransformCapacityPolicy.InitialBindingSizeBytes, + appended.BindingSizeBytes); + Matrix4x4[] readback = new Matrix4x4[3]; + retained.Read(0, MemoryMarshal.AsBytes(readback.AsSpan())); + Assert.Equal(shadow[0], readback[0]); + Assert.Equal(shadow[1], readback[1]); + Assert.Equal(ordinary[0], readback[2]); + Assert.Empty(device.OfKind()); + } + + [Fact] + public void ConnectedDense68395CombinedMatricesRemainInOneAuthoritativeBinding() + { + const uint shadowPrefixInstances = 9_498u; + const int ordinaryInstances = 68_395 - (int)shadowPrefixInstances; + using var device = new RecordingGpuDevice(); + using IGpuBuffer retained = device.CreateBuffer(new GpuBufferDescription( + "connected-dense-retained-transform-arena", + WorldTransformCapacityPolicy.InitialBindingSizeBytes, + GpuBufferUsage.Storage | GpuBufferUsage.TransferDestination, + GpuMemoryResidency.HostWritable)); + using IGpuFrame frame = device.BeginFrame(); + var prefix = new WorldTransformFrameSlice( + frame.Serial, + retained, + BaseOffsetBytes: 0, + WorldTransformCapacityPolicy.InitialBindingSizeBytes, + FirstInstance: 0, + InstanceCount: shadowPrefixInstances); + var arena = new WorldTransformFrameArena(); + + arena.BeginRetained(frame, in prefix); + WorldTransformFrameSlice ordinary = arena.Append( + frame, + new Matrix4x4[ordinaryInstances]); + + Assert.Same(retained, ordinary.Buffer); + Assert.Equal(shadowPrefixInstances, ordinary.FirstInstance); + Assert.Equal(68_395u, arena.UsedInstances); + Assert.Equal(prefix.BindingSizeBytes, ordinary.BindingSizeBytes); + Assert.True(ordinary.IsValidFor(frame)); + Assert.Empty(device.OfKind()); + } + + [Fact] + public void AppendOverflowFailsSafeWithoutAllocatingASecondPoseBuffer() + { + using var device = new RecordingGpuDevice(); + device.Clear(); + using IGpuFrame frame = device.BeginFrame(); + var arena = new WorldTransformFrameArena(); + const uint bindingBytes = 4u * 64u; + var full = new Matrix4x4[4]; + arena.Begin(frame, full, bindingBytes); + + InvalidOperationException failure = Assert.Throws( + () => arena.Append(frame, [Matrix4x4.Identity])); + + Assert.Contains("fail safe", failure.Message, StringComparison.Ordinal); + Assert.True(arena.IsActiveFor(frame.Serial)); + Assert.Equal( + bindingBytes / WorldTransformCapacityPolicy.MatrixBytes, + arena.UsedInstances); + Assert.Single(device.OfKind()); + } + + [Fact] + public void CancelBeforePackRetirementAllowsSameFrameRetailRingPublication() + { + using var device = new RecordingGpuDevice(); + device.Clear(); + var retained = Assert.IsType( + device.CreateBuffer(new GpuBufferDescription( + "retained-shadow-slot-0", + WorldTransformCapacityPolicy.InitialBindingSizeBytes, + GpuBufferUsage.Storage | GpuBufferUsage.TransferDestination, + GpuMemoryResidency.HostWritable))); + using IGpuFrame frame = device.BeginFrame(); + var arena = new WorldTransformFrameArena(); + var retainedPrefix = new WorldTransformFrameSlice( + frame.Serial, + retained, + BaseOffsetBytes: 0, + WorldTransformCapacityPolicy.InitialBindingSizeBytes, + FirstInstance: 0, + InstanceCount: 1); + arena.BeginRetained(frame, in retainedPrefix); + + // This is the production late-budget-failure order: release the + // dispatcher's borrow first, then retire the active pack owner. + arena.Cancel(frame); + retained.Dispose(); + WorldTransformFrameSlice retail = arena.Begin( + frame, + [Matrix4x4.CreateTranslation(4f, 5f, 6f)]); + + Assert.True(retained.IsDisposed); + Assert.NotSame(retained, retail.Buffer); + Assert.Same(device.RingBuffer, retail.Buffer); + Assert.True(arena.IsActiveFor(frame.Serial)); + Assert.Single(device.OfKind()); + } + + [Fact] + public void FrameSerialChangeInvalidatesPriorPublication() + { + using var device = new RecordingGpuDevice(); + var arena = new WorldTransformFrameArena(); + using (IGpuFrame first = device.BeginFrame()) + arena.Begin(first, [Matrix4x4.Identity]); + + using IGpuFrame second = device.BeginFrame(); + InvalidOperationException failure = Assert.Throws( + () => arena.Append(second, [Matrix4x4.Identity])); + + Assert.Contains("has not been published", failure.Message, StringComparison.Ordinal); + Assert.False(arena.IsActive); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs index dd620f14..ecc5eefe 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs @@ -2,6 +2,7 @@ using System.Numerics; using System.Reflection; using AcDream.App.Composition; using AcDream.App.Rendering; +using AcDream.App.Rendering.Packs; using AcDream.App.Rendering.Selection; using AcDream.App.Rendering.Vfx; using AcDream.App.Streaming; @@ -15,6 +16,94 @@ namespace AcDream.App.Tests.Rendering; public sealed class WorldSceneRendererTests { + [Fact] + public void EnhancedPreparation_BuildsCanonicalWorldOnceBeforeExecution() + { + var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: null); + + PreparedWorldSceneFrame prepared = rig.Renderer.PrepareEnhanced(default); + WorldRenderFrameOutcome result = rig.Renderer.RenderPreparedEnhanced( + default, + in prepared); + + Assert.True(result.NormalWorldDrawn); + Assert.True(prepared.ShouldRender); + Assert.Equal(1, rig.Calls.Count(value => value == "frame:build")); + Assert.True(rig.Calls.IndexOf("frame:build") < rig.Calls.IndexOf("selection:begin")); + Assert.Equal(prepared.World.Camera.Frustum, rig.Selection.PreparedViewFrustum); + Assert.Equal(rig.Entities.ResidentWindow, prepared.World.ResidentStreamingWindow); + Assert.Equal((0, 0), rig.Entities.LastResidentWindowCenter); + } + + [Fact] + public void EnhancedPreparation_AttachesTheSelectedAuthoredCelestialSource() + { + var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: null); + rig.DayGroup.SkyObjects = + [ + new SkyObjectData + { + GfxObjId = AuthoredCelestialShadowSourceResolver.SunGfxObjId, + AuthoredSortCenter = Vector3.UnitX, + BeginTime = 0f, + EndTime = 0f, + BeginAngle = 90f, + EndAngle = 90f, + }, + ]; + + PreparedWorldSceneFrame prepared = rig.Renderer.PrepareEnhanced(default); + + Assert.True(prepared.ShouldRender); + Assert.Equal( + AuthoredCelestialShadowSourceKind.Sun, + prepared.World.CelestialShadowSource.Kind); + Assert.Equal(0, prepared.World.CelestialShadowSource.ObjectIndex); + Assert.Equal( + AuthoredCelestialShadowSourceResolver.SunGfxObjId, + prepared.World.CelestialShadowSource.GfxObjId); + Assert.InRange( + Vector3.Distance( + Vector3.UnitZ, + prepared.World.CelestialShadowSource.SurfaceToLightDirection), + 0f, + 1e-5f); + + rig.Renderer.CancelPreparedEnhanced(in prepared); + } + + [Fact] + public void EnhancedPreparation_SkippedWorldStillPublishesEmptySelectionFrame() + { + var rig = new Rig(portalVisible: true, waitingForLogin: false, clipRoot: null); + + PreparedWorldSceneFrame prepared = rig.Renderer.PrepareEnhanced(default); + WorldRenderFrameOutcome result = rig.Renderer.RenderPreparedEnhanced( + default, + in prepared); + + Assert.False(prepared.ShouldRender); + Assert.Equal(default, result); + Assert.Equal(["selection:begin", "selection:complete"], rig.Calls); + } + + [Fact] + public void CancelledEnhancedPrepass_DoesNotPoisonTheNextFrame() + { + var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: null); + PreparedWorldSceneFrame failed = rig.Renderer.PrepareEnhanced(default); + + // Models an exception in the shadow prepass before a world pass opens. + rig.Renderer.CancelPreparedEnhanced(in failed); + PreparedWorldSceneFrame recovered = rig.Renderer.PrepareEnhanced(default); + WorldRenderFrameOutcome result = rig.Renderer.RenderPreparedEnhanced( + default, + in recovered); + + Assert.True(result.NormalWorldDrawn); + Assert.Equal(2, rig.Calls.Count(value => value == "frame:build")); + } + [Fact] public void PortalViewport_PublishesEmptySelectionFrameAndSkipsWorldOwners() { @@ -445,20 +534,21 @@ public sealed class WorldSceneRendererTests clipRoot, playerSeenOutside ?? clipRoot is not null); Frames = new FrameBuilder(Calls, frame); - var selection = new SelectionFrame(Calls); + Selection = new SelectionFrame(Calls); var alpha = new AlphaFrame(Calls); var visibility = new ParticleVisibility(Calls); PView = new PViewRenderer(Calls); Passes = new PassExecutor(Calls); var diagnostics = new Diagnostics(Calls); + Entities = new EntitySource(); Renderer = new WorldSceneRenderer( foundation, login, sky, Frames, - new EntitySource(), - selection, + Entities, + Selection, alpha, visibility, PView, @@ -479,6 +569,10 @@ public sealed class WorldSceneRendererTests public FrameBuilder Frames { get; } + public EntitySource Entities { get; } + + public SelectionFrame Selection { get; } + public PViewRenderer PView { get; } public PassExecutor Passes { get; } @@ -535,6 +629,16 @@ public sealed class WorldSceneRendererTests private sealed class EntitySource : IWorldSceneEntitySource { + public ResidentStreamingWindowFact ResidentWindow { get; } = new( + Revision: 77, + CenterX: 0, + CenterY: 0, + CompleteRadiusLandblocks: 2, + PublishedLandblockCount: 25, + HasPublishedCenter: true); + + public (int X, int Y)? LastResidentWindowCenter { get; private set; } + public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities, IReadOnlyDictionary? AnimatedById)> LandblockEntries => @@ -543,11 +647,25 @@ public sealed class WorldSceneRendererTests public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds => Array.Empty<(uint, Vector3, Vector3)>(); + + public ResidentStreamingWindowFact CaptureResidentStreamingWindow( + int centerX, + int centerY) + { + LastResidentWindowCenter = (centerX, centerY); + return ResidentWindow; + } } private sealed class SelectionFrame(List calls) : IWorldSceneSelectionFrame { - public void BeginFrame() => calls.Add("selection:begin"); + public FrustumPlanes? PreparedViewFrustum { get; private set; } + + public void BeginFrame(FrustumPlanes? preparedViewFrustum = null) + { + PreparedViewFrustum = preparedViewFrustum; + calls.Add("selection:begin"); + } public void CompleteFrame() => calls.Add("selection:complete"); diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index 2fdab9a6..2a6d7776 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -166,6 +166,7 @@ public sealed class RuntimeOptionsTests Assert.False(opts.UiProbeDump); Assert.Null(opts.UiProbeScript); Assert.Null(opts.AutomationArtifactDirectory); + Assert.False(opts.ExactAutomationFramebuffer); Assert.Equal(0.7f, opts.FogStartMultiplier); Assert.Equal(0.95f, opts.FogEndMultiplier); Assert.False(opts.UiProbeEnabled); @@ -411,6 +412,81 @@ public sealed class RuntimeOptionsTests } } + [Fact] + public void ExactAutomationFramebuffer_IsExplicitAndExactOneOnly() + { + Assert.True(RuntimeOptions.Parse( + AnyDatDir, + Env(new() { ["ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER"] = "1" })) + .ExactAutomationFramebuffer); + + foreach (string value in new[] { "", "0", "true", "yes" }) + { + Assert.False(RuntimeOptions.Parse( + AnyDatDir, + Env(new() { ["ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER"] = value })) + .ExactAutomationFramebuffer); + } + } + + [Fact] + public void OrbitDistanceOverride_AcceptsOnlyPositiveFiniteMeters() + { + Assert.Null( + RuntimeOptions.Parse(AnyDatDir, EmptyEnv()) + .InitialOrbitDistanceMeters); + Assert.Equal( + 120f, + RuntimeOptions.Parse( + AnyDatDir, + Env(new() { ["ACDREAM_ORBIT_DISTANCE_METERS"] = "120" })) + .InitialOrbitDistanceMeters); + + foreach (string rejected in new[] { "0", "-1", "NaN", "Infinity", "near" }) + { + Assert.Null( + RuntimeOptions.Parse( + AnyDatDir, + Env(new() { ["ACDREAM_ORBIT_DISTANCE_METERS"] = rejected })) + .InitialOrbitDistanceMeters); + } + } + + [Fact] + public void OrbitAngleOverrides_AcceptFiniteYawAndBoundedPitchDegrees() + { + RuntimeOptions defaults = RuntimeOptions.Parse(AnyDatDir, EmptyEnv()); + Assert.Null(defaults.InitialOrbitYawDegrees); + Assert.Null(defaults.InitialOrbitPitchDegrees); + + RuntimeOptions parsed = RuntimeOptions.Parse( + AnyDatDir, + Env(new() + { + ["ACDREAM_ORBIT_YAW_DEGREES"] = "-135.5", + ["ACDREAM_ORBIT_PITCH_DEGREES"] = "7.25", + })); + Assert.Equal(-135.5f, parsed.InitialOrbitYawDegrees); + Assert.Equal(7.25f, parsed.InitialOrbitPitchDegrees); + + foreach (string rejected in new[] { "NaN", "Infinity", "angle" }) + { + Assert.Null( + RuntimeOptions.Parse( + AnyDatDir, + Env(new() { ["ACDREAM_ORBIT_YAW_DEGREES"] = rejected })) + .InitialOrbitYawDegrees); + } + foreach (string rejected in new[] { "-90", "90", "NaN", "pitch" }) + { + Assert.Null( + RuntimeOptions.Parse( + AnyDatDir, + Env(new() { ["ACDREAM_ORBIT_PITCH_DEGREES"] = rejected })) + .InitialOrbitPitchDegrees); + } + } + /// /// Campaign V slice V7. The sky has two clocks and this pins the one /// ACDREAM_DAY_GROUP cannot reach — the cloud sheet's UV scroll, which diff --git a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs index cfdf8f7c..dfb1984a 100644 --- a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs +++ b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs @@ -298,12 +298,13 @@ public sealed class RuntimeSettingsControllerTests var target = new SilkRuntimeDisplayWindowTarget( surface, switcher, _ => false); - target.Apply(DisplaySettings.Default with + RuntimeDisplayApplyResult result = target.Apply(DisplaySettings.Default with { Fullscreen = true, Resolution = "1234x777", }); + Assert.False(result.Fullscreen); Assert.Empty(switcher.Calls); Assert.Equal(0, surface.Writes); } @@ -318,12 +319,13 @@ public sealed class RuntimeSettingsControllerTests var target = new SilkRuntimeDisplayWindowTarget( surface, switcher, _ => true); - target.Apply(DisplaySettings.Default with + RuntimeDisplayApplyResult result = target.Apply(DisplaySettings.Default with { Fullscreen = true, Resolution = "1920x1080", }); + Assert.False(result.Fullscreen); Assert.False(switcher.IsFullscreen); Assert.Equal(0, surface.Writes); } @@ -879,6 +881,80 @@ public sealed class RuntimeSettingsControllerTests Assert.Contains(logs, line => line.Contains("display save failed", StringComparison.Ordinal)); } + [Fact] + public void RefusedFullscreenRequest_ReconcilesPersistedAndPublishedState() + { + var events = new List(); + var storage = new FakeStorage(events); + var controller = new RuntimeSettingsController( + storage, + static preset => QualitySettings.From(preset), + static _ => { }); + storage.ClearEvents(); + var targets = new FakeRuntimeTargets(events) + { + DisplayResult = new RuntimeDisplayApplyResult(Fullscreen: false), + }; + controller.BindRuntimeTargets(targets); + var observed = new List(); + controller.DisplayChanged += observed.Add; + + controller.SaveDisplay(controller.Display with + { + Fullscreen = true, + Resolution = "2056x1290", + }); + + Assert.Equal(2, storage.DisplaySaves); + Assert.False(storage.DisplayValue.Fullscreen); + Assert.False(controller.Display.Fullscreen); + Assert.False(Assert.Single(observed).Fullscreen); + Assert.Equal( + ["save-display", "target-display", "save-display", "target-quality"], + events); + } + + [Fact] + public void Successful_display_commit_publishes_render_pack_selection_once() + { + var storage = new FakeStorage(); + RuntimeSettingsController controller = CreateController(storage); + var observed = new List(); + controller.DisplayChanged += observed.Add; + DisplaySettings selected = controller.Display with + { + RenderPack = new RenderPackSelectionSettings( + "acdream.atmospheric", + "1.0.0", + "medium"), + }; + + controller.SaveDisplay(selected); + + Assert.Same(selected, controller.Display); + Assert.Equal([selected], observed); + } + + [Fact] + public void Failed_display_commit_does_not_publish_render_pack_selection() + { + var storage = new FakeStorage { ThrowOnDisplaySave = true }; + RuntimeSettingsController controller = CreateController(storage); + int observed = 0; + controller.DisplayChanged += _ => observed++; + + controller.SaveDisplay(controller.Display with + { + RenderPack = new RenderPackSelectionSettings( + "acdream.atmospheric", + "1.0.0", + "high"), + }); + + Assert.Equal(0, observed); + Assert.True(controller.Display.RenderPack.IsRetail); + } + [Fact] public void NonDisplayPersistenceFailuresContinueAndPreserveControllerState() { @@ -1132,7 +1208,7 @@ public sealed class RuntimeSettingsControllerTests public int RemainingAudioFailures { get; set; } - public void ApplyDisplay(DisplaySettings display) + public RuntimeDisplayApplyResult ApplyDisplay(DisplaySettings display) { events.Add("startup-display"); @@ -1141,6 +1217,7 @@ public sealed class RuntimeSettingsControllerTests RemainingDisplayFailures--; throw new InvalidOperationException("display startup failed"); } + return new RuntimeDisplayApplyResult(display.Fullscreen); } public void ApplyAudio(AudioSettings audio) @@ -1161,15 +1238,18 @@ public sealed class RuntimeSettingsControllerTests public bool ThrowOnQuality { get; init; } + public RuntimeDisplayApplyResult? DisplayResult { get; init; } + public int RemainingUiLockFailures { get; set; } public int UiLockCalls { get; private set; } - public void ApplyDisplayWindowState(DisplaySettings display) + public RuntimeDisplayApplyResult ApplyDisplayWindowState(DisplaySettings display) { events.Add("target-display"); if (ThrowOnDisplay) throw new InvalidOperationException("display target failed"); + return DisplayResult ?? new RuntimeDisplayApplyResult(display.Fullscreen); } public void ApplyQuality(QualitySettings quality) @@ -1235,10 +1315,11 @@ public sealed class RuntimeSettingsControllerTests { public int ApplyCount { get; private set; } - public void Apply(DisplaySettings display) + public RuntimeDisplayApplyResult Apply(DisplaySettings display) { ApplyCount++; apply(display); + return new RuntimeDisplayApplyResult(display.Fullscreen); } } diff --git a/tests/AcDream.App.Tests/Streaming/ResidentStreamingWindowFactTests.cs b/tests/AcDream.App.Tests/Streaming/ResidentStreamingWindowFactTests.cs new file mode 100644 index 00000000..48199db9 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/ResidentStreamingWindowFactTests.cs @@ -0,0 +1,142 @@ +using AcDream.App.Streaming; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Streaming; + +public sealed class ResidentStreamingWindowFactTests +{ + [Fact] + public void CaptureUsesOnlyLargestCompleteActuallyPublishedWindow() + { + var state = new GpuWorldState(); + AddSquare(state, centerX: 100, centerY: 101, radius: 1); + ResidentStreamingWindowFact first = + state.CaptureResidentStreamingWindow(100, 101); + Assert.True(first.HasPublishedCenter); + Assert.Equal(1, first.CompleteRadiusLandblocks); + Assert.Equal(192f, first.MaximumReachMeters); + + // A retained outer ring with one unpublished gap is not a safe shadow + // reach even though most of its landblocks are live. + AddRing(state, 100, 101, radius: 2, skipX: 102, skipY: 101); + ResidentStreamingWindowFact incomplete = + state.CaptureResidentStreamingWindow(100, 101); + Assert.Equal(1, incomplete.CompleteRadiusLandblocks); + Assert.Equal(192f, incomplete.MaximumReachMeters); + + Add(state, 102, 101); + ResidentStreamingWindowFact complete = + state.CaptureResidentStreamingWindow(100, 101); + Assert.Equal(2, complete.CompleteRadiusLandblocks); + Assert.Equal(384f, complete.MaximumReachMeters); + } + + [Fact] + public void NearToFarDemotionRetainsTerrainReachAndDoesNotRepublishTheWindow() + { + var state = new GpuWorldState(); + AddSquare(state, centerX: 40, centerY: 50, radius: 1); + ResidentStreamingWindowFact before = + state.CaptureResidentStreamingWindow(40, 50); + + GpuLandblockRetirement? retirement = state.DetachNearLayer( + StreamingRegion.EncodeLandblockId(41, 50)); + ResidentStreamingWindowFact after = + state.CaptureResidentStreamingWindow(40, 50); + + Assert.NotNull(retirement); + Assert.Equal(before.Revision, after.Revision); + Assert.Equal(before.MaximumReachMeters, after.MaximumReachMeters); + Assert.Equal(before.PublishedLandblockCount, after.PublishedLandblockCount); + } + + [Fact] + public void RecenterAndPortalGenerationTurnoverCannotReusePriorReach() + { + var state = new GpuWorldState(); + AddSquare(state, centerX: 12, centerY: 20, radius: 2); + ResidentStreamingWindowFact oldGeneration = + state.CaptureResidentStreamingWindow(12, 20); + Assert.Equal(384f, oldGeneration.MaximumReachMeters); + + _ = state.DetachAllForOriginRecenter(); + ResidentStreamingWindowFact betweenGenerations = + state.CaptureResidentStreamingWindow(12, 20); + Assert.False(betweenGenerations.HasPublishedCenter); + Assert.Equal(0f, betweenGenerations.MaximumReachMeters); + Assert.True(betweenGenerations.Revision > oldGeneration.Revision); + + Add(state, 220, 221); + ResidentStreamingWindowFact newGeneration = + state.CaptureResidentStreamingWindow(220, 221); + Assert.True(newGeneration.HasPublishedCenter); + Assert.Equal(0, newGeneration.CompleteRadiusLandblocks); + Assert.Equal(0f, newGeneration.MaximumReachMeters); + Assert.True(newGeneration.Revision > betweenGenerations.Revision); + Assert.False(state.CaptureResidentStreamingWindow(12, 20).HasPublishedCenter); + } + + [Fact] + public void RemovingOneResidentEdgeImmediatelyShrinksTheReadOnlyReach() + { + var state = new GpuWorldState(); + AddSquare(state, centerX: 80, centerY: 90, radius: 2); + ResidentStreamingWindowFact before = + state.CaptureResidentStreamingWindow(80, 90); + + state.RemoveLandblock(StreamingRegion.EncodeLandblockId(82, 90)); + ResidentStreamingWindowFact after = + state.CaptureResidentStreamingWindow(80, 90); + + Assert.True(after.Revision > before.Revision); + Assert.Equal(1, after.CompleteRadiusLandblocks); + Assert.Equal(192f, after.MaximumReachMeters); + } + + private static void AddSquare( + GpuWorldState state, + int centerX, + int centerY, + int radius) + { + for (int x = centerX - radius; x <= centerX + radius; x++) + { + for (int y = centerY - radius; y <= centerY + radius; y++) + Add(state, x, y); + } + } + + private static void AddRing( + GpuWorldState state, + int centerX, + int centerY, + int radius, + int skipX, + int skipY) + { + for (int x = centerX - radius; x <= centerX + radius; x++) + { + for (int y = centerY - radius; y <= centerY + radius; y++) + { + if (Math.Max(Math.Abs(x - centerX), Math.Abs(y - centerY)) != radius + || (x == skipX && y == skipY)) + { + continue; + } + Add(state, x, y); + } + } + } + + private static void Add(GpuWorldState state, int x, int y) + { + uint landblockId = StreamingRegion.EncodeLandblockId(x, y); + if (state.TryGetLandblock(landblockId, out _)) + return; + state.AddLandblock(new LoadedLandblock( + landblockId, + new LandBlock(), + Array.Empty())); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs index d0374b5d..9bcd6453 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; +using AcDream.Plugin.Abstractions.Rendering; using AcDream.UI.Abstractions.Panels.Settings; namespace AcDream.App.Tests.UI.Layout; @@ -285,7 +286,8 @@ public sealed class ConfigOptionsPageControllerTests public List CameraTurningSaves { get; } = new(); public List ChatSaves { get; } = new(); - public ConfigOptionsPageController.Bindings ToBindings() => new( + public ConfigOptionsPageController.Bindings ToBindings( + ConfigOptionsPageController.RenderPackBindings? renderPacks = null) => new( LoadDisplay: () => Display, SaveDisplay: value => { Display = value; DisplaySaves.Add(value); }, LoadAudio: () => Audio, @@ -293,7 +295,10 @@ public sealed class ConfigOptionsPageControllerTests LoadCameraTurning: () => CameraTurning, SaveCameraTurning: value => { CameraTurning = value; CameraTurningSaves.Add(value); }, LoadChat: () => Chat, - SaveChat: value => { Chat = value; ChatSaves.Add(value); }); + SaveChat: value => { Chat = value; ChatSaves.Add(value); }) + { + RenderPacks = renderPacks, + }; } private static (OptionsPanelController Panel, FakeBindings Bindings, bool Bound) BindReal( @@ -430,6 +435,377 @@ public sealed class ConfigOptionsPageControllerTests Assert.Equal(39, viewport.Children.Count); } + [Fact] + public void OptInRenderPackBindings_append_two_menus_without_changing_retail_only_fixture() + { + ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); + OptionsPanelController controller = OptionsPanelController.Bind( + layout, + new OptionsPanelController.Callbacks( + Toggle: () => { }, + RequestExitToCharacterSelection: () => { }, + ExitGame: () => { }, + UseMouseTurningSettings: () => { }, + DisplaySystemMessage: _ => { }))!; + var fake = new FakeBindings(); + string? activationFailure = "Last activation failed: shader interface mismatch."; + var renderPacks = new ConfigOptionsPageController.RenderPackBindings(() => + [ + new ConfigOptionsPageController.RenderPackChoice( + "acdream.atmospheric", + "Atmospheric Rendering", + "1.0.0", + true, + null, + [ + new ConfigOptionsPageController.RenderPackPresetChoice( + "low", "Low", true, null) + { + MaxResidentGpuBytes = 64L * 1024 * 1024, + MaxIncrementalGpuMillisecondsP50 = 2.0, + MaxIncrementalGpuMillisecondsP99 = 3.0, + MaxIncrementalCpuMillisecondsP50 = 0.15, + MaxIncrementalCpuMillisecondsP99 = 0.50, + }, + new("medium", "Medium", true, null), + new("high", "High", false, "High needs more GPU memory."), + ]) + { + FeatureSummary = "Filmic atmosphere and moving-sun shadows.", + }, + new ConfigOptionsPageController.RenderPackChoice( + "test.unsupported", + "Unsupported Test Pack", + "2.0.0", + false, + "Directional depth sampling is unavailable.", + [new("low", "Low", false, "Directional depth sampling is unavailable.")]), + ]) + { + LoadFailureNotice = () => activationFailure, + }; + + bool bound = ConfigOptionsPageController.Bind( + layout, + controller.ConfigPage, + MakeTemplateResolver(), + (_, _) => null, + fake.ToBindings(renderPacks)); + + Assert.True(bound); + var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!; + var listBox = Assert.IsType( + UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId)); + Assert.Equal(43, Assert.Single(listBox.Children).Children.Count); + Assert.Equal(32, controller.ConfigPage.Rows.Count); + + List menus = CollectMenus(configSlot); + UiMenu packMenu = menus[^2]; + UiMenu presetMenu = menus[^1]; + Assert.Equal("acdream default (retail-faithful)", packMenu.Items[0].Label); + Assert.Contains(packMenu.Items, value => Equals(value.Payload, "acdream.atmospheric")); + Assert.False(packMenu.EnabledProvider!("test.unsupported")); + Assert.Equal( + "Last activation failed: shader interface mismatch.", + packMenu.GetTooltipText()!.Split(Environment.NewLine)[0]); + packMenu.OnSelect!("test.unsupported"); + Assert.True(fake.Display.RenderPack.IsRetail); + + packMenu.OnSelect!("acdream.atmospheric"); + Assert.Equal("acdream.atmospheric", fake.Display.RenderPack.PackId); + Assert.Equal("1.0.0", fake.Display.RenderPack.PackVersion); + Assert.Equal("low", fake.Display.RenderPack.PresetId); + activationFailure = null; + Assert.Equal( + "Filmic atmosphere and moving-sun shadows.", + packMenu.GetTooltipText()); + presetMenu = CollectMenus(configSlot)[^1]; + Assert.Equal(3, presetMenu.Items.Count); + Assert.False(presetMenu.EnabledProvider!("high")); + Assert.Contains( + "GPU p50/p99 ≤ 2/3 ms", + presetMenu.GetTooltipText(), + StringComparison.Ordinal); + Assert.Contains("pack VRAM ≤ 64 MiB", presetMenu.GetTooltipText(), StringComparison.Ordinal); + + presetMenu.OnSelect!("medium"); + Assert.Equal("medium", fake.Display.RenderPack.PresetId); + Assert.True(fake.DisplaySaves.Count >= 2); + } + + [Fact] + public void RenderPackMenu_revision_refresh_removes_withdrawn_schema_and_discovers_reregistration() + { + ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); + OptionsPanelController controller = OptionsPanelController.Bind( + layout, + new OptionsPanelController.Callbacks( + Toggle: () => { }, + RequestExitToCharacterSelection: () => { }, + ExitGame: () => { }, + UseMouseTurningSettings: () => { }, + DisplaySystemMessage: _ => { }))!; + var alpha = new ConfigOptionsPageController.RenderPackChoice( + "pack.alpha", "Alpha", "1.0.0", true, null, + [new("low", "Low", true, null)]) + { + Settings = + [ + new RenderSettingDeclaration( + "alpha-toggle", "Alpha toggle", RenderSettingKind.Boolean, + "true", null, null, null, []), + ], + }; + var beta = new ConfigOptionsPageController.RenderPackChoice( + "pack.beta", "Beta", "2.0.0", true, null, + [new("medium", "Medium", true, null)]); + IReadOnlyList discovered = [alpha]; + long revision = 1; + var fake = new FakeBindings + { + Display = DisplaySettings.Default with + { + RenderPack = new RenderPackSelectionSettings( + "pack.alpha", "1.0.0", "low"), + }, + }; + var renderPacks = new ConfigOptionsPageController.RenderPackBindings( + () => discovered) + { + LoadRevision = () => revision, + }; + + Assert.True(ConfigOptionsPageController.Bind( + layout, + controller.ConfigPage, + MakeTemplateResolver(), + (_, _) => null, + fake.ToBindings(renderPacks))); + + var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!; + var listBox = Assert.IsType( + UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId)); + UiMenu packMenu = CollectMenus(configSlot)[^2]; + Assert.Equal(44, listBox.ItemCount); + Assert.Contains(packMenu.Items, item => Equals(item.Payload, "pack.alpha")); + + discovered = [beta]; + revision++; + packMenu.BeforeOpen!(); + + Assert.DoesNotContain(packMenu.Items, item => Equals(item.Payload, "pack.alpha")); + Assert.Contains(packMenu.Items, item => Equals(item.Payload, "pack.beta")); + Assert.Equal(RenderPackSelectionSettings.RetailPackId, packMenu.Selected); + Assert.Equal(43, listBox.ItemCount); + Assert.Equal(32, controller.ConfigPage.Rows.Count); + controller.ConfigPage.Reset(); + Assert.Equal(RenderPackSelectionSettings.RetailPackId, packMenu.Selected); + Assert.DoesNotContain(packMenu.Items, item => Equals(item.Payload, "pack.alpha")); + + fake.Display = fake.Display with + { + RenderPack = new RenderPackSelectionSettings( + "pack.beta", "2.0.0", "medium"), + }; + revision++; + packMenu.BeforeOpen!(); + + Assert.Equal("pack.beta", packMenu.Selected); + Assert.Equal(43, listBox.ItemCount); + Assert.Equal("Medium", CollectMenus(configSlot)[^1].Items.Single().Label); + } + + [Fact] + public void RenderPackSettings_live_schema_swap_replaces_only_the_optional_tail() + { + ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); + OptionsPanelController controller = OptionsPanelController.Bind( + layout, + new OptionsPanelController.Callbacks( + Toggle: () => { }, + RequestExitToCharacterSelection: () => { }, + ExitGame: () => { }, + UseMouseTurningSettings: () => { }, + DisplaySystemMessage: _ => { }))!; + var fake = new FakeBindings + { + Display = DisplaySettings.Default with + { + RenderPack = new RenderPackSelectionSettings("pack.alpha", "1.0.0", "low"), + }, + }; + RenderSettingDeclaration[] alphaSettings = + [ + new("enabled", "Enabled", RenderSettingKind.Boolean, "false", null, null, null, []), + new("strength", "Strength", RenderSettingKind.Float, "0.5", 0, 1, 0.25, []), + new("samples", "Samples", RenderSettingKind.Integer, "2", 0, 10, 2, []), + new("mode", "Mode", RenderSettingKind.Choice, "low", null, null, null, + ["low", "high"]), + ]; + var alpha = new ConfigOptionsPageController.RenderPackChoice( + "pack.alpha", "Alpha", "1.0.0", true, null, + [ + new ConfigOptionsPageController.RenderPackPresetChoice( + "low", "Low", true, null) + { + SettingOverrides = + [ + new RenderQualitySettingOverride("strength", "0.75"), + ], + }, + new ConfigOptionsPageController.RenderPackPresetChoice( + "high", "High", true, null), + ]) + { + Settings = alphaSettings, + }; + var beta = new ConfigOptionsPageController.RenderPackChoice( + "pack.beta", "Beta", "2.0.0", true, null, + [new("default", "Default", true, null)]) + { + Settings = + [ + new RenderSettingDeclaration( + "beta-enabled", "Beta enabled", RenderSettingKind.Boolean, + "true", null, null, null, []), + ], + }; + var renderPacks = new ConfigOptionsPageController.RenderPackBindings(() => + [alpha, beta]); + + Assert.True(ConfigOptionsPageController.Bind( + layout, + controller.ConfigPage, + MakeTemplateResolver(), + (_, _) => null, + fake.ToBindings(renderPacks))); + + var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!; + var listBox = Assert.IsType( + UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId)); + Assert.Equal(47, listBox.ItemCount); // exact retail 39 + 8-row optional tail + Assert.Equal(36, controller.ConfigPage.Rows.Count); // retail 30 + pack/preset + 4 settings + + IReadOnlyList items = listBox.ViewportForTest!.Children; + var enabled = Assert.IsType( + UiElement.FindDescendant(items[42], 0x10000219u)); + var strength = Assert.IsType( + UiElement.FindDescendant(items[43], 0x1000021Cu)); + var samples = Assert.IsType( + UiElement.FindDescendant(items[44], 0x1000021Cu)); + List menus = CollectMenus(configSlot); + UiMenu packMenu = menus[^3]; + UiMenu presetMenu = menus[^2]; + UiMenu oldModeMenu = menus[^1]; + Assert.Equal(0.75f, strength.ScalarPosition, 3); // preset wins declaration default + Assert.Equal(["low", "high"], oldModeMenu.Items.Select(value => value.Label)); + + enabled.Selected = true; + enabled.OnClick!(); + strength.ScalarChanged!(0.62f); // 0.62 snaps to 0.5 on the declared 0.25 step + samples.ScalarChanged!(0.33f); // 3.3 snaps to integer step 4 + oldModeMenu.OnSelect!("high"); + + Assert.Equal("true", fake.Display.RenderPack.SettingOverrides["enabled"]); + Assert.Equal("0.5", fake.Display.RenderPack.SettingOverrides["strength"]); + Assert.Equal("4", fake.Display.RenderPack.SettingOverrides["samples"]); + Assert.Equal("high", fake.Display.RenderPack.SettingOverrides["mode"]); + + presetMenu.OnSelect!("high"); + Assert.Equal("high", fake.Display.RenderPack.PresetId); + Assert.Equal(4, fake.Display.RenderPack.SettingOverrides.Count); + Assert.Equal(47, listBox.ItemCount); + Assert.Equal(36, controller.ConfigPage.Rows.Count); + int savesBeforeStaleWidget = fake.DisplaySaves.Count; + oldModeMenu.OnSelect!("low"); + Assert.Equal(savesBeforeStaleWidget, fake.DisplaySaves.Count); + + packMenu.OnSelect!("pack.beta"); + Assert.Equal("pack.beta", fake.Display.RenderPack.PackId); + Assert.Equal("2.0.0", fake.Display.RenderPack.PackVersion); + Assert.Equal("default", fake.Display.RenderPack.PresetId); + Assert.Empty(fake.Display.RenderPack.SettingOverrides); + Assert.Equal(44, listBox.ItemCount); // header + pack + preset + one setting + separator + Assert.Equal(33, controller.ConfigPage.Rows.Count); + + oldModeMenu.OnSelect!("high"); + Assert.Empty(fake.Display.RenderPack.SettingOverrides); + + controller.ConfigPage.Reset(); + Assert.Equal("pack.alpha", fake.Display.RenderPack.PackId); + Assert.Equal(47, listBox.ItemCount); + Assert.Equal(36, controller.ConfigPage.Rows.Count); + + controller.ConfigPage.Defaults(); + Assert.True(fake.Display.RenderPack.IsRetail); + Assert.Equal(43, listBox.ItemCount); // header + pack + retail preset + separator + Assert.Equal(32, controller.ConfigPage.Rows.Count); + } + + [Fact] + public void RenderPackSettingEdit_sanitizes_unknown_and_invalid_persisted_values() + { + ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); + OptionsPanelController controller = OptionsPanelController.Bind( + layout, + new OptionsPanelController.Callbacks( + Toggle: () => { }, + RequestExitToCharacterSelection: () => { }, + ExitGame: () => { }, + UseMouseTurningSettings: () => { }, + DisplaySystemMessage: _ => { }))!; + var fake = new FakeBindings + { + Display = DisplaySettings.Default with + { + RenderPack = new RenderPackSelectionSettings("pack.alpha", "1.0.0", "low") + { + SettingOverrides = new RenderPackSettingOverrides( + new Dictionary + { + ["removed"] = "1", + ["strength"] = "0.6", // not aligned to 0.25 + }), + }, + }, + }; + var pack = new ConfigOptionsPageController.RenderPackChoice( + "pack.alpha", "Alpha", "1.0.0", true, null, + [new("low", "Low", true, null)]) + { + Settings = + [ + new RenderSettingDeclaration( + "enabled", "Enabled", RenderSettingKind.Boolean, + "false", null, null, null, []), + new RenderSettingDeclaration( + "strength", "Strength", RenderSettingKind.Float, + "0.5", 0, 1, 0.25, []), + ], + }; + + Assert.True(ConfigOptionsPageController.Bind( + layout, + controller.ConfigPage, + MakeTemplateResolver(), + (_, _) => null, + fake.ToBindings(new ConfigOptionsPageController.RenderPackBindings(() => [pack])))); + + var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!; + var listBox = Assert.IsType( + UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId)); + UiElement enabledRow = listBox.ViewportForTest!.Children[42]; + var enabled = Assert.IsType( + UiElement.FindDescendant(enabledRow, 0x10000219u)); + enabled.Selected = true; + enabled.OnClick!(); + + Assert.Single(fake.Display.RenderPack.SettingOverrides); + Assert.Equal("true", fake.Display.RenderPack.SettingOverrides["enabled"]); + Assert.False(fake.Display.RenderPack.SettingOverrides.ContainsKey("removed")); + Assert.False(fake.Display.RenderPack.SettingOverrides.ContainsKey("strength")); + } + [Fact] public void ToggleRow_SfxEnabled_WritesThroughAudioBindings() { @@ -1043,7 +1419,7 @@ public sealed class ConfigOptionsPageControllerTests (24, RowKind.Menu, true, "Environment Texture Detail"), // AP-198 (25, RowKind.Menu, true, "Texture Filtering"), // AP-198 (26, RowKind.Menu, true, "Landscape Draw Distance"), // AP-198 - (27, RowKind.Toggle, true, "Building Detail Textures"), // AP-198 + (27, RowKind.Toggle, false, "Building Detail Textures"), // LIVE — #226 (28, RowKind.Toggle, true, "Multi-Pass Alpha"), // AP-198 (31, RowKind.Slider, true, "Mouse Look Sensitivity"), // TS-74 (32, RowKind.Toggle, true, "Invert Mouselook Y Axis"), // TS-74 diff --git a/tests/AcDream.App.Tests/UI/Layout/OptionPageModelTests.cs b/tests/AcDream.App.Tests/UI/Layout/OptionPageModelTests.cs index b9ead893..a1c9f7ed 100644 --- a/tests/AcDream.App.Tests/UI/Layout/OptionPageModelTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/OptionPageModelTests.cs @@ -431,4 +431,47 @@ public sealed class OptionPageModelTests Assert.Equal(0, flushCount); // NO AfterApply publication on a seed Assert.Equal(1, gatingCount); // Apply/Reset ghosting re-evaluated } + + [Fact] + public void RemoveTail_DetachesRowsAndPageNotifyOwnership() + { + var page = new OptionPage(); + var retained = new BoolOptionRow(false, false); + var removed = new BoolOptionRow(false, false); + int notifications = 0; + page.OnOptionChanged = () => notifications++; + page.Register(retained); + page.Register(removed); + + page.RemoveTail(1); + notifications = 0; + removed.SetCurrentValue(true); + + Assert.Single(page.Rows); + Assert.Same(retained, page.Rows[0]); + Assert.Equal(0, notifications); + Assert.False(page.Changed); + } + + [Fact] + public void Defaults_IsSafeWhenAnEarlierRowReplacesTheDynamicTail() + { + var page = new OptionPage(); + int removedApplyCount = 0; + var pack = new BoolOptionRow( + initial: true, + defaultValue: false, + apply: _ => page.RemoveTail(1)); + var dynamic = new BoolOptionRow( + initial: false, + defaultValue: true, + apply: _ => removedApplyCount++); + page.Register(pack); + page.Register(dynamic); + + page.Defaults(); + + Assert.Single(page.Rows); + Assert.Equal(0, removedApplyCount); + } } diff --git a/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs b/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs index e1b0bc00..1bb817db 100644 --- a/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs @@ -41,10 +41,65 @@ public sealed class RetailUiAutomationProbeTests public bool IsWorldReady { get; set; } public bool IsWorldViewportVisible { get; set; } public int PortalMaterializationCount { get; set; } + public int RenderPackPerformanceSampleCount { get; set; } + public bool RenderPackFailedToRetail { get; set; } + public RetailUiAutomationRenderPackStatus RenderPackStatus { get; set; } = + RetailUiAutomationRenderPackStatus.Retail; + public int FramebufferWidth { get; set; } = 1280; + public int FramebufferHeight { get; set; } = 720; + public int RenderPackPerformanceResetCount { get; private set; } + public List RenderPackSelections { get; } = []; + public int RenderPackDisableCount { get; private set; } + public int RenderPackReenableCount { get; private set; } + public List<(int Width, int Height)> FramebufferResizes { get; } = []; + public int ClientCloseRequestCount { get; private set; } public List Checkpoints { get; } = new(); public HashSet ScreenshotRequests { get; } = new(); public HashSet CompletedScreenshots { get; } = new(); + public bool TryResetRenderPackPerformance(out string error) + { + RenderPackPerformanceResetCount++; + RenderPackPerformanceSampleCount = 0; + error = string.Empty; + return true; + } + + public bool TrySelectRenderPack(string presetId, out string error) + { + RenderPackSelections.Add(presetId); + error = string.Empty; + return true; + } + + public bool TryDisableRenderPack(out string error) + { + RenderPackDisableCount++; + error = string.Empty; + return true; + } + + public bool TryReenableRenderPack(out string error) + { + RenderPackReenableCount++; + error = string.Empty; + return true; + } + + public bool TryResizeFramebuffer(int width, int height, out string error) + { + FramebufferResizes.Add((width, height)); + error = string.Empty; + return true; + } + + public bool TryRequestClientClose(out string error) + { + ClientCloseRequestCount++; + error = string.Empty; + return true; + } + public bool TryRequestCheckpoint( string name, out IRetailUiAutomationCheckpoint? checkpoint, @@ -654,6 +709,199 @@ public sealed class RetailUiAutomationProbeTests } } + [Fact] + public void ScriptRunner_resetsThenWaitsForCompleteRenderPackEvidenceWindow() + { + var (root, _, _, _, objects) = RootWithTwoItemLists(); + var runtime = new FakeRuntime + { + RenderPackPerformanceSampleCount = 1200, + }; + var probe = new RetailUiAutomationProbe(root, objects); + string path = Path.Combine( + Path.GetTempPath(), + Path.GetRandomFileName() + ".ui-probe.txt"); + File.WriteAllLines(path, + [ + "renderpack reset-performance", + "wait render-pack-samples 2048 1000", + "screenshot complete-window 1000", + ]); + + try + { + using var runner = new RetailUiAutomationScriptRunner( + probe, + path, + dumpOnStart: false, + runtime: runtime); + + runner.Tick(0d); + Assert.Equal(1, runtime.RenderPackPerformanceResetCount); + Assert.Equal(0, runtime.RenderPackPerformanceSampleCount); + Assert.Empty(runtime.ScreenshotRequests); + + runtime.RenderPackPerformanceSampleCount = 2047; + runner.Tick(0.5d); + Assert.Empty(runtime.ScreenshotRequests); + + runtime.RenderPackPerformanceSampleCount = 2048; + runner.Tick(0.001d); + Assert.Contains("complete-window", runtime.ScreenshotRequests); + Assert.False(runner.Completed); + + runtime.CompletedScreenshots.Add("complete-window"); + runner.Tick(0.001d); + Assert.True(runner.Completed); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ScriptRunner_renderPackWaitTerminatesEarlyForFailedToRetailFallback() + { + var (root, _, _, _, objects) = RootWithTwoItemLists(); + var runtime = new FakeRuntime + { + RenderPackFailedToRetail = true, + }; + var probe = new RetailUiAutomationProbe(root, objects); + string path = Path.Combine( + Path.GetTempPath(), + Path.GetRandomFileName() + ".ui-probe.txt"); + File.WriteAllLines(path, + [ + "renderpack reset-performance", + "wait render-pack-samples 2048 300000", + "screenshot safe-fallback 1000", + ]); + + try + { + using var runner = new RetailUiAutomationScriptRunner( + probe, + path, + dumpOnStart: false, + runtime: runtime); + + runner.Tick(0d); + + Assert.Equal(1, runtime.RenderPackPerformanceResetCount); + Assert.Equal(0, runtime.RenderPackPerformanceSampleCount); + Assert.Contains("safe-fallback", runtime.ScreenshotRequests); + Assert.False(runner.Completed); + + runtime.CompletedScreenshots.Add("safe-fallback"); + runner.Tick(0.001d); + Assert.True(runner.Completed); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ScriptRunner_renderPackTransitionsAndResizeWaitForPublishedRuntimeState() + { + var (root, _, _, _, objects) = RootWithTwoItemLists(); + var runtime = new FakeRuntime(); + var probe = new RetailUiAutomationProbe(root, objects); + string path = Path.Combine( + Path.GetTempPath(), + Path.GetRandomFileName() + ".ui-probe.txt"); + File.WriteAllLines(path, + [ + "renderpack select high", + "wait render-pack high 1000", + "renderpack disable", + "wait render-pack retail 1000", + "renderpack reenable", + "wait render-pack high 1000", + "resize 1024 768", + "wait framebuffer 1024 768 1000", + ]); + + try + { + using var runner = new RetailUiAutomationScriptRunner( + probe, + path, + dumpOnStart: false, + runtime: runtime); + + runner.Tick(0d); + Assert.Equal(["high"], runtime.RenderPackSelections); + Assert.False(runner.Completed); + + runtime.RenderPackStatus = new RetailUiAutomationRenderPackStatus( + RetailUiAutomationRenderPackState.Active, + "acdream.atmospheric", + "high", + ActivationGeneration: 1, + FailureReason: null); + runner.Tick(0.001d); + Assert.Equal(1, runtime.RenderPackDisableCount); + + runtime.RenderPackStatus = RetailUiAutomationRenderPackStatus.Retail; + runner.Tick(0.001d); + Assert.Equal(1, runtime.RenderPackReenableCount); + + runtime.RenderPackStatus = new RetailUiAutomationRenderPackStatus( + RetailUiAutomationRenderPackState.Active, + "acdream.atmospheric", + "high", + ActivationGeneration: 3, + FailureReason: null); + runner.Tick(0.001d); + Assert.Equal([(1024, 768)], runtime.FramebufferResizes); + Assert.False(runner.Completed); + + runtime.FramebufferWidth = 1024; + runtime.FramebufferHeight = 768; + runner.Tick(0.001d); + Assert.True(runner.Completed); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ScriptRunner_closeClientRequestsNormalRuntimeShutdownExactlyOnce() + { + var (root, _, _, _, objects) = RootWithTwoItemLists(); + var runtime = new FakeRuntime(); + var probe = new RetailUiAutomationProbe(root, objects); + string path = Path.Combine( + Path.GetTempPath(), + Path.GetRandomFileName() + ".ui-probe.txt"); + File.WriteAllText(path, "close-client"); + + try + { + using var runner = new RetailUiAutomationScriptRunner( + probe, + path, + dumpOnStart: false, + runtime: runtime); + + runner.Tick(0d); + runner.Tick(0d); + + Assert.True(runner.Completed); + Assert.Equal(1, runtime.ClientCloseRequestCount); + } + finally + { + File.Delete(path); + } + } + [Theory] [InlineData(RetailUiAutomationCheckpointStatus.Failed, "deferred write failed")] [InlineData(RetailUiAutomationCheckpointStatus.Cancelled, "shutdown cancelled")] diff --git a/tests/AcDream.App.Tests/UI/UiTemplateListBoxViewportTests.cs b/tests/AcDream.App.Tests/UI/UiTemplateListBoxViewportTests.cs index 02c6a176..ac4d4c4e 100644 --- a/tests/AcDream.App.Tests/UI/UiTemplateListBoxViewportTests.cs +++ b/tests/AcDream.App.Tests/UI/UiTemplateListBoxViewportTests.cs @@ -77,6 +77,29 @@ public sealed class UiTemplateListBoxViewportTests Assert.True(row2.Visible, "row 2 culled — the #372 blank-tab bug"); } + [Fact] + public void RemoveTail_PreservesPrefixAndRestacksFutureRows() + { + var box = MakeListBox(276f, 560f); + var prefix = new UiText { Width = 260f, Height = 20f }; + var removedA = new UiText { Width = 260f, Height = 30f }; + var removedB = new UiText { Width = 260f, Height = 40f }; + box.AddPrebuiltRow(prefix); + box.AddPrebuiltRow(removedA); + box.AddPrebuiltRow(removedB); + + box.RemoveTail(1); + var replacement = new UiText { Width = 260f, Height = 25f }; + box.AddPrebuiltRow(replacement); + + Assert.Equal(2, box.ItemCount); + Assert.Same(prefix, box.ViewportForTest!.Children[0]); + Assert.Same(replacement, box.ViewportForTest.Children[1]); + Assert.Equal(20f, replacement.Top); + Assert.Null(removedA.Parent); + Assert.Null(removedB.Parent); + } + /// /// #412-class regression (2026-08-16, overnight hover/UI round, Batch A bug /// 2): the Options panel's Config tab escaped past the window frame — the diff --git a/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/HelloPlugin.cs b/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/HelloPlugin.cs index 8048fb62..325824d1 100644 --- a/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/HelloPlugin.cs +++ b/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/HelloPlugin.cs @@ -1,8 +1,9 @@ using AcDream.Plugin.Abstractions; +using AcDream.Plugin.Abstractions.Rendering; namespace AcDream.Core.Tests.Fixtures.HelloPlugin; -public sealed class HelloPlugin : IAcDreamPlugin +public sealed class HelloPlugin : IAcDreamPlugin, IRenderPackPlugin, IRenderPackAssets { public int InitializeCount { get; private set; } public int EnableCount { get; private set; } @@ -17,4 +18,39 @@ public sealed class HelloPlugin : IAcDreamPlugin public void Enable() => EnableCount++; public void Disable() => DisableCount++; + + public void Register(IRenderPackRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + _ = registry.Register( + new RenderPackDescriptor( + Id: "acdream.test.noop-pack", + DisplayName: "Test no-op pack", + PackVersion: new Version(1, 0, 0), + PackApiVersion: RenderPackApi.Current, + HighestTier: RenderPackTier.Tier1, + RequiredCapabilities: [], + OptionalCapabilities: [], + Resources: [], + Passes: [], + SceneReplays: [], + PipelineVariants: [], + QualityPresets: [], + Settings: [], + AtmospherePolicy: null) + { + FeatureSummary = "Test-only no-op render-pack fixture.", + }, + this); + string? directory = Path.GetDirectoryName(typeof(HelloPlugin).Assembly.Location); + if (directory is not null + && File.Exists(Path.Combine(directory, "throw-after-render-register"))) + { + throw new InvalidOperationException( + "fixture render-pack registration failed after publishing a descriptor"); + } + } + + public Stream OpenRead(string assetKey) => + new MemoryStream([], writable: false); } diff --git a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs index c9b44bbe..9fe15303 100644 --- a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs +++ b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs @@ -1,6 +1,7 @@ using AcDream.Core.Plugins; using AcDream.Core.Selection; using AcDream.Plugin.Abstractions; +using AcDream.Plugin.Abstractions.Rendering; namespace AcDream.Core.Tests.Plugins; @@ -69,6 +70,36 @@ public class PluginLoaderTests } } + private sealed class RecordingRenderPackRegistry : IRenderPackRegistry, IDisposable + { + private readonly List _registrations = []; + + public int ActiveCount => _registrations.Count(static item => item.Active); + public RenderPackDescriptor? Descriptor { get; private set; } + + public IDisposable Register( + RenderPackDescriptor descriptor, + IRenderPackAssets assets) + { + Descriptor = descriptor; + var registration = new Registration(); + _registrations.Add(registration); + return registration; + } + + public void Dispose() + { + foreach (Registration registration in _registrations) + registration.Dispose(); + } + + private sealed class Registration : IDisposable + { + public bool Active { get; private set; } = true; + public void Dispose() => Active = false; + } + } + [Fact] public void Load_FixtureDll_InstantiatesPluginAndCallsInitialize() { @@ -89,13 +120,42 @@ public class PluginLoaderTests manifest: manifest, host: host); - Assert.True(loaded.Success); + Assert.True(loaded.Success, loaded.Error?.ToString()); Assert.NotNull(loaded.Plugin); Assert.Equal("HelloPlugin", loaded.Plugin!.GetType().Name); loaded.Plugin.Disable(); loaded.LoadContext!.Unload(); } + [Fact] + public void Load_RenderPackOnlyFixture_RegistersWithoutGameplayEntrypointRequirement() + { + string dllPath = FixturePluginPath(); + var host = new StubHost(); + using var registry = new RecordingRenderPackRegistry(); + var manifest = new PluginManifest( + Id: "acdream.test.render-pack", + DisplayName: "Render pack", + Version: "1.0.0", + EntryDll: Path.GetFileName(dllPath), + ApiVersion: 1, + Dependencies: [], + Kinds: [PluginKind.RenderPack]); + + LoadedPlugin loaded = PluginLoader.Load( + Path.GetDirectoryName(dllPath)!, + manifest, + host, + registry); + + Assert.True(loaded.Success, loaded.Error?.ToString()); + Assert.Null(loaded.Plugin); + Assert.NotNull(loaded.RenderPackPlugin); + Assert.Equal(1, registry.ActiveCount); + Assert.Equal("acdream.test.noop-pack", registry.Descriptor?.Id); + loaded.LoadContext!.Unload(); + } + [Fact] public void Load_UnsupportedApiVersion_IsRefusedBeforeAnyCodeLoads() { diff --git a/tests/AcDream.Core.Tests/Plugins/PluginManifestTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginManifestTests.cs index cfe08bcb..b9ff6a51 100644 --- a/tests/AcDream.Core.Tests/Plugins/PluginManifestTests.cs +++ b/tests/AcDream.Core.Tests/Plugins/PluginManifestTests.cs @@ -24,6 +24,7 @@ public class PluginManifestTests Assert.Equal("0.1.0", manifest.Version); Assert.Equal("AcDream.Plugins.Smoke.dll", manifest.EntryDll); Assert.Equal(1, manifest.ApiVersion); + Assert.Equal([PluginKind.Gameplay], manifest.Kinds); } [Fact] @@ -59,4 +60,49 @@ public class PluginManifestTests var manifest = PluginManifest.Parse(json); Assert.Empty(manifest.Dependencies); } + + [Fact] + public void Parse_RenderPackAndHybridKinds_AreExplicitAndDeduplicated() + { + const string json = """ + { + "id": "x", + "displayName": "X", + "version": "1.0.0", + "entryDll": "x.dll", + "apiVersion": 1, + "kinds": ["renderPack", "gameplay", "RENDERPACK"] + } + """; + + PluginManifest manifest = PluginManifest.Parse(json); + + Assert.Equal( + [PluginKind.RenderPack, PluginKind.Gameplay], + manifest.Kinds); + Assert.True(manifest.Declares(PluginKind.RenderPack)); + Assert.True(manifest.Declares(PluginKind.Gameplay)); + } + + [Theory] + [InlineData("[]", "kinds must contain at least one entry")] + [InlineData("[\"nativeCode\"]", "unknown plugin kind: nativeCode")] + public void Parse_InvalidKinds_Throws(string kindsJson, string expected) + { + string json = $$""" + { + "id": "x", + "displayName": "X", + "version": "1.0.0", + "entryDll": "x.dll", + "apiVersion": 1, + "kinds": {{kindsJson}} + } + """; + + PluginManifestException error = Assert.Throws( + () => PluginManifest.Parse(json)); + + Assert.Equal(expected, error.Message); + } } diff --git a/tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs index d7832c2e..6a07d944 100644 --- a/tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs +++ b/tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs @@ -2,6 +2,7 @@ using System.Text.Json; using AcDream.Core.Plugins; using AcDream.Core.Selection; using AcDream.Plugin.Abstractions; +using AcDream.Plugin.Abstractions.Rendering; namespace AcDream.Core.Tests.Plugins; @@ -73,11 +74,118 @@ public sealed class PluginSessionTests Assert.Empty(statuses); } + [Fact] + public void GraphicalKindSet_RegistersRenderPackAndWithdrawsBeforeUnload() + { + using var temporary = new TemporaryDirectory(); + InstallFixture( + temporary.Path, + "render", + "acdream.test.render", + [PluginKind.RenderPack]); + var statuses = new List(); + var registry = new RecordingRenderPackRegistry(); + var plugins = new PluginSession( + new StubHost(), + statuses.Add, + registry, + [PluginKind.Gameplay, PluginKind.RenderPack]); + + plugins.Start([temporary.Path], allowList: null); + + Assert.Equal(1, plugins.LoadedCount); + Assert.Equal(1, registry.ActiveCount); + Assert.Equal("acdream.test.noop-pack", registry.Descriptor?.Id); + IReadOnlyList contexts = + plugins.CaptureLoadContextWeakReferences(); + plugins.Dispose(); + Assert.Equal(0, registry.ActiveCount); + Collect(contexts); + } + + [Fact] + public void GameplayOnlyHost_SkipsUnrequestedRenderPackBeforeDllProbe() + { + using var temporary = new TemporaryDirectory(); + InstallBroken( + temporary.Path, + "render", + "acdream.test.render", + [PluginKind.RenderPack]); + var statuses = new List(); + using var plugins = new PluginSession(new StubHost(), statuses.Add); + + plugins.Start([temporary.Path], allowList: null); + + Assert.Equal(0, plugins.LoadedCount); + Assert.Empty(statuses); + Assert.Empty(plugins.CaptureLoadContextWeakReferences()); + } + + [Fact] + public void RenderPackRegisterFailure_WithdrawsPartialRegistrationBeforeUnload() + { + using var temporary = new TemporaryDirectory(); + InstallFixture( + temporary.Path, + "render", + "acdream.test.render", + [PluginKind.RenderPack]); + File.WriteAllText( + Path.Combine(temporary.Path, "render", "throw-after-render-register"), + string.Empty); + var registry = new RecordingRenderPackRegistry(); + var statuses = new List(); + var plugins = new PluginSession( + new StubHost(), + statuses.Add, + registry, + [PluginKind.Gameplay, PluginKind.RenderPack]); + + plugins.Start([temporary.Path], allowList: null); + + Assert.Equal(0, plugins.LoadedCount); + Assert.Equal(0, registry.ActiveCount); + PluginSessionStatus status = Assert.Single(statuses); + Assert.Equal(PluginSessionStatusKind.Failed, status.Kind); + Assert.Contains("failed after publishing", status.Error); + IReadOnlyList contexts = + plugins.CaptureLoadContextWeakReferences(); + plugins.Dispose(); + Collect(contexts); + } + + [Fact] + public void GameplayOnlyHost_ExplicitRenderPackReportsKindWithoutDllProbe() + { + using var temporary = new TemporaryDirectory(); + InstallBroken( + temporary.Path, + "render", + "acdream.test.render", + [PluginKind.RenderPack]); + var statuses = new List(); + using var plugins = new PluginSession(new StubHost(), statuses.Add); + + plugins.Start([temporary.Path], ["acdream.test.render"]); + + PluginSessionStatus status = Assert.Single(statuses); + Assert.Equal(PluginSessionStatusKind.Failed, status.Kind); + Assert.Contains("does not support", status.Error); + Assert.DoesNotContain("entry dll", status.Error); + Assert.Empty(plugins.CaptureLoadContextWeakReferences()); + } + private static void ReleaseAndCollect(PluginSession plugins) { IReadOnlyList contexts = plugins.CaptureLoadContextWeakReferences(); plugins.Dispose(); + Collect(contexts); + } + + private static void Collect(IReadOnlyList contexts) + { for (int attempt = 0; attempt < 10 && contexts.Any(static context => context.IsAlive); attempt++) @@ -90,6 +198,13 @@ public sealed class PluginSessionTests } private static void InstallFixture(string root, string folder, string id) + => InstallFixture(root, folder, id, kinds: null); + + private static void InstallFixture( + string root, + string folder, + string id, + IReadOnlyList? kinds) { string source = FixturePluginPath(); Assert.True(File.Exists(source), $"fixture DLL not found: {source}"); @@ -97,20 +212,25 @@ public sealed class PluginSessionTests Directory.CreateDirectory(pluginDirectory); string fileName = Path.GetFileName(source); File.Copy(source, Path.Combine(pluginDirectory, fileName)); - WriteManifest(pluginDirectory, id, fileName); + WriteManifest(pluginDirectory, id, fileName, kinds); } - private static void InstallBroken(string root, string folder, string id) + private static void InstallBroken( + string root, + string folder, + string id, + IReadOnlyList? kinds = null) { string pluginDirectory = Path.Combine(root, folder); Directory.CreateDirectory(pluginDirectory); - WriteManifest(pluginDirectory, id, "missing.dll"); + WriteManifest(pluginDirectory, id, "missing.dll", kinds); } private static void WriteManifest( string directory, string id, - string entryDll) => + string entryDll, + IReadOnlyList? kinds = null) => File.WriteAllText( Path.Combine(directory, "plugin.json"), JsonSerializer.Serialize(new @@ -120,8 +240,46 @@ public sealed class PluginSessionTests version = "1.0.0", entryDll, apiVersion = 1, + kinds = kinds?.Select(static kind => kind.ToString()), })); + private sealed class RecordingRenderPackRegistry : IRenderPackRegistry + { + private readonly List _registrations = []; + + internal int ActiveCount => _registrations.Count; + internal RenderPackDescriptor? Descriptor { get; private set; } + + public IDisposable Register( + RenderPackDescriptor descriptor, + IRenderPackAssets assets) + { + Descriptor = descriptor; + var registration = new Registration(this, assets); + _registrations.Add(registration); + return registration; + } + + private void Remove(Registration registration) => + _registrations.Remove(registration); + + private sealed class Registration( + RecordingRenderPackRegistry owner, + IRenderPackAssets assets) : IDisposable + { + private RecordingRenderPackRegistry? _owner = owner; + private IRenderPackAssets? _assets = assets; + + public void Dispose() + { + RecordingRenderPackRegistry? current = + Interlocked.Exchange(ref _owner, null); + _assets = null; + current?.Remove(this); + } + } + } + private static string FixturePluginPath() { string configuration = new DirectoryInfo(AppContext.BaseDirectory) diff --git a/tests/AcDream.Core.Tests/Rendering/TranslucencyFadeManagerTests.cs b/tests/AcDream.Core.Tests/Rendering/TranslucencyFadeManagerTests.cs index 41be6390..545a0664 100644 --- a/tests/AcDream.Core.Tests/Rendering/TranslucencyFadeManagerTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/TranslucencyFadeManagerTests.cs @@ -149,4 +149,26 @@ public sealed class TranslucencyFadeManagerTests Assert.Equal(1f, part0); // part 0's 1s ramp is done Assert.Equal(0.5f, part1, 5); // part 1's 2s ramp is halfway } + + [Fact] + public void Revision_AdvancesOnlyWhenCommittedCasterOpacityChanges() + { + var mgr = new TranslucencyFadeManager(); + ulong initial = mgr.Revision; + + mgr.StartPartFade(1, 0, start: 0f, end: 1f, time: 1f); + ulong started = mgr.Revision; + Assert.True(started > initial); + + mgr.AdvanceAll(0f); + Assert.Equal(started, mgr.Revision); + mgr.AdvanceAll(0.5f); + Assert.True(mgr.Revision > started); + + ulong advanced = mgr.Revision; + mgr.ClearEntity(999); + Assert.Equal(advanced, mgr.Revision); + mgr.ClearEntity(1); + Assert.True(mgr.Revision > advanced); + } } diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs index 50b947b5..5a3fdde1 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs @@ -124,6 +124,112 @@ public sealed class WbDrawDispatcherIndirectBuilderTests Assert.Equal(0, result.TransparentCount); } + [Fact] + public void EveryBuiltMeshMaterialSubsetIsDetailEligible() + { + TranslucencyKind[] kinds = + [ + TranslucencyKind.Opaque, + TranslucencyKind.ClipMap, + TranslucencyKind.AlphaBlend, + TranslucencyKind.Additive, + TranslucencyKind.InvAlpha, + ]; + var groups = kinds.Select((kind, index) => new WbDrawDispatcher.IndirectGroupInput( + IndexCount: 3, + FirstIndex: (uint)(index * 3), + BaseVertex: 0, + InstanceCount: 1, + FirstInstance: index, + TextureIndex: (uint)index, + TextureLayer: 0, + Translucency: kind)).ToList(); + var indirect = new DrawElementsIndirectCommand[kinds.Length]; + var batches = new WbDrawDispatcher.BatchDataPublic[kinds.Length]; + + WbDrawDispatcher.BuildIndirectArrays(groups, indirect, batches); + + Assert.All(batches, batch => Assert.Equal(1u, batch.Flags)); + } + + [Fact] + public void DetailCategoryPredicateCoversEveryInstanceInAnIndirectCommand() + { + var command = new DrawElementsIndirectCommand + { + BaseInstance = 2, + InstanceCount = 3, + }; + + Assert.True(WbDrawDispatcher.CommandContainsDetailCategory( + command, + [0u, 0u, 0u, 1u, 0u])); + Assert.False(WbDrawDispatcher.CommandContainsDetailCategory( + command, + [1u, 1u, 0u, 0u, 0u])); + Assert.Throws(() => + WbDrawDispatcher.CommandContainsDetailCategory( + command, + [0u, 0u, 0u, 1u])); + } + + [Fact] + public void OpaqueDetailRunsSkipNonbuildingCommandsAndKeepMixedCommands() + { + DrawElementsIndirectCommand[] commands = + [ + new() { BaseInstance = 0, InstanceCount = 2 }, + new() { BaseInstance = 2, InstanceCount = 2 }, + new() { BaseInstance = 4, InstanceCount = 1 }, + ]; + + // Command 1 is mixed: instance 2 is ordinary and instance 3 is a + // building. It must be submitted once, then mesh_detail filters the + // ordinary instance. Commands 0 and 2 must never reach the detail pipe. + uint[] mixedCategories = [0u, 0u, 0u, 1u, 0u]; + Assert.True(WbDrawDispatcher.TryGetNextDetailCommandRun( + commands, + mixedCategories, + searchStart: 0, + exclusiveEnd: commands.Length, + out WbDrawDispatcher.DetailCommandRun mixedRun)); + Assert.Equal(new WbDrawDispatcher.DetailCommandRun(1, 1), mixedRun); + Assert.False(WbDrawDispatcher.TryGetNextDetailCommandRun( + commands, + mixedCategories, + searchStart: mixedRun.FirstCommand + mixedRun.CommandCount, + exclusiveEnd: commands.Length, + out _)); + + Assert.False(WbDrawDispatcher.TryGetNextDetailCommandRun( + commands, + new uint[5], + searchStart: 0, + exclusiveEnd: commands.Length, + out _)); + } + + [Fact] + public void OpaqueDetailRunsCoalesceConsecutiveEligibleCommands() + { + DrawElementsIndirectCommand[] commands = + [ + new() { BaseInstance = 0, InstanceCount = 1 }, + new() { BaseInstance = 1, InstanceCount = 1 }, + new() { BaseInstance = 2, InstanceCount = 1 }, + new() { BaseInstance = 3, InstanceCount = 1 }, + ]; + uint[] categories = [0u, 1u, 1u, 0u]; + + Assert.True(WbDrawDispatcher.TryGetNextDetailCommandRun( + commands, + categories, + searchStart: 0, + exclusiveEnd: commands.Length, + out WbDrawDispatcher.DetailCommandRun run)); + Assert.Equal(new WbDrawDispatcher.DetailCommandRun(1, 2), run); + } + [Fact] public void BatchDataPublic_LayoutMatchesPrivateBatchData() { diff --git a/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs b/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs index efdce837..3bbfd284 100644 --- a/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs +++ b/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs @@ -226,4 +226,148 @@ public class LandblockMeshTests Assert.Equal(10.0f, atX48Y0.Position.Z); Assert.Equal(0.0f, atX0Y48.Position.Z); } + + [Fact] + public void Build_NormalsMatchRetailIncidentFaceAverages_NotCentralDifferences() + { + // A deliberately non-planar surface makes retail's split-aware + // incident-plane average observably different from the former + // central-difference approximation. + var block = BuildFlatLandBlock(); + for (int x = 0; x < LandblockMesh.HeightmapSide; x++) + for (int y = 0; y < LandblockMesh.HeightmapSide; y++) + block.Height[x * LandblockMesh.HeightmapSide + y] = + (byte)((x * x * 3 + y * y * 5 + x * y * 11 + x * 7 + y * 13) % 96); + + const uint landblockX = 0xA9; + const uint landblockY = 0xB4; + var mesh = LandblockMesh.Build( + block, + landblockX, + landblockY, + IdentityHeightTable, + MakeContext(), + new Dictionary()); + + // Independent geometry oracle: derive each polygon plane from the + // actual emitted positions/indices, accumulate it at the shared + // position, and normalize only after every incident polygon is seen. + var incidentNormalSums = new Dictionary(); + for (int i = 0; i < mesh.Indices.Length; i += 3) + { + Vector3 p0 = mesh.Vertices[mesh.Indices[i]].Position; + Vector3 p1 = mesh.Vertices[mesh.Indices[i + 1]].Position; + Vector3 p2 = mesh.Vertices[mesh.Indices[i + 2]].Position; + Vector3 planeNormal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0)); + + AddNormal(incidentNormalSums, p0, planeNormal); + AddNormal(incidentNormalSums, p1, planeNormal); + AddNormal(incidentNormalSums, p2, planeNormal); + } + + foreach (TerrainVertex vertex in mesh.Vertices) + { + Vector3 expected = Vector3.Normalize(incidentNormalSums[vertex.Position]); + AssertVectorNear(expected, vertex.Normal, 1e-6f); + Assert.InRange(vertex.Normal.Length(), 1f - 1e-6f, 1f + 1e-6f); + } + + bool differsFromCentralDifferences = false; + for (int x = 0; x < LandblockMesh.HeightmapSide; x++) + { + for (int y = 0; y < LandblockMesh.HeightmapSide; y++) + { + int xL = Math.Max(x - 1, 0); + int xR = Math.Min(x + 1, LandblockMesh.HeightmapSide - 1); + int yD = Math.Max(y - 1, 0); + int yU = Math.Min(y + 1, LandblockMesh.HeightmapSide - 1); + float dx = (HeightAt(block, xR, y) - HeightAt(block, xL, y)) / + ((xR - xL) * LandblockMesh.CellSize); + float dy = (HeightAt(block, x, yU) - HeightAt(block, x, yD)) / + ((yU - yD) * LandblockMesh.CellSize); + Vector3 oldApproximation = Vector3.Normalize(new Vector3(-dx, -dy, 1f)); + Vector3 position = new( + x * LandblockMesh.CellSize, + y * LandblockMesh.CellSize, + HeightAt(block, x, y)); + Vector3 actual = mesh.Vertices.First(vertex => vertex.Position == position).Normal; + differsFromCentralDifferences |= Vector3.Distance(oldApproximation, actual) > 1e-4f; + } + } + + Assert.True( + differsFromCentralDifferences, + "Synthetic terrain failed to distinguish retail incident-face averaging from central differences."); + } + + [Theory] + [InlineData(0u, 0u)] + [InlineData(0xA9u, 0xB4u)] + public void Build_RetailNormalChange_PreservesExactSplitAwarePositionsAndIndices( + uint landblockX, + uint landblockY) + { + var block = BuildFlatLandBlock(); + for (int x = 0; x < LandblockMesh.HeightmapSide; x++) + for (int y = 0; y < LandblockMesh.HeightmapSide; y++) + block.Height[x * LandblockMesh.HeightmapSide + y] = + (byte)((x * 17 + y * 29 + x * y * 3) % 80); + + var mesh = LandblockMesh.Build( + block, + landblockX, + landblockY, + IdentityHeightTable, + MakeContext(), + new Dictionary()); + + Assert.Equal( + Enumerable.Range(0, LandblockMesh.VerticesPerLandblock).Select(i => (uint)i), + mesh.Indices); + + int vertexIndex = 0; + for (int cy = 0; cy < LandblockMesh.CellsPerSide; cy++) + { + for (int cx = 0; cx < LandblockMesh.CellsPerSide; cx++) + { + Vector3 bl = PositionAt(block, cx, cy); + Vector3 br = PositionAt(block, cx + 1, cy); + Vector3 tr = PositionAt(block, cx + 1, cy + 1); + Vector3 tl = PositionAt(block, cx, cy + 1); + Vector3[] expected = TerrainBlending.CalculateSplitDirection( + landblockX, (uint)cx, landblockY, (uint)cy) == CellSplitDirection.SWtoNE + ? [bl, br, tr, bl, tr, tl] + : [bl, br, tl, br, tr, tl]; + + foreach (Vector3 position in expected) + Assert.Equal(position, mesh.Vertices[vertexIndex++].Position); + } + } + + Assert.Equal(LandblockMesh.VerticesPerLandblock, vertexIndex); + } + + private static float HeightAt(LandBlock block, int x, int y) => + IdentityHeightTable[block.Height[x * LandblockMesh.HeightmapSide + y]]; + + private static Vector3 PositionAt(LandBlock block, int x, int y) => new( + x * LandblockMesh.CellSize, + y * LandblockMesh.CellSize, + HeightAt(block, x, y)); + + private static void AddNormal( + IDictionary sums, + Vector3 position, + Vector3 normal) + { + sums.TryGetValue(position, out Vector3 sum); + sums[position] = sum + normal; + } + + private static void AssertVectorNear(Vector3 expected, Vector3 actual, float epsilon) + { + Assert.InRange(actual.X, expected.X - epsilon, expected.X + epsilon); + Assert.InRange(actual.Y, expected.Y - epsilon, expected.Y + epsilon); + Assert.InRange(actual.Z, expected.Z - epsilon, expected.Z + epsilon); + } } diff --git a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs index e01bf55a..1edb6ded 100644 --- a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs +++ b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; using System.Numerics; +using AcDream.Core.Content; using AcDream.Core.World; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; +using DatReaderWriter.Lib.IO; using DatReaderWriter.Types; using Xunit; @@ -126,6 +128,55 @@ public sealed class SkyDescLoaderTests Assert.Equal(0x01004C44u, obj.GfxObjId); Assert.Equal(0x3300042Cu, obj.PesObjectId); Assert.True(obj.IsPostScene); + Assert.Equal(Vector3.Zero, obj.AuthoredSortCenter); + } + + [Fact] + public void LoadFromRegion_WithDatSourceCarriesDefaultAndReplacementSortCenters() + { + const uint defaultId = 0x01001348u; + const uint replacementId = 0x01001F6Au; + Vector3 defaultCenter = new(1050f, 0f, 0f); + Vector3 replacementCenter = new(2066.82f, 552.99f, 0f); + Region region = MakeRegion(dirBright: 1f, rBgrOrder: 255); + DayGroup group = region.SkyInfo!.DayGroups[0]; + group.SkyObjects.Add(new SkyObject + { + DefaultGfxObjectId = defaultId, + }); + group.SkyTime[0].SkyObjReplace.Add(new SkyObjectReplace + { + ObjectIndex = 0, + GfxObjId = replacementId, + }); + var dats = new FakeDatObjectSource(); + dats.Add(defaultId, new GfxObj { SortCenter = defaultCenter }); + dats.Add(replacementId, new GfxObj { SortCenter = replacementCenter }); + + LoadedSkyDesc loaded = Assert.IsType( + SkyDescLoader.LoadFromRegion(region, dats)); + + Assert.Equal(defaultCenter, + Assert.Single(loaded.DayGroups[0].SkyObjects).AuthoredSortCenter); + Assert.Equal(replacementCenter, + Assert.Single(loaded.DayGroups[0].SkyTimes[0].Replaces).AuthoredSortCenter); + } + + [Fact] + public void LoadFromRegion_FailedOptionalSortCenterLookupCannotFailSkyLoading() + { + Region region = MakeRegion(dirBright: 1f, rBgrOrder: 255); + region.SkyInfo!.DayGroups[0].SkyObjects.Add(new SkyObject + { + DefaultGfxObjectId = 0x01001348u, + }); + + LoadedSkyDesc loaded = Assert.IsType( + SkyDescLoader.LoadFromRegion(region, new ThrowingDatObjectSource())); + + Assert.Equal( + Vector3.Zero, + Assert.Single(loaded.DayGroups[0].SkyObjects).AuthoredSortCenter); } [Fact] @@ -201,4 +252,31 @@ public sealed class SkyDescLoaderTests // At begin → begin angle. Assert.Equal(0f, obj.CurrentAngle(0.25f), precision: 2); } + + private sealed class FakeDatObjectSource : IDatObjectSource + { + private readonly Dictionary _objects = []; + + internal void Add(uint id, IDBObj value) => _objects[id] = value; + + public T Get(uint fileId) where T : IDBObj => + _objects.TryGetValue(fileId, out IDBObj? value) && value is T typed + ? typed + : default!; + + public bool TryGet(uint fileId, out T value) where T : IDBObj + { + value = Get(fileId); + return value is not null; + } + } + + private sealed class ThrowingDatObjectSource : IDatObjectSource + { + public T Get(uint fileId) where T : IDBObj => + throw new InvalidOperationException("synthetic DAT lookup failure"); + + public bool TryGet(uint fileId, out T value) where T : IDBObj => + throw new InvalidOperationException("synthetic DAT lookup failure"); + } } diff --git a/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs index 8e63eec3..30da32c4 100644 --- a/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs @@ -108,6 +108,45 @@ public sealed class HeadlessPluginSessionTests Assert.DoesNotContain("fixture-", output.ToString()); } + [Fact] + public void RenderPackOnlyRequest_IsRejectedBeforeHeadlessLoadsItsDll() + { + using var temporary = new TemporaryDirectory(); + const string renderPackId = "acdream.test.render-only"; + string pluginDirectory = Path.Combine(temporary.Path, "render-only"); + Directory.CreateDirectory(pluginDirectory); + File.WriteAllText( + Path.Combine(pluginDirectory, "plugin.json"), + JsonSerializer.Serialize(new + { + id = renderPackId, + displayName = "Render only", + version = "1.0.0", + entryDll = "deliberately-missing.dll", + apiVersion = 1, + kinds = new[] { "renderPack" }, + })); + string statusPath = Path.Combine(temporary.Path, "status.jsonl"); + var credential = new HeadlessCredentialSecret("fixture", "password"); + using var session = new HeadlessSessionHost( + Descriptor([renderPackId], statusPath), + credential, + new HeadlessDiagnosticWriter(new StringWriter()), + new FixtureSessionOperations(), + pluginRoots: [temporary.Path]); + + _ = session.Start(); + + Assert.Equal(0, session.Plugins.LoadedCount); + Assert.Empty(session.Plugins.CaptureLoadContextWeakReferences()); + JsonElement failed = ReadStatuses(statusPath) + .Single(static item => + item.GetProperty("e").GetString() == "pluginFailed"); + string error = failed.GetProperty("error").GetString()!; + Assert.Contains("does not support", error); + Assert.DoesNotContain("entry dll", error, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void ThrowAfterRegistrationRollsBackEventsAndCollectsContext() { diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs index f847916b..c1476ffd 100644 --- a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs @@ -28,13 +28,25 @@ if (!string.IsNullOrWhiteSpace(selfUpdateData) using var http = new HttpClient(); var manager = new LauncherSelfUpdateManager(Paths(selfUpdateData), http); - SelfUpdateStartupResult startup = await LauncherSelfUpdateBootstrap.HandleAsync( - effectiveArgs, - manager, - Path.GetFullPath(AppContext.BaseDirectory), - Path.GetFullPath( - Environment.ProcessPath - ?? throw new InvalidOperationException("Process path is unavailable."))); + SelfUpdateStartupResult startup; + try + { + startup = await LauncherSelfUpdateBootstrap.HandleAsync( + effectiveArgs, + manager, + Path.GetFullPath(AppContext.BaseDirectory), + Path.GetFullPath( + Environment.ProcessPath + ?? throw new InvalidOperationException("Process path is unavailable."))); + } + catch (LauncherUpdateException) + { + // Refusal is an expected result in the hostile-state process tests. + // Translate it to a stable non-zero code instead of invoking the OS + // unhandled-exception path, which can launch a crash reporter and make + // a process-lifetime test wait on diagnostics rather than our result. + return 74; + } if (startup.ShouldExit) { return startup.ExitCode; @@ -149,11 +161,19 @@ static async Task BootstrapProbeAsync(string[] arguments) using var http = new HttpClient(); var manager = new LauncherSelfUpdateManager(Paths(arguments[0]), http); - SelfUpdateStartupResult result = await LauncherSelfUpdateBootstrap.HandleAsync( - ["ordinary"], - manager, - Path.GetFullPath(arguments[1]), - Path.GetFullPath(arguments[2])); + SelfUpdateStartupResult result; + try + { + result = await LauncherSelfUpdateBootstrap.HandleAsync( + ["ordinary"], + manager, + Path.GetFullPath(arguments[1]), + Path.GetFullPath(arguments[2])); + } + catch (LauncherUpdateException) + { + return 74; + } File.WriteAllText( Path.GetFullPath(arguments[3]), result.ShouldExit ? "exit" : string.Join("\n", result.RemainingArguments)); diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs index f63b5638..d258bc7f 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs @@ -12,6 +12,9 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable private const string TargetEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_TARGET"; private const string HelperPidEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_HELPER_PID"; + private static readonly System.Runtime.CompilerServices.ConditionalWeakTable< + Process, + ProcessOutputCapture> ProcessOutput = new(); private readonly string _root = Path.Combine( Path.GetTempPath(), @@ -361,8 +364,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable ], environment); await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); - string helperError = await helper.StandardError.ReadToEndAsync(); - string helperOutput = await helper.StandardOutput.ReadToEndAsync(); + string helperError = await ReadStandardErrorAsync(helper); + string helperOutput = await ReadStandardOutputAsync(helper); Assert.True( helper.ExitCode == LauncherSelfUpdateBootstrap.UpdateLeaseBusyExitCode, $"helper exit {helper.ExitCode}; stdout: {helperOutput}; stderr: {helperError}"); @@ -481,7 +484,7 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable { using Process process = StartProcess(canonicalPath, arguments, environment); await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); - string stderr = await process.StandardError.ReadToEndAsync(); + string stderr = await ReadStandardErrorAsync(process); if (exactExit.HasValue) { Assert.True( @@ -699,10 +702,26 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable } } - return Process.Start(start) + Process process = Process.Start(start) ?? throw new InvalidOperationException($"Could not start '{executable}'."); + ProcessOutput.Add( + process, + new ProcessOutputCapture( + process.StandardOutput.ReadToEndAsync(), + process.StandardError.ReadToEndAsync())); + return process; } + private static Task ReadStandardOutputAsync(Process process) => + ProcessOutput.TryGetValue(process, out ProcessOutputCapture? capture) + ? capture.StandardOutput + : process.StandardOutput.ReadToEndAsync(); + + private static Task ReadStandardErrorAsync(Process process) => + ProcessOutput.TryGetValue(process, out ProcessOutputCapture? capture) + ? capture.StandardError + : process.StandardError.ReadToEndAsync(); + private static async Task WaitForFileAsync( string path, Process? process, @@ -726,9 +745,9 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable { throw new InvalidOperationException( $"{failure} Process exited {process.ExitCode}. stdout: " - + await process.StandardOutput.ReadToEndAsync() + + await ReadStandardOutputAsync(process) + " stderr: " - + await process.StandardError.ReadToEndAsync()); + + await ReadStandardErrorAsync(process)); } if (DateTimeOffset.UtcNow >= deadline) @@ -766,6 +785,10 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable private static string GetFixtureDllPath() => Path.Combine(GetFixtureDirectory(), FixtureBaseName + ".dll"); + private sealed record ProcessOutputCapture( + Task StandardOutput, + Task StandardError); + private static string GetFixtureDirectory() { string configuration = new DirectoryInfo(AppContext.BaseDirectory) diff --git a/tests/AcDream.Platform.Tests/ApplicationPathSetTests.cs b/tests/AcDream.Platform.Tests/ApplicationPathSetTests.cs index 7b16a89d..456a7143 100644 --- a/tests/AcDream.Platform.Tests/ApplicationPathSetTests.cs +++ b/tests/AcDream.Platform.Tests/ApplicationPathSetTests.cs @@ -117,6 +117,60 @@ public sealed class ApplicationPathSetTests paths.LegacyConfigDirectory); } + [Fact] + public void CrossPlatformEnvironmentOverridesIsolateAllMutableUserState() + { + string root = Path.GetFullPath( + Path.Combine(Path.GetTempPath(), "acdream-isolated-automation")); + var platform = new FixtureEnvironment(isWindows: true) + { + CurrentDirectoryValue = root, + ApplicationData = Path.Combine(root, "real-roaming"), + LocalApplicationData = Path.Combine(root, "real-local"), + Variables = + { + ["ACDREAM_CONFIG_DIR"] = "capture-config", + ["ACDREAM_DATA_DIR"] = "capture-data", + ["ACDREAM_CACHE_DIR"] = "capture-cache", + }, + }; + + ApplicationPathSet paths = ApplicationPathSet.Resolve(platform: platform); + + Assert.Equal(Path.Combine(root, "capture-config"), paths.ConfigDirectory); + Assert.Equal(Path.Combine(root, "capture-data"), paths.DataDirectory); + Assert.Equal(Path.Combine(root, "capture-cache"), paths.CacheDirectory); + Assert.Null(paths.LegacyConfigDirectory); + } + + [Fact] + public void ExplicitArgumentsOverrideAutomationEnvironmentRoots() + { + string root = Path.GetFullPath( + Path.Combine(Path.GetTempPath(), "acdream-explicit-paths")); + var platform = new FixtureEnvironment(isWindows: false) + { + CurrentDirectoryValue = root, + UserProfile = Path.Combine(root, "home"), + Variables = + { + ["ACDREAM_CONFIG_DIR"] = "environment-config", + ["ACDREAM_DATA_DIR"] = "environment-data", + ["ACDREAM_CACHE_DIR"] = "environment-cache", + }, + }; + + ApplicationPathSet paths = ApplicationPathSet.Resolve( + "explicit-config", + "explicit-data", + "explicit-cache", + platform); + + Assert.Equal(Path.Combine(root, "explicit-config"), paths.ConfigDirectory); + Assert.Equal(Path.Combine(root, "explicit-data"), paths.DataDirectory); + Assert.Equal(Path.Combine(root, "explicit-cache"), paths.CacheDirectory); + } + private sealed class FixtureEnvironment(bool isWindows) : IApplicationPathEnvironment { diff --git a/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs index b294f697..6aa42997 100644 --- a/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs +++ b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs @@ -1,4 +1,5 @@ using AcDream.Plugin.Abstractions; +using AcDream.Plugin.Abstractions.Rendering; using System.Runtime.Loader; namespace AcDream.Plugin.Tests.Fixtures.HostPlugin; @@ -9,7 +10,7 @@ namespace AcDream.Plugin.Tests.Fixtures.HostPlugin; /// gameplay events. A headless registry must make the UI call harmless without /// retaining this instance in the default load context. /// -public sealed class HostPlugin : IAcDreamPlugin +public sealed class HostPlugin : IAcDreamPlugin, IRenderPackPlugin, IRenderPackAssets { private IPluginHost? _host; private string? _assemblyDirectory; @@ -67,6 +68,63 @@ public sealed class HostPlugin : IAcDreamPlugin _host = null; } + public void Register(IRenderPackRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + string directory = Path.GetDirectoryName( + typeof(HostPlugin).Assembly.Location)!; + if (File.Exists(Path.Combine(directory, "register-no-render-packs"))) + return; + string versionPath = Path.Combine(directory, "render-pack-version.txt"); + Version version = File.Exists(versionPath) + ? Version.Parse(File.ReadAllText(versionPath).Trim()) + : new Version(1, 0, 0); + _ = registry.Register( + new RenderPackDescriptor( + Id: "acdream.test.external-render-pack", + DisplayName: "External graphical render-pack fixture", + PackVersion: version, + PackApiVersion: RenderPackApi.Current, + HighestTier: RenderPackTier.Tier1, + RequiredCapabilities: [], + OptionalCapabilities: [], + Resources: [], + Passes: [], + SceneReplays: [], + PipelineVariants: [], + QualityPresets: + [ + Preset("low", "Low"), + Preset("high", "High"), + ], + Settings: [], + AtmospherePolicy: null) + { + FeatureSummary = "Collectible external no-op render-pack fixture.", + }, + this); + if (File.Exists(Path.Combine(directory, "throw-after-render-pack-register"))) + { + throw new InvalidOperationException( + "fixture render-pack registration failed after publishing its descriptor"); + } + } + + public Stream OpenRead(string assetKey) => + new MemoryStream([], writable: false); + + private static RenderQualityPreset Preset(string id, string displayName) => new( + id, + displayName, + RequiredCapabilities: [], + ResourceOverrides: [], + SettingOverrides: [], + MaxResidentGpuBytes: 0, + MaxIncrementalGpuMillisecondsP50: 0, + MaxIncrementalGpuMillisecondsP99: 0, + MaxIncrementalCpuMillisecondsP50: 0, + MaxIncrementalCpuMillisecondsP99: 0); + private void RegisterHostCallbacks(IPluginHost host) { host.Ui.AddMarkupPanel( diff --git a/tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal.csproj b/tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal.csproj new file mode 100644 index 00000000..161c2f57 --- /dev/null +++ b/tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal.csproj @@ -0,0 +1,14 @@ + + + net10.0 + enable + enable + false + + + + false + runtime + + + diff --git a/tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal/InternalRenderPackPlugin.cs b/tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal/InternalRenderPackPlugin.cs new file mode 100644 index 00000000..3c22e72e --- /dev/null +++ b/tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal/InternalRenderPackPlugin.cs @@ -0,0 +1,8 @@ +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal; + +internal sealed class InternalRenderPackPlugin : IRenderPackPlugin +{ + public void Register(IRenderPackRegistry registry) { } +} diff --git a/tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal/packages.neutral.lock.json b/tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal/packages.neutral.lock.json new file mode 100644 index 00000000..3924e2e2 --- /dev/null +++ b/tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal/packages.neutral.lock.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "acdream.plugin.abstractions": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple.csproj b/tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple.csproj new file mode 100644 index 00000000..161c2f57 --- /dev/null +++ b/tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple.csproj @@ -0,0 +1,14 @@ + + + net10.0 + enable + enable + false + + + + false + runtime + + + diff --git a/tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple/MultipleRenderPackPlugins.cs b/tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple/MultipleRenderPackPlugins.cs new file mode 100644 index 00000000..88e07805 --- /dev/null +++ b/tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple/MultipleRenderPackPlugins.cs @@ -0,0 +1,13 @@ +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple; + +public sealed class FirstRenderPackPlugin : IRenderPackPlugin +{ + public void Register(IRenderPackRegistry registry) { } +} + +public sealed class SecondRenderPackPlugin : IRenderPackPlugin +{ + public void Register(IRenderPackRegistry registry) { } +} diff --git a/tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple/packages.neutral.lock.json b/tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple/packages.neutral.lock.json new file mode 100644 index 00000000..3924e2e2 --- /dev/null +++ b/tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple/packages.neutral.lock.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "acdream.plugin.abstractions": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs new file mode 100644 index 00000000..a3c20dad --- /dev/null +++ b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs @@ -0,0 +1,251 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank.Tests; + +public sealed class MossTankPanelTests +{ + [Fact] + public void RetainedLabelReads_UseUpdateSideSnapshotsWithoutAllocating() + { + var automation = new FakeAutomation + { + CurrentHealth = 90, + MaxHealth = 100, + CurrentStamina = 80, + MaxStamina = 110, + CurrentMana = 70, + MaxMana = 120, + Skills = + [ + new PluginSkillInfo(1, "Life Magic", PluginSkillTraining.Trained, 300), + new PluginSkillInfo(2, "War Magic", PluginSkillTraining.Specialized, 350), + new PluginSkillInfo(3, "Run", PluginSkillTraining.Untrained, 100), + ], + Attributes = + [ + new PluginAttributeInfo(0, "Strength", 100), + new PluginAttributeInfo(1, "Endurance", 100), + ], + KnownSelfBuffs = + [ + Spell(1, 10, "Increases the caster's Life Magic skill by 10 points."), + ], + }; + var panel = new MossTankPanel(new FakeHost(automation)); + + panel.OnTick(0.0); + + Assert.Equal("Health 90/100 Stam 80/110 Mana 70/120", panel.Vitals); + Assert.Equal("2 attributes, 2 trained skills, 1 buff lines", panel.Coverage); + string expectedVitals = panel.Vitals; + string expectedCoverage = panel.Coverage; + + _ = panel.Vitals; + _ = panel.Coverage; + long before = GC.GetAllocatedBytesForCurrentThread(); + bool sameReferences = true; + for (int i = 0; i < 10_000; i++) + { + sameReferences &= ReferenceEquals(expectedVitals, panel.Vitals); + sameReferences &= ReferenceEquals(expectedCoverage, panel.Coverage); + } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True(sameReferences); + Assert.Equal(0, allocated); + Assert.Equal(1, automation.KnownSelfBuffReads); + } + + [Fact] + public void UpdateTick_RefreshesRareCoverageAndChangedVitals() + { + var automation = new FakeAutomation + { + CurrentHealth = 90, + MaxHealth = 100, + CurrentStamina = 80, + MaxStamina = 110, + CurrentMana = 70, + MaxMana = 120, + Skills = + [ + new PluginSkillInfo(1, "Life Magic", PluginSkillTraining.Trained, 300), + ], + Attributes = [new PluginAttributeInfo(0, "Strength", 100)], + KnownSelfBuffs = + [ + Spell(1, 10, "Increases the caster's Life Magic skill by 10 points."), + ], + }; + var panel = new MossTankPanel(new FakeHost(automation)); + panel.OnTick(0.0); + + automation.CurrentHealth = 75; + automation.Skills = + [ + new PluginSkillInfo(1, "Life Magic", PluginSkillTraining.Trained, 300), + new PluginSkillInfo(2, "War Magic", PluginSkillTraining.Specialized, 350), + ]; + panel.OnTick(0.5); + + Assert.StartsWith("Health 75/100", panel.Vitals, StringComparison.Ordinal); + Assert.Equal("1 attributes, 1 trained skills, 1 buff lines", panel.Coverage); + + panel.OnTick(0.5); + Assert.Equal("1 attributes, 2 trained skills, 1 buff lines", panel.Coverage); + + automation.KnownSelfBuffs = + [ + Spell(1, 10, "Increases the caster's Life Magic skill by 10 points."), + Spell(2, 11, "Increases the caster's War Magic skill by 10 points."), + ]; + panel.OnTick(0.0); + + Assert.Equal("1 attributes, 2 trained skills, 2 buff lines", panel.Coverage); + } + + private static PluginSpellInfo Spell(uint id, uint family, string description) => new( + id, + $"Spell {id}", + family, + Tier: 1, + Difficulty: 10, + ManaCost: 5, + DurationSeconds: 60f, + School: 1, + description, + IsSelfTargeted: true, + IsBeneficial: true); + + private sealed class FakeHost(IAutomationSurface automation) : IPluginHost + { + public bool HasUi => false; + public IPluginLogger Log { get; } = new FakeLogger(); + public IGameState State { get; } = new FakeState(); + public IEvents Events { get; } = new FakeEvents(); + public ISelectionService Selection { get; } = new FakeSelection(); + public IUiRegistry Ui => NoOpUiRegistry.Instance; + public IAutomationSurface Automation { get; } = automation; + } + + private sealed class FakeAutomation + : IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands, IPluginChat + { + private IReadOnlyList _knownSelfBuffs = []; + + public bool IsAvailable { get; set; } = true; + public ICharacterInfo Character => this; + public ISpellCatalog Spells => this; + public IMagicCommands Magic => this; + public IPluginChat Chat => this; + public bool IsInWorld => IsAvailable; + public uint ObjectId { get; set; } = 1; + public uint CurrentHealth { get; set; } + public uint MaxHealth { get; set; } + public uint CurrentStamina { get; set; } + public uint MaxStamina { get; set; } + public uint CurrentMana { get; set; } + public uint MaxMana { get; set; } + public IReadOnlyList Skills { get; set; } = []; + public IReadOnlyList Attributes { get; set; } = []; + public IReadOnlyList ActiveEnchantments { get; set; } = []; + + public int KnownSelfBuffReads { get; private set; } + + public IReadOnlyList KnownSelfBuffs + { + get + { + KnownSelfBuffReads++; + return _knownSelfBuffs; + } + set => _knownSelfBuffs = value; + } + + public bool TryGetSkill(uint skillId, out PluginSkillInfo skill) + { + foreach (PluginSkillInfo candidate in Skills) + { + if (candidate.SkillId == skillId) + { + skill = candidate; + return true; + } + } + skill = default; + return false; + } + + public bool TryGet(uint spellId, out PluginSpellInfo info) + { + foreach (PluginSpellInfo candidate in _knownSelfBuffs) + { + if (candidate.SpellId == spellId) + { + info = candidate; + return true; + } + } + info = default; + return false; + } + + public bool IsCasting => false; + public PluginCastGate EvaluateGate(uint spellId) => PluginCastGate.Ready; + public bool Cast(uint spellId) => true; + public void PostSystemMessage(string text) { } + } + + private sealed class FakeLogger : IPluginLogger + { + public void Info(string message) { } + public void Warn(string message) { } + public void Error(string message, Exception? exception = null) { } + } + + private sealed class FakeState : IGameState + { + public IReadOnlyList Entities => []; + } + + private sealed class FakeEvents : IEvents + { + public event Action EntitySpawned + { + add { } + remove { } + } + + public event Action Tick + { + add { } + remove { } + } + } + + private sealed class FakeSelection : ISelectionService + { + public uint? SelectedObjectId { get; private set; } + public uint? PreviousObjectId { get; private set; } + + public event Action Changed + { + add { } + remove { } + } + + public bool Select(uint objectId) + { + PreviousObjectId = SelectedObjectId; + SelectedObjectId = objectId; + return true; + } + + public bool Clear() + { + PreviousObjectId = SelectedObjectId; + SelectedObjectId = null; + return true; + } + } +} diff --git a/tests/AcDream.RenderPackValidator.Tests/AcDream.RenderPackValidator.Tests.csproj b/tests/AcDream.RenderPackValidator.Tests/AcDream.RenderPackValidator.Tests.csproj new file mode 100644 index 00000000..20709ee8 --- /dev/null +++ b/tests/AcDream.RenderPackValidator.Tests/AcDream.RenderPackValidator.Tests.csproj @@ -0,0 +1,31 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + + false + true + + + false + true + + + false + true + + + diff --git a/tests/AcDream.RenderPackValidator.Tests/RenderPackValidatorCommandTests.cs b/tests/AcDream.RenderPackValidator.Tests/RenderPackValidatorCommandTests.cs new file mode 100644 index 00000000..0e8e813e --- /dev/null +++ b/tests/AcDream.RenderPackValidator.Tests/RenderPackValidatorCommandTests.cs @@ -0,0 +1,1230 @@ +using System.Text.Json; +using AcDream.Plugin.Abstractions.Rendering; +using AcDream.Tools.RenderPackValidator; + +namespace AcDream.RenderPackValidator.Tests; + +public sealed class RenderPackValidatorCommandTests +{ + [Fact] + public void PublicShaderAbiConstantsDescribeV1() + { + Assert.Equal(3, RenderPackShaderAbi.UniformDescriptorSet); + Assert.Equal(5, RenderPackShaderAbi.AtmosphericFrameBinding); + Assert.Equal(160, RenderPackShaderAbi.AtmosphericFrameSizeBytes); + Assert.Equal(6, RenderPackShaderAbi.DirectionalShadowBinding); + Assert.Equal(336, RenderPackShaderAbi.DirectionalShadowSizeBytes); + Assert.Equal(7, RenderPackShaderAbi.PackPassBinding); + Assert.Equal(64, RenderPackShaderAbi.PackPassSizeBytes); + Assert.Equal(8, RenderPackShaderAbi.PackSettingsBinding); + Assert.Equal(256, RenderPackShaderAbi.PackSettingsSizeBytes); + Assert.Equal(64, RenderPackShaderAbi.PackSettingScalarCapacity); + Assert.Equal(2, RenderPackShaderAbi.SampledTextureDescriptorSet); + Assert.Equal(0, RenderPackShaderAbi.SampledTextureBinding); + Assert.Equal(4, RenderPackShaderAbi.SampledPassInputCapacity); + Assert.Equal(96, RenderPackShaderAbi.PushConstantSizeBytes); + Assert.Equal(16 * 1024 * 1024, RenderPackShaderAbi.MaximumShaderAssetBytes); + } + + [Fact] + public void ExternalNoOpSample_ValidatesWithoutAppOrVulkanDependency() + { + string root = FindRepositoryRoot(); + string projectPath = Path.Combine( + root, + "samples", + "AcDream.RenderPacks.NoOp", + "AcDream.RenderPacks.NoOp.csproj"); + string project = File.ReadAllText(projectPath); + Assert.Contains("AcDream.Plugin.Abstractions", project); + Assert.DoesNotContain("AcDream.App", project); + Assert.DoesNotContain("Silk.NET", project); + string outputDirectory = Path.Combine( + Path.GetDirectoryName(projectPath)!, + "bin", + "Release", + "net10.0"); + var output = new StringWriter(); + var error = new StringWriter(); + + int result = RenderPackValidatorCommand.Run( + [outputDirectory], + output, + error); + + Assert.Equal(0, result); + Assert.Contains("OK: sample.no-op-render-pack 1.0.0", output.ToString()); + Assert.Equal(string.Empty, error.ToString()); + } + + [Fact] + public void ExternalAtmosphericTierTwoSample_ValidatesPackagedShadersWithoutRendererDependency() + { + string root = FindRepositoryRoot(); + string projectPath = Path.Combine( + root, + "samples", + "AcDream.RenderPacks.AtmosphericTier2", + "AcDream.RenderPacks.AtmosphericTier2.csproj"); + string project = File.ReadAllText(projectPath); + Assert.Contains("AcDream.Plugin.Abstractions", project); + Assert.DoesNotContain("AcDream.App", project); + Assert.DoesNotContain("Silk.NET", project); + + string outputDirectory = Path.Combine( + Path.GetDirectoryName(projectPath)!, + "bin", + "Release", + "net10.0"); + Assert.False(File.Exists(Path.Combine( + outputDirectory, + "AcDream.Plugin.Abstractions.dll"))); + Assert.False(File.Exists(Path.Combine(outputDirectory, "AcDream.App.dll"))); + + var output = new StringWriter(); + var error = new StringWriter(); + int result = RenderPackValidatorCommand.Run( + [outputDirectory], + output, + error); + + Assert.Equal(0, result); + Assert.Contains( + "OK: sample.atmospheric-tier2 1.0.0 (API 1, 4 preset(s))", + output.ToString()); + Assert.Contains( + "Validated 1 render pack(s) from 'sample.atmospheric-tier2'", + output.ToString()); + Assert.Equal(string.Empty, error.ToString()); + } + + [Fact] + public void ExternalShadowsOnlyTierTwoSample_ValidatesAsAComposablePack() + { + string root = FindRepositoryRoot(); + string projectPath = Path.Combine( + root, + "samples", + "AcDream.RenderPacks.ShadowsOnlyTier2", + "AcDream.RenderPacks.ShadowsOnlyTier2.csproj"); + string project = File.ReadAllText(projectPath); + Assert.Contains("AcDream.Plugin.Abstractions", project); + Assert.DoesNotContain("AcDream.App", project); + Assert.DoesNotContain("Silk.NET", project); + + string outputDirectory = Path.Combine( + Path.GetDirectoryName(projectPath)!, + "bin", + "Release", + "net10.0"); + Assert.False(File.Exists(Path.Combine( + outputDirectory, + "AcDream.Plugin.Abstractions.dll"))); + Assert.False(File.Exists(Path.Combine(outputDirectory, "AcDream.App.dll"))); + + var output = new StringWriter(); + var error = new StringWriter(); + int result = RenderPackValidatorCommand.Run( + [outputDirectory], + output, + error); + + Assert.Equal(0, result); + Assert.Contains( + "OK: sample.shadows-only-tier2 1.0.0 (API 1, 1 preset(s))", + output.ToString()); + Assert.Contains( + "Validated 1 render pack(s) from 'sample.shadows-only-tier2'", + output.ToString()); + Assert.Equal(string.Empty, error.ToString()); + } + + [Fact] + public void AtmosphericSampleDescriptorValidatesSelectedCelestialContract() + { + RenderPackSdkValidationResult result = + RenderPackSdkValidator.ValidateDescriptor(AtmosphericDescriptor()); + + Assert.True(result.Success, result.Reason); + } + + [Fact] + public void MalformedManifest_FailsBeforeEntryDllInspection() + { + using var directory = new TemporaryDirectory(); + File.WriteAllText( + Path.Combine(directory.Path, "plugin.json"), + JsonSerializer.Serialize(new + { + id = "Bad Id", + displayName = "Bad pack", + version = "1.0.0", + entryDll = "missing.dll", + apiVersion = 1, + kinds = new[] { "renderPack" }, + })); + var output = new StringWriter(); + var error = new StringWriter(); + + int result = RenderPackValidatorCommand.Run( + [directory.Path], + output, + error); + + Assert.Equal(1, result); + Assert.Contains("stable lowercase logical id", error.ToString()); + Assert.DoesNotContain("does not exist", error.ToString()); + } + + [Fact] + public void ManifestWithoutRenderKind_IsRejectedPrecisely() + { + const string json = """ + { + "id": "sample.gameplay", + "displayName": "Gameplay only", + "version": "1.0.0", + "entryDll": "missing.dll", + "apiVersion": 1 + } + """; + + ValidationOutcome result = PackManifest.Parse(json); + + Assert.False(result.Success); + Assert.Contains("does not declare the renderPack kind", result.Reason); + } + + [Fact] + public void DescriptorReadBeforeWrite_IsRejectedPrecisely() + { + RenderPackDescriptor descriptor = Descriptor( + resources: + [ + new RenderResourceDeclaration( + "intermediate", + RenderResourceKind.Image2D, + RenderFormatClass.HdrColor, + new RenderExtentDeclaration( + RenderExtentMode.RelativeToMainWorld, + 0.5, + 0.5), + 0, + RenderResourceUsage.Sampled | RenderResourceUsage.ColorAttachment, + RenderResourceLifetime.ActivePack, + 1024), + ], + passes: + [ + new RenderPassDeclaration( + "bad-pass", + RenderPassHook.ToneMap, + "shader.vert.spv", + "shader.frag.spv", + [], + ["intermediate"], + []), + ]); + + RenderPackSdkValidationResult result = + RenderPackSdkValidator.ValidateDescriptor(descriptor); + + Assert.False(result.Success); + Assert.Equal( + "Pass 'bad-pass' reads resource 'intermediate' before it is written.", + result.Reason); + } + + [Fact] + public void InvalidShaderAsset_IsRejectedWithoutGpuDependency() + { + RenderPackDescriptor descriptor = Descriptor( + passes: + [ + new RenderPassDeclaration( + "pass", + RenderPassHook.ToneMap, + "shader.vert.spv", + "shader.frag.spv", + [], + [], + []), + ]); + + RenderPackSdkValidationResult result = RenderPackSdkValidator.ValidateAssets( + descriptor, + new ByteAssets([0, 1, 2, 3])); + + Assert.False(result.Success); + Assert.Contains("is not valid SPIR-V", result.Reason); + } + + [Fact] + public void ShaderBinaryInterface_IsValidatedByTheStandaloneSdk() + { + RenderPackDescriptor descriptor = Descriptor( + passes: + [ + new RenderPassDeclaration( + "pass", + RenderPassHook.ToneMap, + "reserved-set.vert.spv", + "unused.frag.spv", + [], + [], + []), + ]); + string root = FindRepositoryRoot(); + byte[] reservedSetShader = File.ReadAllBytes(Path.Combine( + root, + "src", + "AcDream.App", + "Rendering", + "Shaders", + "spv", + "directional_shadow_world_opaque.vert.spv")); + + RenderPackSdkValidationResult result = RenderPackSdkValidator.ValidateAssets( + descriptor, + new MappedAssets(new Dictionary + { + ["reserved-set.vert.spv"] = reservedSetShader, + })); + + Assert.False(result.Success); + Assert.Contains("set 0 binding 0", result.Reason); + } + + [Fact] + public void DuplicateRendererOwnedResourceSemantic_IsRejectedByTheSdk() + { + RenderPackDescriptor descriptor = Descriptor( + resources: + [ + Image("first") with { Semantic = RenderResourceSemantic.BloomPing }, + Image("second") with { Semantic = RenderResourceSemantic.BloomPing }, + ]); + + RenderPackSdkValidationResult result = + RenderPackSdkValidator.ValidateDescriptor(descriptor); + + Assert.False(result.Success); + Assert.Contains("duplicate resource semantic 'BloomPing'", result.Reason); + } + + [Fact] + public void PartialFixedAtmosphericExecutor_IsRejectedByTheSdk() + { + RenderPassDeclaration partial = new( + "rays", + RenderPassHook.ToneMap, + "shader.vert.spv", + "shader.frag.spv", + [], + [], + []) + { + Semantic = RenderPassSemantic.SunRays, + }; + + RenderPackSdkValidationResult result = + RenderPackSdkValidator.ValidateDescriptor(Descriptor(passes: [partial])); + + Assert.False(result.Success); + Assert.Contains("does not declare required pass semantic", result.Reason); + } + + [Fact] + public void FixedAtmosphericResourceShape_IsEnforcedByTheSdk() + { + RenderPackDescriptor descriptor = AtmosphericDescriptor(); + descriptor = descriptor with + { + Resources = descriptor.Resources.Select(resource => + resource.Semantic == RenderResourceSemantic.SunOcclusionMask + ? resource with { Format = RenderFormatClass.HdrColor } + : resource).ToArray(), + }; + + RenderPackSdkValidationResult result = + RenderPackSdkValidator.ValidateDescriptor(descriptor); + + Assert.False(result.Success); + Assert.Contains("Resource semantic 'SunOcclusionMask'", result.Reason); + Assert.Contains("kind, format, extent, usage, and lifetime", result.Reason); + } + + [Fact] + public void FixedAtmosphericVariantShape_IsEnforcedByTheSdk() + { + RenderPackDescriptor descriptor = AtmosphericDescriptor(); + descriptor = descriptor with + { + PipelineVariants = descriptor.PipelineVariants.Select(variant => + variant.Semantic == RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver + ? variant with { BaseSemantic = RenderPipelineBaseSemantic.Terrain } + : variant).ToArray(), + }; + + RenderPackSdkValidationResult result = + RenderPackSdkValidator.ValidateDescriptor(descriptor); + + Assert.False(result.Success); + Assert.Contains( + "Pipeline variant semantic 'WorldDirectionalShadowReceiver'", + result.Reason); + Assert.Contains("base, material, and input contract", result.Reason); + } + + [Fact] + public void SelectedCelestialSemanticRequiresAuthoredCelestialCapabilityInTheSdk() + { + RenderPackDescriptor descriptor = AtmosphericDescriptor(); + descriptor = descriptor with + { + RequiredCapabilities = descriptor.RequiredCapabilities + .Where(static capability => + capability != RenderCapability.AuthoredCelestialDirectionalLight) + .ToArray(), + }; + + RenderPackSdkValidationResult result = + RenderPackSdkValidator.ValidateDescriptor(descriptor); + + Assert.False(result.Success); + Assert.Equal( + $"Pack '{descriptor.Id}' declares semantic " + + $"'{RenderSemanticInput.SelectedCelestialDirectionalLight}' but does not " + + $"require capability '{RenderCapability.AuthoredCelestialDirectionalLight}'.", + result.Reason); + } + + [Fact] + public void DirectionalShadowDepthRejectsSunDirectionAliasInTheSdk() + { + RenderPackDescriptor descriptor = AtmosphericDescriptor(); + RenderPassDeclaration shadowPass = descriptor.Passes.Single(static pass => + pass.Semantic == RenderPassSemantic.DirectionalShadowDepth); + descriptor = descriptor with + { + Passes = descriptor.Passes.Select(pass => ReferenceEquals(pass, shadowPass) + ? pass with + { + SemanticInputs = pass.SemanticInputs + .Append(RenderSemanticInput.SunDirection) + .ToArray(), + } + : pass).ToArray(), + }; + + RenderPackSdkValidationResult result = + RenderPackSdkValidator.ValidateDescriptor(descriptor); + + Assert.False(result.Success); + Assert.Equal( + $"Directional-shadow pass '{shadowPass.Id}' must declare " + + $"'{RenderSemanticInput.SelectedCelestialDirectionalLight}' and must not " + + "alias the sun-specific atmospheric direction.", + result.Reason); + } + + [Fact] + public void StandaloneSdkRequiresSixMember336ByteDirectionalShadowBlock() + { + RenderPackDescriptor atmospheric = AtmosphericDescriptor(); + PipelineVariantDeclaration variant = atmospheric.PipelineVariants.Single(static value => + value.Semantic + == RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster); + RenderPackDescriptor descriptor = Descriptor() with + { + PipelineVariants = [variant], + }; + string shaderDirectory = Path.Combine( + FindRepositoryRoot(), + "src", + "AcDream.App", + "Rendering", + "Shaders", + "spv"); + byte[] validVertex = File.ReadAllBytes(Path.Combine( + shaderDirectory, + "directional_shadow_world_opaque.vert.spv")); + byte[] validFragment = File.ReadAllBytes(Path.Combine( + shaderDirectory, + "directional_shadow_world_opaque.frag.spv")); + + Assert.Equal(336, RenderPackShaderAbi.DirectionalShadowSizeBytes); + Assert.Equal( + 6, + BlockMemberCount( + validVertex, + RenderPackShaderAbi.UniformDescriptorSet, + RenderPackShaderAbi.DirectionalShadowBinding)); + Assert.Equal( + 320u, + BlockMemberOffset( + validVertex, + RenderPackShaderAbi.UniformDescriptorSet, + RenderPackShaderAbi.DirectionalShadowBinding, + member: 5)); + RenderPackSdkValidationResult baseline = RenderPackSdkValidator.ValidateAssets( + descriptor, + VariantAssets(variant, validVertex, validFragment)); + Assert.True(baseline.Success, baseline.Reason); + + byte[] wrongOffset = validVertex.ToArray(); + MutateBlockMemberOffset( + wrongOffset, + RenderPackShaderAbi.UniformDescriptorSet, + RenderPackShaderAbi.DirectionalShadowBinding, + member: 5, + replacement: 304); + RenderPackSdkValidationResult offsetResult = RenderPackSdkValidator.ValidateAssets( + descriptor, + VariantAssets(variant, wrongOffset, validFragment)); + Assert.False(offsetResult.Success); + Assert.Contains("336-byte ABI v1", offsetResult.Reason, StringComparison.Ordinal); + + byte[] fiveMembers = RemoveLastBlockMember( + validVertex, + RenderPackShaderAbi.UniformDescriptorSet, + RenderPackShaderAbi.DirectionalShadowBinding); + RenderPackSdkValidationResult memberCountResult = RenderPackSdkValidator.ValidateAssets( + descriptor, + VariantAssets(variant, fiveMembers, validFragment)); + Assert.False(memberCountResult.Success); + Assert.Contains("336-byte ABI v1", memberCountResult.Reason, StringComparison.Ordinal); + } + + [Fact] + public void FixedAtmosphericReplayRequiresEveryHeadlineCasterInTheSdk() + { + RenderPackDescriptor descriptor = AtmosphericDescriptor(); + SceneReplayDeclaration replay = descriptor.SceneReplays.Single(); + descriptor = descriptor with + { + SceneReplays = + [ + replay with + { + CasterClasses = replay.CasterClasses + & ~RenderCasterClass.AnimatedAlphaCutout, + }, + ], + }; + + RenderPackSdkValidationResult result = + RenderPackSdkValidator.ValidateDescriptor(descriptor); + + Assert.False(result.Success); + Assert.Contains("all five headline caster classes", result.Reason); + Assert.Contains("four maximum cascade views", result.Reason); + } + + [Fact] + public void BufferResource_IsRejectedAsReservedInV1() + { + RenderPackDescriptor descriptor = Descriptor( + resources: + [ + new RenderResourceDeclaration( + "future-buffer", + RenderResourceKind.Buffer, + RenderFormatClass.StructuredData, + Extent: null, + SizeBytes: 1024, + RenderResourceUsage.Sampled, + RenderResourceLifetime.ActivePack, + EstimatedResidentBytes: 1024), + ]); + + RenderPackSdkValidationResult result = + RenderPackSdkValidator.ValidateDescriptor(descriptor); + + Assert.False(result.Success); + Assert.Contains("reserved and unbindable", result.Reason); + } + + [Fact] + public void MoreThanFourSampledInputs_IsRejectedByShaderAbi() + { + RenderResourceDeclaration first = Image("first"); + RenderResourceDeclaration second = Image("second"); + RenderPackDescriptor descriptor = Descriptor( + requiredCapabilities: + [ + RenderCapability.MainWorldColorIntermediate, + RenderCapability.SceneDepthSampling, + RenderCapability.SceneNormalSampling, + ], + resources: [first, second], + passes: + [ + new RenderPassDeclaration( + "produce-first", + RenderPassHook.AtmosphereBeforeToneMap, + "shader.vert.spv", + "shader.frag.spv", + [], + [], + ["first"]), + new RenderPassDeclaration( + "produce-second", + RenderPassHook.AtmosphereBeforeToneMap, + "shader.vert.spv", + "shader.frag.spv", + [], + [], + ["second"]), + new RenderPassDeclaration( + "consume", + RenderPassHook.ToneMap, + "shader.vert.spv", + "shader.frag.spv", + [ + RenderSemanticInput.WorldColor, + RenderSemanticInput.SceneDepth, + RenderSemanticInput.SceneNormals, + ], + ["first", "second"], + []), + ]); + + RenderPackSdkValidationResult result = + RenderPackSdkValidator.ValidateDescriptor(descriptor); + + Assert.False(result.Success); + Assert.Contains("at most four (TextureIndexA-D)", result.Reason); + } + + [Fact] + public void MoreThanSixtyFourSettings_IsRejectedByShaderAbi() + { + RenderSettingDeclaration[] settings = Enumerable.Range(0, 65) + .Select(index => new RenderSettingDeclaration( + $"setting-{index}", + $"Setting {index}", + RenderSettingKind.Boolean, + "false", + Minimum: null, + Maximum: null, + Step: null, + Choices: [])) + .ToArray(); + RenderPackDescriptor descriptor = Descriptor(settings: settings); + + RenderPackSdkValidationResult result = + RenderPackSdkValidator.ValidateDescriptor(descriptor); + + Assert.False(result.Success); + Assert.Contains("at most 64 shader setting slots", result.Reason); + } + + [Fact] + public void SixtyFourSettings_IsTheAcceptedShaderAbiBoundary() + { + RenderSettingDeclaration[] settings = Enumerable.Range(0, 64) + .Select(index => BooleanSetting(index)) + .ToArray(); + + RenderPackSdkValidationResult result = + RenderPackSdkValidator.ValidateDescriptor(Descriptor(settings: settings)); + + Assert.True(result.Success, result.Reason); + } + + [Fact] + public void SettingValuesUseTheInvariantKindSpecificGrammar() + { + RenderSettingDeclaration[] invalidSettings = + [ + new("boolean", "Boolean", RenderSettingKind.Boolean, "1", null, null, null, []), + new("integer", "Integer", RenderSettingKind.Integer, "1.5", null, null, null, []), + new("large-integer", "Large integer", RenderSettingKind.Integer, "16777217", null, null, null, []), + new("float", "Float", RenderSettingKind.Float, "1,5", null, null, null, []), + new("large-float", "Large float", RenderSettingKind.Float, "1e100", null, null, null, []), + new("choice", "Choice", RenderSettingKind.Choice, "missing", null, null, null, ["first"]), + ]; + + foreach (RenderSettingDeclaration setting in invalidSettings) + { + RenderPackSdkValidationResult result = RenderPackSdkValidator.ValidateDescriptor( + Descriptor(settings: [setting])); + Assert.False(result.Success); + Assert.Contains($"'{setting.Id}'", result.Reason); + } + } + + [Theory] + [InlineData("1.1")] + [InlineData("2.25")] + [InlineData("-0.25")] + public void SettingValuesMustRespectDeclaredRangeAndStep(string value) + { + RenderSettingDeclaration setting = new( + "exposure", "Exposure", RenderSettingKind.Float, value, + Minimum: 0, Maximum: 2, Step: 0.25, Choices: []); + + RenderPackSdkValidationResult result = + RenderPackSdkValidator.ValidateDescriptor(Descriptor(settings: [setting])); + + Assert.False(result.Success); + Assert.Contains("invalid default value", result.Reason); + } + + [Fact] + public void DuplicatePresetSettingOverrideIsRejectedCaseInsensitively() + { + RenderSettingDeclaration setting = new( + "exposure", "Exposure", RenderSettingKind.Float, "1", + Minimum: 0, Maximum: 2, Step: 0.25, Choices: []); + RenderPackDescriptor descriptor = Descriptor(settings: [setting]); + descriptor = descriptor with + { + QualityPresets = + [ + descriptor.QualityPresets[0] with + { + SettingOverrides = + [ + new RenderQualitySettingOverride("exposure", "1.25"), + new RenderQualitySettingOverride("EXPOSURE", "1.5"), + ], + }, + ], + }; + + RenderPackSdkValidationResult result = + RenderPackSdkValidator.ValidateDescriptor(descriptor); + + Assert.False(result.Success); + Assert.Contains("more than once", result.Reason); + } + + [Fact] + public void MissingFeatureSummaryIsRejectedByTheExternalSdkValidator() + { + RenderPackSdkValidationResult result = RenderPackSdkValidator.ValidateDescriptor( + Descriptor() with { FeatureSummary = string.Empty }); + + Assert.False(result.Success); + Assert.Equal("Pack 'sample.test-pack' has no feature summary.", result.Reason); + } + + [Fact] + public void AtmosphericEffectCurvesRejectNullNonMonotonicAndOutOfRangeValues() + { + RenderPackDescriptor descriptor = AtmosphericDescriptor(); + AtmospherePolicyDeclaration policy = descriptor.AtmospherePolicy!; + AtmospherePolicyDeclaration[] malformed = + [ + policy with { DirectionalShadowLightElevationResponse = null! }, + policy with + { + DirectionalShadowLightElevationResponse = + [ + new SunElevationResponsePoint(10, 0), + new SunElevationResponsePoint(10, 1), + ], + }, + policy with + { + VolumetricShaftSunElevationResponse = + [ + new SunElevationResponsePoint(-10, 0), + new SunElevationResponsePoint(10, 1.01), + ], + }, + policy with + { + VolumetricShaftSunElevationResponse = + [ + new SunElevationResponsePoint(double.NaN, 0), + new SunElevationResponsePoint(10, 1), + ], + }, + ]; + + foreach (AtmospherePolicyDeclaration value in malformed) + { + RenderPackSdkValidationResult result = RenderPackSdkValidator.ValidateDescriptor( + descriptor with { AtmospherePolicy = value }); + Assert.False(result.Success); + Assert.Contains("curve", result.Reason, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void DirectionalShadowCurveMustRemainZeroAtAndBelowAuthoredHorizon() + { + RenderPackDescriptor descriptor = AtmosphericDescriptor(); + AtmospherePolicyDeclaration policy = descriptor.AtmospherePolicy!; + IReadOnlyList[] invalidCurves = + [ + [ + new SunElevationResponsePoint(-90, 0.1), + new SunElevationResponsePoint(0, 0), + new SunElevationResponsePoint(90, 1), + ], + [ + new SunElevationResponsePoint(-90, 0), + new SunElevationResponsePoint(90, 1), + ], + [ + new SunElevationResponsePoint(0, 0.1), + new SunElevationResponsePoint(90, 1), + ], + ]; + + foreach (IReadOnlyList invalidCurve in invalidCurves) + { + RenderPackSdkValidationResult result = RenderPackSdkValidator.ValidateDescriptor( + descriptor with + { + AtmospherePolicy = policy with + { + DirectionalShadowLightElevationResponse = invalidCurve, + }, + }); + + Assert.False(result.Success); + Assert.Contains("zero at and below the 0-degree authored horizon", result.Reason, + StringComparison.Ordinal); + } + + Assert.True(RenderPackSdkValidator.ValidateDescriptor(descriptor).Success); + } + + private static RenderPackDescriptor Descriptor( + IReadOnlyList? requiredCapabilities = null, + IReadOnlyList? resources = null, + IReadOnlyList? passes = null, + IReadOnlyList? settings = null) => new( + "sample.test-pack", + "Test pack", + new Version(1, 0, 0), + RenderPackApi.Current, + RenderPackTier.Tier1, + requiredCapabilities ?? [], + [], + resources ?? [], + passes ?? [], + [], + [], + [ + new RenderQualityPreset( + "default", + "Default", + [], + [], + [], + 0, + 0, + 0, + 0, + 0), + ], + settings ?? [], + null) + { + FeatureSummary = "Test render pack.", + }; + + private static RenderPackDescriptor AtmosphericDescriptor() + { + RenderResourceDeclaration Image( + string id, + RenderResourceSemantic semantic, + RenderFormatClass format) => new( + id, + RenderResourceKind.Image2D, + format, + new RenderExtentDeclaration(RenderExtentMode.RelativeToMainWorld, 0.5, 0.5), + 0, + RenderResourceUsage.Sampled | RenderResourceUsage.ColorAttachment, + RenderResourceLifetime.ActivePack, + 1024) + { + Semantic = semantic, + }; + + RenderPassDeclaration Pass( + string id, + RenderPassSemantic semantic, + RenderPassHook hook, + IReadOnlyList inputs, + IReadOnlyList reads, + IReadOnlyList writes) => new( + id, + hook, + $"{id}.vert.spv", + $"{id}.frag.spv", + inputs, + reads, + writes) + { + Semantic = semantic, + }; + + PipelineVariantDeclaration Variant( + string id, + RenderPipelineVariantSemantic semantic, + RenderPipelineBaseSemantic baseSemantic, + RenderMaterialClass materials, + IReadOnlyList inputs) => new( + id, + baseSemantic, + $"{id}.vert.spv", + $"{id}.frag.spv", + materials, + inputs) + { + Semantic = semantic, + }; + + RenderResourceDeclaration[] resources = + [ + Image("world-hdr", RenderResourceSemantic.MainWorldHdr, RenderFormatClass.HdrColor), + Image("bloom-a", RenderResourceSemantic.BloomPing, RenderFormatClass.HdrColor), + Image("bloom-b", RenderResourceSemantic.BloomPong, RenderFormatClass.HdrColor), + Image("sun-mask", RenderResourceSemantic.SunOcclusionMask, RenderFormatClass.SingleChannel), + Image("sun-rays", RenderResourceSemantic.SunRays, RenderFormatClass.HdrColor), + new RenderResourceDeclaration( + "shadow-depth", + RenderResourceKind.Image2DArray, + RenderFormatClass.DirectionalDepth, + new RenderExtentDeclaration(RenderExtentMode.AbsolutePixels, 1024, 1024, 4), + 0, + RenderResourceUsage.Sampled | RenderResourceUsage.DepthAttachment, + RenderResourceLifetime.ActivePack, + 4096) + { + Semantic = RenderResourceSemantic.DirectionalShadowDepth, + }, + Image("volumetric", RenderResourceSemantic.VolumetricShafts, RenderFormatClass.HdrColor), + ]; + RenderPassDeclaration[] passes = + [ + Pass("shadow-pass", RenderPassSemantic.DirectionalShadowDepth, + RenderPassHook.ShadowDepthBeforeWorld, + [RenderSemanticInput.CameraMatrices, + RenderSemanticInput.SelectedCelestialDirectionalLight, + RenderSemanticInput.ShadowCasterTransforms, RenderSemanticInput.ActiveDayGroup, + RenderSemanticInput.Weather], [], ["shadow-depth"]), + Pass("occlusion-pass", RenderPassSemantic.SunOcclusion, + RenderPassHook.AtmosphereBeforeToneMap, + [RenderSemanticInput.SceneDepth, RenderSemanticInput.SunScreenPosition, + RenderSemanticInput.ActiveDayGroup, RenderSemanticInput.Weather], + [], ["sun-mask"]), + Pass("rays-pass", RenderPassSemantic.SunRays, + RenderPassHook.AtmosphereBeforeToneMap, + [RenderSemanticInput.SunScreenPosition, RenderSemanticInput.FrameTime], + ["sun-mask"], ["sun-rays"]), + Pass("volumetric-pass", RenderPassSemantic.VolumetricShafts, + RenderPassHook.AtmosphereBeforeToneMap, + [RenderSemanticInput.SceneDepth, RenderSemanticInput.CameraMatrices, + RenderSemanticInput.SunDirection, RenderSemanticInput.DirectionalShadowMaps, + RenderSemanticInput.ActiveDayGroup, RenderSemanticInput.Weather], + ["shadow-depth"], ["volumetric"]), + Pass("downsample-pass", RenderPassSemantic.BloomDownsample, + RenderPassHook.AtmosphereBeforeToneMap, + [RenderSemanticInput.WorldColor], ["sun-rays", "volumetric"], ["bloom-a"]), + Pass("blur-h-pass", RenderPassSemantic.BloomBlurHorizontal, + RenderPassHook.AtmosphereBeforeToneMap, + [RenderSemanticInput.FrameTime], ["bloom-a"], ["bloom-b"]), + Pass("blur-v-pass", RenderPassSemantic.BloomBlurVertical, + RenderPassHook.AtmosphereBeforeToneMap, + [RenderSemanticInput.FrameTime], ["bloom-b"], ["bloom-a"]), + Pass("filmic-pass", RenderPassSemantic.FilmicComposite, + RenderPassHook.ToneMap, + [RenderSemanticInput.WorldColor, RenderSemanticInput.FrameTime], + ["bloom-a", "sun-rays", "volumetric"], []), + ]; + const RenderCasterClass allCasters = RenderCasterClass.Terrain + | RenderCasterClass.OpaqueWorld + | RenderCasterClass.AlphaCutoutWorld + | RenderCasterClass.AnimatedOpaque + | RenderCasterClass.AnimatedAlphaCutout; + PipelineVariantDeclaration[] variants = + [ + Variant("terrain-caster", + RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster, + RenderPipelineBaseSemantic.Terrain, RenderMaterialClass.Opaque, + [RenderSemanticInput.CameraMatrices]), + Variant("world-caster", + RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster, + RenderPipelineBaseSemantic.WorldMesh, + RenderMaterialClass.Opaque | RenderMaterialClass.AnimatedOpaque, + [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]), + Variant("cutout-caster", + RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster, + RenderPipelineBaseSemantic.WorldMesh, + RenderMaterialClass.AlphaCutout | RenderMaterialClass.AnimatedAlphaCutout, + [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]), + Variant("terrain-receiver", + RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver, + RenderPipelineBaseSemantic.Terrain, RenderMaterialClass.Opaque, + [RenderSemanticInput.DirectionalShadowMaps, + RenderSemanticInput.SelectedCelestialDirectionalLight]), + Variant("world-receiver", + RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver, + RenderPipelineBaseSemantic.WorldMesh, + RenderMaterialClass.Opaque | RenderMaterialClass.AlphaCutout + | RenderMaterialClass.AnimatedOpaque + | RenderMaterialClass.AnimatedAlphaCutout, + [RenderSemanticInput.DirectionalShadowMaps, + RenderSemanticInput.SelectedCelestialDirectionalLight]), + ]; + + return new RenderPackDescriptor( + "sample.atmospheric", + "Atmospheric sample", + new Version(1, 0, 0), + RenderPackApi.Current, + RenderPackTier.Tier2Plus, + [ + RenderCapability.MainWorldColorIntermediate, + RenderCapability.FullscreenPasses, + RenderCapability.SceneDepthSampling, + RenderCapability.AuthoredSunDirection, + RenderCapability.AuthoredCelestialDirectionalLight, + RenderCapability.AuthoredSunScreenPosition, + RenderCapability.AuthoredWeather, + RenderCapability.AnimatedCasterTransforms, + RenderCapability.DirectionalShadowMaps, + ], + [], + resources, + passes, + [new SceneReplayDeclaration( + "outdoor-casters", + RenderSceneReplaySemantic.OutdoorDirectionalShadowCasters, + allCasters, + 4)], + variants, + [new RenderQualityPreset("default", "Default", [], [], [], 0, 0, 0, 0, 0)], + [], + new AtmospherePolicyDeclaration( + [ + new SunElevationResponsePoint(-90, 0), + new SunElevationResponsePoint(90, 1), + ], + []) + { + DirectionalShadowLightElevationResponse = + [ + new SunElevationResponsePoint(-90, 0), + new SunElevationResponsePoint(0, 0), + new SunElevationResponsePoint(90, 1), + ], + VolumetricShaftSunElevationResponse = + [ + new SunElevationResponsePoint(-90, 0), + new SunElevationResponsePoint(90, 1), + ], + }) + { + FeatureSummary = "Atmospheric validator test render pack.", + }; + } + + private static RenderResourceDeclaration Image(string id) => new( + id, + RenderResourceKind.Image2D, + RenderFormatClass.HdrColor, + new RenderExtentDeclaration( + RenderExtentMode.RelativeToMainWorld, + 0.5, + 0.5), + 0, + RenderResourceUsage.Sampled | RenderResourceUsage.ColorAttachment, + RenderResourceLifetime.ActivePack, + 1024); + + private static RenderSettingDeclaration BooleanSetting(int index) => new( + $"setting-{index}", + $"Setting {index}", + RenderSettingKind.Boolean, + "false", + Minimum: null, + Maximum: null, + Step: null, + Choices: []); + + private static string FindRepositoryRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + throw new InvalidOperationException("Could not find repository root."); + } + + private sealed class ByteAssets(byte[] value) : IRenderPackAssets + { + public Stream OpenRead(string assetKey) => + new MemoryStream(value, writable: false); + } + + private sealed class MappedAssets(IReadOnlyDictionary values) + : IRenderPackAssets + { + public Stream OpenRead(string assetKey) => + new MemoryStream(values[assetKey], writable: false); + } + + private static MappedAssets VariantAssets( + PipelineVariantDeclaration variant, + byte[] vertex, + byte[] fragment) => new(new Dictionary + { + [variant.VertexShaderAsset] = vertex, + [variant.FragmentShaderAsset] = fragment, + }); + + private static void MutateBlockMemberOffset( + byte[] spirv, + uint set, + uint binding, + int member, + uint replacement) + { + uint[] words = Words(spirv); + uint structure = DescriptorStructure(words, set, binding); + for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16))) + { + int count = checked((int)(words[index] >> 16)); + if ((words[index] & 0xffff) == 72 + && count >= 5 + && words[index + 1] == structure + && words[index + 2] == (uint)member + && words[index + 3] == 35) + { + words[index + 4] = replacement; + Buffer.BlockCopy(words, 0, spirv, 0, spirv.Length); + return; + } + } + throw new InvalidOperationException("Descriptor block member offset was not found."); + } + + private static int BlockMemberCount(byte[] spirv, uint set, uint binding) + { + uint[] words = Words(spirv); + uint structure = DescriptorStructure(words, set, binding); + for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16))) + { + int count = checked((int)(words[index] >> 16)); + if ((words[index] & 0xffff) == 30 && count >= 2 && words[index + 1] == structure) + return count - 2; + } + throw new InvalidOperationException("Descriptor block structure was not found."); + } + + private static uint BlockMemberOffset( + byte[] spirv, + uint set, + uint binding, + int member) + { + uint[] words = Words(spirv); + uint structure = DescriptorStructure(words, set, binding); + for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16))) + { + int count = checked((int)(words[index] >> 16)); + if ((words[index] & 0xffff) == 72 + && count >= 5 + && words[index + 1] == structure + && words[index + 2] == (uint)member + && words[index + 3] == 35) + { + return words[index + 4]; + } + } + throw new InvalidOperationException("Descriptor block member offset was not found."); + } + + private static byte[] RemoveLastBlockMember(byte[] spirv, uint set, uint binding) + { + uint[] words = Words(spirv); + uint structure = DescriptorStructure(words, set, binding); + for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16))) + { + int count = checked((int)(words[index] >> 16)); + if ((words[index] & 0xffff) != 30 + || count < 3 + || words[index + 1] != structure) + { + continue; + } + + var mutated = words.ToList(); + mutated[index] = ((uint)(count - 1) << 16) | 30u; + mutated.RemoveAt(index + count - 1); + var bytes = new byte[mutated.Count * sizeof(uint)]; + Buffer.BlockCopy(mutated.ToArray(), 0, bytes, 0, bytes.Length); + return bytes; + } + throw new InvalidOperationException("Descriptor block structure was not found."); + } + + private static uint DescriptorStructure(uint[] words, uint set, uint binding) + { + var sets = new Dictionary(); + var bindings = new Dictionary(); + for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16))) + { + int count = checked((int)(words[index] >> 16)); + if ((words[index] & 0xffff) != 71 || count < 4) + continue; + if (words[index + 2] == 34) sets[words[index + 1]] = words[index + 3]; + if (words[index + 2] == 33) bindings[words[index + 1]] = words[index + 3]; + } + uint variable = sets.Keys.Single(id => + sets[id] == set && bindings.GetValueOrDefault(id) == binding); + uint pointer = 0; + for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16))) + { + int count = checked((int)(words[index] >> 16)); + if ((words[index] & 0xffff) == 59 && count >= 4 && words[index + 2] == variable) + { + pointer = words[index + 1]; + break; + } + } + if (pointer == 0) + throw new InvalidOperationException("Descriptor variable was not found."); + for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16))) + { + int count = checked((int)(words[index] >> 16)); + if ((words[index] & 0xffff) == 32 && count >= 4 && words[index + 1] == pointer) + return words[index + 3]; + } + throw new InvalidOperationException("Descriptor pointer type was not found."); + } + + private static uint[] Words(byte[] bytes) + { + var words = new uint[bytes.Length / sizeof(uint)]; + Buffer.BlockCopy(bytes, 0, words, 0, bytes.Length); + return words; + } + + private sealed class TemporaryDirectory : IDisposable + { + internal TemporaryDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"acdream-render-pack-sdk-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + internal string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/tests/AcDream.RenderPackValidator.Tests/packages.neutral.lock.json b/tests/AcDream.RenderPackValidator.Tests/packages.neutral.lock.json new file mode 100644 index 00000000..e78a072d --- /dev/null +++ b/tests/AcDream.RenderPackValidator.Tests/packages.neutral.lock.json @@ -0,0 +1,107 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.14.1, )", + "resolved": "17.14.1", + "contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==", + "dependencies": { + "Microsoft.CodeCoverage": "17.14.1", + "Microsoft.TestPlatform.TestHost": "17.14.1" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.4, )", + "resolved": "3.1.4", + "contentHash": "5mj99LvCqrq3CNi06xYdyIAXOEh+5b33F2nErCzI5zWiDdLHXiPXEWFSUAF8zlIv0ZWqjZNCwHTQeAPYbF3pCg==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.14.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "acdream.plugin.abstractions": { + "type": "Project" + }, + "acdream.tools.renderpackvalidator": { + "type": "Project", + "dependencies": { + "AcDream.Plugin.Abstractions": "[1.0.0, )" + } + } + } + } +} \ No newline at end of file diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs index 2c5531ff..10ff200f 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs @@ -28,6 +28,8 @@ public sealed class DisplaySettingsTests Assert.Equal(90f, d.FieldOfView); Assert.Equal(1.0f, d.Gamma); Assert.False(d.ShowFps); + Assert.Equal(RenderPackSelectionSettings.Retail, d.RenderPack); + Assert.True(d.RenderPack.IsRetail); } [Fact] @@ -68,6 +70,46 @@ public sealed class DisplaySettingsTests Assert.False(d.ShowFps); } + [Fact] + public void Render_pack_selection_uses_stable_logical_ids() + { + var selection = new RenderPackSelectionSettings( + "acdream.atmospheric", + "1.0.0", + "medium"); + DisplaySettings changed = DisplaySettings.Default with + { + RenderPack = selection, + }; + + Assert.Equal("acdream.atmospheric", changed.RenderPack.PackId); + Assert.Equal("1.0.0", changed.RenderPack.PackVersion); + Assert.Equal("medium", changed.RenderPack.PresetId); + Assert.False(changed.RenderPack.IsRetail); + Assert.Equal(RenderPackSelectionSettings.Retail, DisplaySettings.Default.RenderPack); + } + + [Fact] + public void Render_pack_setting_overrides_are_value_equal_and_case_insensitive_by_id() + { + var first = new RenderPackSelectionSettings("pack", "1.0.0", "high") + { + SettingOverrides = new RenderPackSettingOverrides( + new Dictionary { ["Exposure"] = "1.25" }), + }; + var second = new RenderPackSelectionSettings("pack", "1.0.0", "high") + { + SettingOverrides = new RenderPackSettingOverrides( + new Dictionary { ["exposure"] = "1.25" }), + }; + + Assert.Equal(first, second); + Assert.Equal(first.GetHashCode(), second.GetHashCode()); + Assert.True(first.SettingOverrides.TryGetValue("EXPOSURE", out string? value)); + Assert.Equal("1.25", value); + Assert.Empty(RenderPackSelectionSettings.Retail.SettingOverrides); + } + private static int ParseWidth(string res) { int x = res.IndexOf('x'); diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs index 75beed29..5f14d4d0 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs @@ -46,12 +46,28 @@ public sealed class SettingsStoreTests : System.IDisposable Gamma: 1.4f, ShowFps: true, Quality: AcDream.UI.Abstractions.Settings.QualityPreset.Ultra, - ParticleRange: ParticleRange.Extended); + ParticleRange: ParticleRange.Extended) + { + RenderPack = new RenderPackSelectionSettings( + "acdream.atmospheric", + "1.0.0", + "high") + { + SettingOverrides = new RenderPackSettingOverrides( + new Dictionary + { + ["exposure"] = "1.25", + ["sun-rays"] = "true", + }), + }, + }; store.SaveDisplay(original); var loaded = store.LoadDisplay(); Assert.Equal(original, loaded); + Assert.Equal("1.25", loaded.RenderPack.SettingOverrides["exposure"]); + Assert.Equal("true", loaded.RenderPack.SettingOverrides["sun-rays"]); } [Fact] @@ -85,6 +101,27 @@ public sealed class SettingsStoreTests : System.IDisposable Assert.Equal(DisplaySettings.Default.VSync, loaded.VSync); Assert.Equal(DisplaySettings.Default.FieldOfView, loaded.FieldOfView); Assert.Equal(ParticleRange.Extended, loaded.ParticleRange); + Assert.Equal(RenderPackSelectionSettings.Retail, loaded.RenderPack); + Assert.Empty(loaded.RenderPack.SettingOverrides); + } + + [Fact] + public void LoadDisplay_upgrades_pre_pack_file_to_retail_without_a_write() + { + File.WriteAllText(_tempPath, """ + { + "version": 3, + "display": { + "resolution": "1920x1080", + "fullscreen": false + } + } + """); + + DisplaySettings loaded = new SettingsStore(_tempPath).LoadDisplay(); + + Assert.Equal("1920x1080", loaded.Resolution); + Assert.Equal(RenderPackSelectionSettings.Retail, loaded.RenderPack); } // ── #389 schema-v3 fieldOfView migration ──────────────────────────── diff --git a/tools/RenderPackValidator/AcDream.Tools.RenderPackValidator.csproj b/tools/RenderPackValidator/AcDream.Tools.RenderPackValidator.csproj new file mode 100644 index 00000000..4c5cd471 --- /dev/null +++ b/tools/RenderPackValidator/AcDream.Tools.RenderPackValidator.csproj @@ -0,0 +1,18 @@ + + + Exe + net10.0 + enable + enable + latest + true + AcDream.Tools.RenderPackValidator + AcDream.Tools.RenderPackValidator + + + + + + + + diff --git a/tools/RenderPackValidator/PackManifest.cs b/tools/RenderPackValidator/PackManifest.cs new file mode 100644 index 00000000..7cdf6dc7 --- /dev/null +++ b/tools/RenderPackValidator/PackManifest.cs @@ -0,0 +1,141 @@ +using System.Text.Json; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Tools.RenderPackValidator; + +internal sealed record PackManifest( + string Id, + string DisplayName, + string Version, + string EntryDll, + int ApiVersion, + IReadOnlyList Dependencies, + IReadOnlyList Kinds) +{ + internal static ValidationOutcome Parse(string json) + { + ManifestDto? value; + try + { + value = JsonSerializer.Deserialize(json, JsonOptions); + } + catch (JsonException error) + { + return ValidationOutcome.Invalid($"plugin.json is invalid JSON: {error.Message}"); + } + + if (value is null) + return ValidationOutcome.Invalid("plugin.json is empty."); + if (!StableId.IsValid(value.Id)) + return ValidationOutcome.Invalid("plugin.json id must be a stable lowercase logical id."); + if (string.IsNullOrWhiteSpace(value.DisplayName)) + return ValidationOutcome.Invalid("plugin.json is missing displayName."); + if (string.IsNullOrWhiteSpace(value.Version)) + return ValidationOutcome.Invalid("plugin.json is missing version."); + if (!System.Version.TryParse(value.Version, out _)) + return ValidationOutcome.Invalid("plugin.json version must be a dotted numeric version."); + if (!SafeRelativePath.IsValid(value.EntryDll, requireDll: true)) + return ValidationOutcome.Invalid("plugin.json entryDll must be a safe relative .dll path."); + if (!PluginApi.IsSupported(value.ApiVersion)) + { + return ValidationOutcome.Invalid( + $"plugin.json apiVersion {value.ApiVersion} is unsupported; " + + $"this SDK supports {PluginApi.MinimumSupported}..{PluginApi.Current}."); + } + + string[] dependencies = value.Dependencies ?? []; + if (dependencies.Any(static dependency => !StableId.IsValid(dependency))) + return ValidationOutcome.Invalid("plugin.json contains an invalid dependency id."); + string[] kinds = value.Kinds ?? ["gameplay"]; + if (kinds.Length == 0) + return ValidationOutcome.Invalid("plugin.json kinds must contain at least one entry."); + foreach (string? kind in kinds) + { + if (!string.Equals(kind, "gameplay", StringComparison.OrdinalIgnoreCase) + && !string.Equals(kind, "renderPack", StringComparison.OrdinalIgnoreCase)) + { + return ValidationOutcome.Invalid( + $"plugin.json contains unknown kind '{kind ?? ""}'."); + } + } + if (!kinds.Contains("renderPack", StringComparer.OrdinalIgnoreCase)) + return ValidationOutcome.Invalid("plugin.json does not declare the renderPack kind."); + + return ValidationOutcome.Valid(new PackManifest( + value.Id!, + value.DisplayName!, + value.Version!, + value.EntryDll!, + value.ApiVersion, + dependencies, + kinds.Distinct(StringComparer.OrdinalIgnoreCase).ToArray())); + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + private sealed class ManifestDto + { + public string? Id { get; set; } + public string? DisplayName { get; set; } + public string? Version { get; set; } + public string? EntryDll { get; set; } + public int ApiVersion { get; set; } + public string[]? Dependencies { get; set; } + public string[]? Kinds { get; set; } + } +} + +internal readonly record struct ValidationOutcome( + bool Success, + string? Reason, + PackManifest? Manifest = null) +{ + internal static ValidationOutcome Valid(PackManifest manifest) => + new(true, null, manifest); + + internal static ValidationOutcome Invalid(string reason) => + new(false, reason); +} + +internal static class StableId +{ + internal static bool IsValid(string? value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 128) + return false; + if (value[0] is < 'a' or > 'z') + return false; + return value.All(static character => + character is >= 'a' and <= 'z' + || character is >= '0' and <= '9' + || character is '.' or '-' or '_'); + } +} + +internal static class SafeRelativePath +{ + internal static bool IsValid(string? value, bool requireDll = false) + { + if (string.IsNullOrWhiteSpace(value) + || value.Length > 512 + || Path.IsPathRooted(value) + || value.Contains('\\')) + { + return false; + } + + string[] segments = value.Split('/'); + if (segments.Any(static segment => + segment.Length == 0 || segment is "." or "..")) + { + return false; + } + + return !requireDll + || string.Equals(Path.GetExtension(value), ".dll", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tools/RenderPackValidator/Program.cs b/tools/RenderPackValidator/Program.cs new file mode 100644 index 00000000..0323c2ba --- /dev/null +++ b/tools/RenderPackValidator/Program.cs @@ -0,0 +1,7 @@ +namespace AcDream.Tools.RenderPackValidator; + +internal static class Program +{ + private static int Main(string[] args) => + RenderPackValidatorCommand.Run(args, Console.Out, Console.Error); +} diff --git a/tools/RenderPackValidator/RenderPackSdkValidator.cs b/tools/RenderPackValidator/RenderPackSdkValidator.cs new file mode 100644 index 00000000..7edac7e9 --- /dev/null +++ b/tools/RenderPackValidator/RenderPackSdkValidator.cs @@ -0,0 +1,1500 @@ +using System.Buffers.Binary; +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.Tools.RenderPackValidator; + +internal readonly record struct RenderPackSdkValidationResult( + bool Success, + string? Reason) +{ + internal static RenderPackSdkValidationResult Valid() => new(true, null); + + internal static RenderPackSdkValidationResult Invalid(string reason) => + new(false, reason); +} + +/// +/// Hardware-independent v1 schema validation. This deliberately does not +/// compile pipelines or promise device compatibility; the graphical client +/// performs those checks before atomic activation. +/// +internal static class RenderPackSdkValidator +{ + private const long MaximumPackBytes = 256L * 1024 * 1024; + private const int MaximumImageDimension = 16_384; + private const int MaximumImageLayers = 256; + private const uint SpirvMagic = 0x0723_0203u; + + internal static RenderPackSdkValidationResult ValidateDescriptor( + RenderPackDescriptor? descriptor) + { + if (descriptor is null) + return Invalid("The pack descriptor is missing."); + if (!StableId.IsValid(descriptor.Id)) + return Invalid("The pack id must be a stable lowercase logical id."); + if (string.IsNullOrWhiteSpace(descriptor.DisplayName)) + return Invalid($"Pack '{descriptor.Id}' has no display name."); + if (string.IsNullOrWhiteSpace(descriptor.FeatureSummary)) + return Invalid($"Pack '{descriptor.Id}' has no feature summary."); + if (descriptor.PackVersion is null) + return Invalid($"Pack '{descriptor.Id}' has no version."); + if (!RenderPackApi.IsSupported(descriptor.PackApiVersion)) + { + return Invalid( + $"Pack '{descriptor.Id}' requires render-pack API " + + $"{descriptor.PackApiVersion}; this SDK supports " + + $"{RenderPackApi.MinimumSupported}..{RenderPackApi.Current}."); + } + if (!Enum.IsDefined(descriptor.HighestTier)) + return Invalid($"Pack '{descriptor.Id}' declares an unknown tier."); + + string? nullList = FirstNullList(descriptor); + if (nullList is not null) + return Invalid($"Pack '{descriptor.Id}' has a null {nullList} declaration list."); + if (descriptor.RequiredCapabilities.Any(static value => !Enum.IsDefined(value)) + || descriptor.OptionalCapabilities.Any(static value => !Enum.IsDefined(value))) + { + return Invalid($"Pack '{descriptor.Id}' declares an unknown capability."); + } + + RenderPackSdkValidationResult semanticCapabilities = + ValidateSemanticCapabilities(descriptor); + if (!semanticCapabilities.Success) return semanticCapabilities; + RenderPackSdkValidationResult uniqueIds = ValidateUniqueIds(descriptor); + if (!uniqueIds.Success) return uniqueIds; + RenderPackSdkValidationResult resources = ValidateResources(descriptor); + if (!resources.Success) return resources; + RenderPackSdkValidationResult passes = ValidatePasses(descriptor); + if (!passes.Success) return passes; + RenderPackSdkValidationResult replays = ValidateReplays(descriptor); + if (!replays.Success) return replays; + RenderPackSdkValidationResult variants = ValidateVariants(descriptor); + if (!variants.Success) return variants; + RenderPackSdkValidationResult settings = ValidateSettings(descriptor); + if (!settings.Success) return settings; + RenderPackSdkValidationResult presets = ValidatePresets(descriptor); + if (!presets.Success) return presets; + RenderPackSdkValidationResult semantics = ValidateSemanticRoles(descriptor); + if (!semantics.Success) return semantics; + return ValidateAtmosphere(descriptor); + } + + internal static RenderPackSdkValidationResult ValidateAssets( + RenderPackDescriptor descriptor, + IRenderPackAssets assets) + { + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(assets); + + ShaderValidationRequest[] requests = descriptor.Passes + .SelectMany(static pass => new[] + { + new ShaderValidationRequest( + pass.VertexShaderAsset, RenderPackShaderStage.Vertex, pass, null), + new ShaderValidationRequest( + pass.FragmentShaderAsset, RenderPackShaderStage.Fragment, pass, null), + }) + .Concat(descriptor.PipelineVariants.SelectMany(static variant => new[] + { + new ShaderValidationRequest( + variant.VertexShaderAsset, RenderPackShaderStage.Vertex, null, variant), + new ShaderValidationRequest( + variant.FragmentShaderAsset, RenderPackShaderStage.Fragment, null, variant), + })) + .ToArray(); + + byte[] readBuffer = new byte[4096]; + foreach (IGrouping group in + requests.GroupBy(static request => request.Key, StringComparer.Ordinal)) + { + string key = group.Key; + if (!SafeRelativePath.IsValid(key)) + return Invalid($"Pack '{descriptor.Id}' declares unsafe asset key '{key}'."); + try + { + using Stream stream = assets.OpenRead(key); + if (stream is null || !stream.CanRead) + return Invalid($"Pack '{descriptor.Id}' asset '{key}' is not readable."); + using var destination = new MemoryStream(); + while (true) + { + int count = stream.Read(readBuffer); + if (count == 0) + break; + if (destination.Length + count > RenderPackShaderAbi.MaximumShaderAssetBytes) + { + return Invalid( + $"Pack '{descriptor.Id}' asset '{key}' exceeds " + + $"the {RenderPackShaderAbi.MaximumShaderAssetBytes}-byte " + + "shader ceiling."); + } + destination.Write(readBuffer, 0, count); + } + + byte[] spirv = destination.ToArray(); + if (spirv.Length < 4 + || (spirv.Length & 3) != 0 + || BinaryPrimitives.ReadUInt32LittleEndian(spirv) != SpirvMagic) + { + return Invalid( + $"Pack '{descriptor.Id}' asset '{key}' is not valid SPIR-V."); + } + + foreach (ShaderValidationRequest request in group) + { + RenderPackSpirvValidationResult validation = request.Pass is not null + ? RenderPackSpirvValidator.ValidatePassShader( + spirv, request.Stage, request.Pass) + : RenderPackSpirvValidator.ValidatePipelineVariantShader( + spirv, request.Stage, request.Variant!); + if (!validation.Success) + { + return Invalid( + $"Pack '{descriptor.Id}' asset '{key}' fails render-pack shader ABI v1: " + + validation.Reason + "."); + } + } + } + catch (Exception exception) when (exception is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException) + { + return Invalid( + $"Pack '{descriptor.Id}' asset '{key}' could not be opened: " + + exception.GetBaseException().Message); + } + } + + return RenderPackSdkValidationResult.Valid(); + } + + private sealed record ShaderValidationRequest( + string Key, + RenderPackShaderStage Stage, + RenderPassDeclaration? Pass, + PipelineVariantDeclaration? Variant); + + private static RenderPackSdkValidationResult ValidateSemanticRoles( + RenderPackDescriptor descriptor) + { + RenderPackSdkValidationResult unique = UniqueNonCustomSemantics( + descriptor, descriptor.Resources, static value => value.Semantic, + RenderResourceSemantic.Custom, "resource"); + if (!unique.Success) return unique; + unique = UniqueNonCustomSemantics( + descriptor, descriptor.Passes, static value => value.Semantic, + RenderPassSemantic.CustomFullscreen, "pass"); + if (!unique.Success) return unique; + unique = UniqueNonCustomSemantics( + descriptor, descriptor.PipelineVariants, static value => value.Semantic, + RenderPipelineVariantSemantic.Custom, "pipeline variant"); + if (!unique.Success) return unique; + unique = UniqueNonCustomSemantics( + descriptor, descriptor.QualityPresets, static value => value.Semantic, + RenderQualitySemantic.Custom, "quality preset"); + if (!unique.Success) return unique; + unique = UniqueNonCustomSemantics( + descriptor, descriptor.Settings, static value => value.Semantic, + RenderSettingSemantic.Custom, "setting"); + if (!unique.Success) return unique; + + RenderSettingDeclaration? automaticSetting = descriptor.Settings.FirstOrDefault( + static value => value.Semantic == RenderSettingSemantic.AutomaticQuality); + if (automaticSetting is not null && automaticSetting.Kind != RenderSettingKind.Boolean) + { + return Invalid( + $"Pack '{descriptor.Id}' AutomaticQuality setting must be Boolean."); + } + if (automaticSetting is not null + || descriptor.QualityPresets.Any(static value => + value.Semantic == RenderQualitySemantic.Automatic)) + { + foreach (RenderQualitySemantic semantic in new[] + { + RenderQualitySemantic.Low, + RenderQualitySemantic.Medium, + RenderQualitySemantic.High, + }) + { + if (!descriptor.QualityPresets.Any(value => + value.Semantic == semantic && value.AutoEligible)) + { + return Invalid( + $"Pack '{descriptor.Id}' declares Automatic quality but has no " + + $"AutoEligible '{semantic}' semantic preset."); + } + } + } + + bool atmosphericExecutor = descriptor.Passes.Any(static value => + value.Semantic != RenderPassSemantic.CustomFullscreen); + if (!atmosphericExecutor) + return RenderPackSdkValidationResult.Valid(); + + bool directionalShadowOnly = descriptor.Passes.Any(static value => + value.Semantic == RenderPassSemantic.DirectionalShadowDepth) + && descriptor.Passes.All(static value => + value.Semantic is RenderPassSemantic.CustomFullscreen + or RenderPassSemantic.DirectionalShadowDepth); + if (directionalShadowOnly) + return ValidateDirectionalShadowProfile(descriptor); + + RenderPassSemantic[] requiredPasses = + [ + RenderPassSemantic.DirectionalShadowDepth, + RenderPassSemantic.SunOcclusion, + RenderPassSemantic.SunRays, + RenderPassSemantic.VolumetricShafts, + RenderPassSemantic.BloomDownsample, + RenderPassSemantic.BloomBlurHorizontal, + RenderPassSemantic.BloomBlurVertical, + RenderPassSemantic.FilmicComposite, + ]; + foreach (RenderPassSemantic semantic in requiredPasses) + { + if (!descriptor.Passes.Any(value => value.Semantic == semantic)) + { + return Invalid( + $"Pack '{descriptor.Id}' requests the atmospheric executor but " + + $"does not declare required pass semantic '{semantic}'."); + } + } + + RenderResourceSemantic[] requiredResources = + [ + RenderResourceSemantic.MainWorldHdr, + RenderResourceSemantic.BloomPing, + RenderResourceSemantic.BloomPong, + RenderResourceSemantic.SunOcclusionMask, + RenderResourceSemantic.SunRays, + RenderResourceSemantic.DirectionalShadowDepth, + RenderResourceSemantic.VolumetricShafts, + ]; + foreach (RenderResourceSemantic semantic in requiredResources) + { + if (!descriptor.Resources.Any(value => value.Semantic == semantic)) + { + return Invalid( + $"Pack '{descriptor.Id}' requests the atmospheric executor but " + + $"does not declare required resource semantic '{semantic}'."); + } + } + + if (descriptor.SceneReplays.Count(value => + value.Semantic == RenderSceneReplaySemantic.OutdoorDirectionalShadowCasters) != 1) + { + return Invalid( + $"Pack '{descriptor.Id}' must declare exactly one outdoor directional-shadow replay."); + } + + bool usesMultiview = UsesMultiview(descriptor); + List requiredVariants = + [ + RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster, + RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster, + RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster, + RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver, + RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver, + ]; + if (usesMultiview) + { + requiredVariants.Add(RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster); + requiredVariants.Add(RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster); + requiredVariants.Add(RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster); + } + foreach (RenderPipelineVariantSemantic semantic in requiredVariants) + { + if (!descriptor.PipelineVariants.Any(value => value.Semantic == semantic)) + { + return Invalid( + $"Pack '{descriptor.Id}' does not declare required pipeline-variant " + + $"semantic '{semantic}'."); + } + } + + if (descriptor.AtmospherePolicy?.DirectionalShadowLightElevationResponse + is not { Count: >= 2 } + || descriptor.AtmospherePolicy.VolumetricShaftSunElevationResponse + is not { Count: >= 2 }) + { + return Invalid( + $"Pack '{descriptor.Id}' must declare directional-shadow and " + + "volumetric-shaft elevation response curves."); + } + + RenderPackSdkValidationResult shapes = + ValidateAtmosphericSemanticShapes(descriptor); + if (!shapes.Success) return shapes; + return ValidateAtmosphericSemanticEdges(descriptor); + } + + private static RenderPackSdkValidationResult ValidateDirectionalShadowProfile( + RenderPackDescriptor descriptor) + { + if (descriptor.HighestTier < RenderPackTier.Tier2) + return Invalid($"Pack '{descriptor.Id}' declares directional shadows below Tier2."); + + RenderCapability[] requiredCapabilities = + [ + RenderCapability.MainWorldColorIntermediate, + RenderCapability.FullscreenPasses, + RenderCapability.AuthoredCelestialDirectionalLight, + RenderCapability.AuthoredWeather, + RenderCapability.DirectionalShadowMaps, + RenderCapability.OutdoorDirectionalShadowCasterReplay, + RenderCapability.AnimatedCasterTransforms, + RenderCapability.AlphaCutoutShadowCasters, + ]; + foreach (RenderCapability capability in requiredCapabilities) + { + if (!descriptor.RequiredCapabilities.Contains(capability)) + { + return Invalid( + $"Pack '{descriptor.Id}' directional shadows must explicitly require " + + $"capability '{capability}'."); + } + } + + if (descriptor.Passes.Count(static value => + value.Semantic == RenderPassSemantic.DirectionalShadowDepth) != 1 + || descriptor.Resources.Count(static value => + value.Semantic == RenderResourceSemantic.DirectionalShadowDepth) != 1) + { + return Invalid( + $"Pack '{descriptor.Id}' must declare exactly one directional-shadow " + + "pass and resource."); + } + if (descriptor.SceneReplays.Count != 1 + || descriptor.SceneReplays[0].Semantic + != RenderSceneReplaySemantic.OutdoorDirectionalShadowCasters) + { + return Invalid( + $"Pack '{descriptor.Id}' must declare exactly one outdoor " + + "directional-shadow replay."); + } + + RenderPipelineVariantSemantic[] semantics = + [ + RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster, + RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster, + RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster, + RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver, + RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver, + ]; + RenderPipelineBaseSemantic[] bases = + [ + RenderPipelineBaseSemantic.Terrain, + RenderPipelineBaseSemantic.WorldMesh, + RenderPipelineBaseSemantic.WorldMesh, + RenderPipelineBaseSemantic.Terrain, + RenderPipelineBaseSemantic.WorldMesh, + ]; + RenderMaterialClass[] materials = + [ + RenderMaterialClass.Opaque, + RenderMaterialClass.Opaque | RenderMaterialClass.AnimatedOpaque, + RenderMaterialClass.AlphaCutout | RenderMaterialClass.AnimatedAlphaCutout, + RenderMaterialClass.Opaque, + RenderMaterialClass.Opaque | RenderMaterialClass.AlphaCutout + | RenderMaterialClass.AnimatedOpaque + | RenderMaterialClass.AnimatedAlphaCutout, + ]; + RenderSemanticInput[][] inputs = + [ + [RenderSemanticInput.CameraMatrices], + [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms], + [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms], + [RenderSemanticInput.DirectionalShadowMaps, + RenderSemanticInput.SelectedCelestialDirectionalLight], + [RenderSemanticInput.DirectionalShadowMaps, + RenderSemanticInput.SelectedCelestialDirectionalLight], + ]; + int expectedVariantCount = UsesMultiview(descriptor) ? 8 : semantics.Length; + if (descriptor.PipelineVariants.Count != expectedVariantCount) + { + return Invalid( + $"Pack '{descriptor.Id}' directional shadows require exactly {expectedVariantCount} " + + "semantic pipeline variants."); + } + if (UsesMultiview(descriptor)) + { + RenderPipelineVariantSemantic[] multiview = + [ + RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster, + RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster, + RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster, + ]; + for (int i = 0; i < multiview.Length; i++) + { + PipelineVariantDeclaration? variant = descriptor.PipelineVariants + .SingleOrDefault(value => value.Semantic == multiview[i]); + if (variant is null + || variant.BaseSemantic != bases[i] + || variant.CompatibleMaterials != materials[i] + || !variant.SemanticInputs.SequenceEqual(inputs[i])) + { + return Invalid( + $"Pipeline variant semantic '{multiview[i]}' does not match the fixed " + + "multiview directional-shadow executor contract."); + } + } + } + for (int i = 0; i < semantics.Length; i++) + { + PipelineVariantDeclaration? variant = descriptor.PipelineVariants + .SingleOrDefault(value => value.Semantic == semantics[i]); + if (variant is null + || variant.BaseSemantic != bases[i] + || variant.CompatibleMaterials != materials[i] + || !variant.SemanticInputs.SequenceEqual(inputs[i])) + { + return Invalid( + $"Pipeline variant semantic '{semantics[i]}' does not match the fixed " + + "directional-shadow executor contract."); + } + } + + foreach (RenderSettingSemantic semantic in new[] + { + RenderSettingSemantic.DirectionalShadowStrength, + RenderSettingSemantic.DirectionalShadowReachMetres, + RenderSettingSemantic.DirectionalShadowPcfTaps, + }) + { + if (!descriptor.Settings.Any(value => value.Semantic == semantic)) + { + return Invalid( + $"Pack '{descriptor.Id}' does not declare required directional-shadow " + + $"setting semantic '{semantic}'."); + } + } + if (descriptor.AtmospherePolicy?.DirectionalShadowLightElevationResponse + is not { Count: >= 2 }) + { + return Invalid( + $"Pack '{descriptor.Id}' must declare a directional-shadow " + + "light-elevation response curve."); + } + + RenderPassDeclaration shadow = descriptor.Passes.Single(value => + value.Semantic == RenderPassSemantic.DirectionalShadowDepth); + RenderResourceDeclaration depth = descriptor.Resources.Single(value => + value.Semantic == RenderResourceSemantic.DirectionalShadowDepth); + if (shadow.Hook != RenderPassHook.ShadowDepthBeforeWorld + || shadow.ResourceReads.Count != 0 + || shadow.ResourceWrites.Count != 1 + || !string.Equals( + shadow.ResourceWrites[0], depth.Id, StringComparison.OrdinalIgnoreCase)) + { + return Invalid( + $"Pack '{descriptor.Id}' directional-shadow pass must run before the world, " + + "read no declared resource, and write its directional-depth resource."); + } + if (depth.Kind != RenderResourceKind.Image2DArray + || depth.Format != RenderFormatClass.DirectionalDepth + || depth.Extent?.Mode != RenderExtentMode.AbsolutePixels + || depth.Usage != (RenderResourceUsage.Sampled | RenderResourceUsage.DepthAttachment) + || depth.Lifetime != RenderResourceLifetime.ActivePack) + { + return Invalid( + $"Pack '{descriptor.Id}' directional-shadow resource does not match the " + + "host executor's array-depth contract."); + } + + SceneReplayDeclaration replay = descriptor.SceneReplays[0]; + const RenderCasterClass requiredCasters = RenderCasterClass.Terrain + | RenderCasterClass.OpaqueWorld + | RenderCasterClass.AlphaCutoutWorld + | RenderCasterClass.AnimatedOpaque + | RenderCasterClass.AnimatedAlphaCutout; + if (replay.CasterClasses != requiredCasters || replay.ViewCount != 4) + { + return Invalid( + $"Pack '{descriptor.Id}' outdoor directional-shadow replay must declare " + + "all five headline caster classes and four maximum cascade views."); + } + + RenderPassDeclaration[] outputs = descriptor.Passes + .Where(static value => value.Hook == RenderPassHook.ToneMap + && value.ResourceWrites.Count == 0) + .ToArray(); + if (outputs.Length != 1 + || outputs[0].Semantic != RenderPassSemantic.CustomFullscreen + || !outputs[0].SemanticInputs.Contains(RenderSemanticInput.WorldColor)) + { + return Invalid( + $"Pack '{descriptor.Id}' directional shadows require exactly one custom " + + "ToneMap output-copy pass sampling WorldColor."); + } + if (descriptor.Passes.Any(value => + value.Semantic == RenderPassSemantic.CustomFullscreen + && value.Hook is RenderPassHook.ShadowDepthBeforeWorld + or RenderPassHook.AfterToneMapBeforePrivateViewports)) + { + return Invalid( + $"Pack '{descriptor.Id}' uses an unsupported custom pass hook in its " + + "directional-shadow graph."); + } + return RenderPackSdkValidationResult.Valid(); + } + + private static RenderPackSdkValidationResult ValidateAtmosphericSemanticShapes( + RenderPackDescriptor descriptor) + { + RenderPackSdkValidationResult result = Resource( + RenderResourceSemantic.MainWorldHdr, + RenderResourceKind.Image2D, + RenderFormatClass.HdrColor, + RenderExtentMode.RelativeToMainWorld, + RenderResourceUsage.Sampled | RenderResourceUsage.ColorAttachment); + if (!result.Success) return result; + foreach (RenderResourceSemantic semantic in new[] + { + RenderResourceSemantic.BloomPing, + RenderResourceSemantic.BloomPong, + RenderResourceSemantic.SunRays, + RenderResourceSemantic.VolumetricShafts, + }) + { + result = Resource( + semantic, + RenderResourceKind.Image2D, + RenderFormatClass.HdrColor, + RenderExtentMode.RelativeToMainWorld, + RenderResourceUsage.Sampled | RenderResourceUsage.ColorAttachment); + if (!result.Success) return result; + } + result = Resource( + RenderResourceSemantic.SunOcclusionMask, + RenderResourceKind.Image2D, + RenderFormatClass.SingleChannel, + RenderExtentMode.RelativeToMainWorld, + RenderResourceUsage.Sampled | RenderResourceUsage.ColorAttachment); + if (!result.Success) return result; + result = Resource( + RenderResourceSemantic.DirectionalShadowDepth, + RenderResourceKind.Image2DArray, + RenderFormatClass.DirectionalDepth, + RenderExtentMode.AbsolutePixels, + RenderResourceUsage.Sampled | RenderResourceUsage.DepthAttachment); + if (!result.Success) return result; + + result = Variant( + RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster, + RenderPipelineBaseSemantic.Terrain, + RenderMaterialClass.Opaque, + [RenderSemanticInput.CameraMatrices]); + if (!result.Success) return result; + result = Variant( + RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster, + RenderPipelineBaseSemantic.WorldMesh, + RenderMaterialClass.Opaque | RenderMaterialClass.AnimatedOpaque, + [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]); + if (!result.Success) return result; + result = Variant( + RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster, + RenderPipelineBaseSemantic.WorldMesh, + RenderMaterialClass.AlphaCutout | RenderMaterialClass.AnimatedAlphaCutout, + [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]); + if (!result.Success) return result; + if (UsesMultiview(descriptor)) + { + result = Variant( + RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster, + RenderPipelineBaseSemantic.Terrain, + RenderMaterialClass.Opaque, + [RenderSemanticInput.CameraMatrices]); + if (!result.Success) return result; + result = Variant( + RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster, + RenderPipelineBaseSemantic.WorldMesh, + RenderMaterialClass.Opaque | RenderMaterialClass.AnimatedOpaque, + [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]); + if (!result.Success) return result; + result = Variant( + RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster, + RenderPipelineBaseSemantic.WorldMesh, + RenderMaterialClass.AlphaCutout | RenderMaterialClass.AnimatedAlphaCutout, + [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]); + if (!result.Success) return result; + } + result = Variant( + RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver, + RenderPipelineBaseSemantic.Terrain, + RenderMaterialClass.Opaque, + [RenderSemanticInput.DirectionalShadowMaps, + RenderSemanticInput.SelectedCelestialDirectionalLight]); + if (!result.Success) return result; + result = Variant( + RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver, + RenderPipelineBaseSemantic.WorldMesh, + RenderMaterialClass.Opaque | RenderMaterialClass.AlphaCutout + | RenderMaterialClass.AnimatedOpaque + | RenderMaterialClass.AnimatedAlphaCutout, + [RenderSemanticInput.DirectionalShadowMaps, + RenderSemanticInput.SelectedCelestialDirectionalLight]); + if (!result.Success) return result; + + SceneReplayDeclaration replay = descriptor.SceneReplays.Single(value => + value.Semantic == RenderSceneReplaySemantic.OutdoorDirectionalShadowCasters); + const RenderCasterClass requiredCasters = RenderCasterClass.Terrain + | RenderCasterClass.OpaqueWorld + | RenderCasterClass.AlphaCutoutWorld + | RenderCasterClass.AnimatedOpaque + | RenderCasterClass.AnimatedAlphaCutout; + if (replay.CasterClasses != requiredCasters || replay.ViewCount != 4) + { + return Invalid( + $"Pack '{descriptor.Id}' outdoor directional-shadow replay must declare " + + "all five headline caster classes and four maximum cascade views."); + } + + return RenderPackSdkValidationResult.Valid(); + + RenderPackSdkValidationResult Resource( + RenderResourceSemantic semantic, + RenderResourceKind kind, + RenderFormatClass format, + RenderExtentMode extentMode, + RenderResourceUsage usage) + { + RenderResourceDeclaration resource = descriptor.Resources.Single(value => + value.Semantic == semantic); + if (resource.Kind != kind + || resource.Format != format + || resource.Extent?.Mode != extentMode + || resource.Usage != usage + || resource.Lifetime != RenderResourceLifetime.ActivePack) + { + return Invalid( + $"Resource semantic '{semantic}' does not match the fixed atmospheric " + + "executor's kind, format, extent, usage, and lifetime contract."); + } + return RenderPackSdkValidationResult.Valid(); + } + + RenderPackSdkValidationResult Variant( + RenderPipelineVariantSemantic semantic, + RenderPipelineBaseSemantic baseSemantic, + RenderMaterialClass materials, + IReadOnlyList inputs) + { + PipelineVariantDeclaration variant = descriptor.PipelineVariants.Single(value => + value.Semantic == semantic); + if (variant.BaseSemantic != baseSemantic + || variant.CompatibleMaterials != materials + || !variant.SemanticInputs.SequenceEqual(inputs)) + { + return Invalid( + $"Pipeline variant semantic '{semantic}' does not match the fixed " + + "atmospheric executor's base, material, and input contract."); + } + return RenderPackSdkValidationResult.Valid(); + } + } + + private static RenderPackSdkValidationResult ValidateAtmosphericSemanticEdges( + RenderPackDescriptor descriptor) + { + if (descriptor.Passes.Count != 8 + || descriptor.Resources.Count != 7 + || descriptor.PipelineVariants.Count != (UsesMultiview(descriptor) ? 8 : 5) + || descriptor.SceneReplays.Count != 1) + { + return Invalid( + $"Pack '{descriptor.Id}' requests the fixed atmospheric executor; API v1 " + + "requires exactly 8 semantic passes, 7 semantic resources, the hinted semantic " + + "pipeline variants, and 1 semantic scene replay."); + } + + RenderPackSdkValidationResult Hook(RenderPassSemantic semantic, RenderPassHook hook) + { + RenderPassDeclaration pass = descriptor.Passes.Single(value => value.Semantic == semantic); + return pass.Hook == hook + ? RenderPackSdkValidationResult.Valid() + : Invalid( + $"Pass semantic '{semantic}' must run at hook '{hook}', not '{pass.Hook}'."); + } + + RenderPackSdkValidationResult result = Hook( + RenderPassSemantic.DirectionalShadowDepth, + RenderPassHook.ShadowDepthBeforeWorld); + if (!result.Success) return result; + foreach (RenderPassSemantic semantic in new[] + { + RenderPassSemantic.SunOcclusion, + RenderPassSemantic.SunRays, + RenderPassSemantic.VolumetricShafts, + RenderPassSemantic.BloomDownsample, + RenderPassSemantic.BloomBlurHorizontal, + RenderPassSemantic.BloomBlurVertical, + }) + { + result = Hook(semantic, RenderPassHook.AtmosphereBeforeToneMap); + if (!result.Success) return result; + } + result = Hook(RenderPassSemantic.FilmicComposite, RenderPassHook.ToneMap); + if (!result.Success) return result; + + RenderPassSemantic[] declaredOrder = descriptor.Passes + .Where(static pass => pass.Hook is RenderPassHook.AtmosphereBeforeToneMap + or RenderPassHook.ToneMap) + .Select(static pass => pass.Semantic) + .ToArray(); + RenderPassSemantic[] requiredOrder = + [ + RenderPassSemantic.SunOcclusion, + RenderPassSemantic.SunRays, + RenderPassSemantic.VolumetricShafts, + RenderPassSemantic.BloomDownsample, + RenderPassSemantic.BloomBlurHorizontal, + RenderPassSemantic.BloomBlurVertical, + RenderPassSemantic.FilmicComposite, + ]; + if (!declaredOrder.SequenceEqual(requiredOrder)) + { + return Invalid( + $"Pack '{descriptor.Id}' atmospheric pass order does not match the " + + "renderer-owned semantic execution order."); + } + + result = Edge(RenderPassSemantic.DirectionalShadowDepth, [], + RenderResourceSemantic.DirectionalShadowDepth); + if (!result.Success) return result; + result = Edge(RenderPassSemantic.SunOcclusion, [], + RenderResourceSemantic.SunOcclusionMask); + if (!result.Success) return result; + result = Edge(RenderPassSemantic.SunRays, + [RenderResourceSemantic.SunOcclusionMask], RenderResourceSemantic.SunRays); + if (!result.Success) return result; + result = Edge(RenderPassSemantic.VolumetricShafts, + [RenderResourceSemantic.DirectionalShadowDepth], RenderResourceSemantic.VolumetricShafts); + if (!result.Success) return result; + result = Edge(RenderPassSemantic.BloomDownsample, + [RenderResourceSemantic.SunRays, RenderResourceSemantic.VolumetricShafts], + RenderResourceSemantic.BloomPing); + if (!result.Success) return result; + result = Edge(RenderPassSemantic.BloomBlurHorizontal, + [RenderResourceSemantic.BloomPing], RenderResourceSemantic.BloomPong); + if (!result.Success) return result; + result = Edge(RenderPassSemantic.BloomBlurVertical, + [RenderResourceSemantic.BloomPong], RenderResourceSemantic.BloomPing); + if (!result.Success) return result; + return Edge(RenderPassSemantic.FilmicComposite, + [RenderResourceSemantic.BloomPing, RenderResourceSemantic.SunRays, + RenderResourceSemantic.VolumetricShafts], + output: null); + + RenderPackSdkValidationResult Edge( + RenderPassSemantic passSemantic, + IReadOnlyList reads, + RenderResourceSemantic? output) + { + RenderPassDeclaration pass = descriptor.Passes.Single(value => + value.Semantic == passSemantic); + RenderResourceSemantic[] actualReads = pass.ResourceReads + .Select(id => descriptor.Resources.Single(resource => string.Equals( + resource.Id, id, StringComparison.OrdinalIgnoreCase)).Semantic) + .ToArray(); + if (!actualReads.SequenceEqual(reads)) + { + return Invalid( + $"Pass semantic '{passSemantic}' declares resource reads that do not " + + "match its renderer-owned execution edges."); + } + RenderResourceSemantic[] actualWrites = pass.ResourceWrites + .Select(id => descriptor.Resources.Single(resource => string.Equals( + resource.Id, id, StringComparison.OrdinalIgnoreCase)).Semantic) + .ToArray(); + RenderResourceSemantic[] expectedWrites = output is { } semantic ? [semantic] : []; + return actualWrites.SequenceEqual(expectedWrites) + ? RenderPackSdkValidationResult.Valid() + : Invalid( + $"Pass semantic '{passSemantic}' declares a resource output that does " + + "not match its renderer-owned execution edge."); + } + } + + private static RenderPackSdkValidationResult UniqueNonCustomSemantics( + RenderPackDescriptor descriptor, + IEnumerable values, + Func select, + TSemantic custom, + string kind) + where T : class + where TSemantic : struct, Enum + { + var seen = new HashSet(); + foreach (T? value in values) + { + if (value is null) + continue; + TSemantic semantic = select(value); + if (!Enum.IsDefined(semantic)) + return Invalid($"Pack '{descriptor.Id}' declares an unknown {kind} semantic."); + if (!EqualityComparer.Default.Equals(semantic, custom) + && !seen.Add(semantic)) + { + return Invalid( + $"Pack '{descriptor.Id}' declares duplicate {kind} semantic '{semantic}'."); + } + } + return RenderPackSdkValidationResult.Valid(); + } + + private static RenderPackSdkValidationResult ValidateUniqueIds( + RenderPackDescriptor descriptor) + { + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + IEnumerable<(string Kind, string? Id)> declarations = + descriptor.Resources.Select(static value => ("resource", value?.Id)) + .Concat(descriptor.Passes.Select(static value => ("pass", value?.Id))) + .Concat(descriptor.SceneReplays.Select( + static value => ("scene replay", value?.Id))) + .Concat(descriptor.PipelineVariants.Select( + static value => ("pipeline variant", value?.Id))) + .Concat(descriptor.QualityPresets.Select( + static value => ("quality preset", value?.Id))) + .Concat(descriptor.Settings.Select(static value => ("setting", value?.Id))); + + foreach ((string kind, string? id) in declarations) + { + if (!StableId.IsValid(id)) + return Invalid($"Pack '{descriptor.Id}' has an invalid {kind} id."); + if (!ids.Add($"{kind}:{id}")) + return Invalid($"Pack '{descriptor.Id}' declares duplicate {kind} id '{id}'."); + } + return RenderPackSdkValidationResult.Valid(); + } + + private static RenderPackSdkValidationResult ValidateResources( + RenderPackDescriptor descriptor) + { + long declaredBytes = 0; + foreach (RenderResourceDeclaration? resource in descriptor.Resources) + { + if (resource is null) + return Invalid($"Pack '{descriptor.Id}' contains a null resource declaration."); + if (!Enum.IsDefined(resource.Kind) + || !Enum.IsDefined(resource.Format) + || !Enum.IsDefined(resource.Lifetime) + || !ValidFlags(resource.Usage, RenderResourceUsage.TransferDestination + | RenderResourceUsage.TransferSource + | RenderResourceUsage.Storage + | RenderResourceUsage.DepthAttachment + | RenderResourceUsage.ColorAttachment + | RenderResourceUsage.Sampled)) + { + return Invalid($"Resource '{resource.Id}' declares an unknown enum value."); + } + if (resource.Kind == RenderResourceKind.Buffer + || resource.Format == RenderFormatClass.StructuredData + || resource.Usage.HasFlag(RenderResourceUsage.Storage)) + { + return Invalid( + $"Resource '{resource.Id}' uses a buffer/structured/storage " + + "declaration reserved and unbindable in render-pack API v1."); + } + if (resource.Kind == RenderResourceKind.Image2DArray + && resource.Format != RenderFormatClass.DirectionalDepth) + { + return Invalid( + $"Resource '{resource.Id}' uses a color image array; render-pack API " + + "v1 reserves image arrays for directional depth maps."); + } + if (resource.EstimatedResidentBytes < 0 || resource.SizeBytes < 0) + return Invalid($"Resource '{resource.Id}' declares negative bytes."); + if (resource.Usage == RenderResourceUsage.None) + return Invalid($"Resource '{resource.Id}' declares no usage."); + if (resource.Kind == RenderResourceKind.Buffer && resource.Extent is not null) + return Invalid($"Buffer resource '{resource.Id}' must not declare an image extent."); + if (resource.Kind == RenderResourceKind.Buffer && resource.SizeBytes <= 0) + return Invalid($"Buffer resource '{resource.Id}' must declare positive SizeBytes."); + if (resource.Kind != RenderResourceKind.Buffer) + { + if (resource.Extent is null) + return Invalid($"Image resource '{resource.Id}' has no extent."); + RenderExtentDeclaration extent = resource.Extent; + if (!Enum.IsDefined(extent.Mode) + || !FinitePositive(extent.Width) + || !FinitePositive(extent.Height) + || extent.Layers <= 0 + || extent.Layers > MaximumImageLayers) + { + return Invalid($"Image resource '{resource.Id}' has an invalid extent."); + } + if (extent.Mode == RenderExtentMode.AbsolutePixels + && (extent.Width > MaximumImageDimension + || extent.Height > MaximumImageDimension + || extent.Width != Math.Truncate(extent.Width) + || extent.Height != Math.Truncate(extent.Height))) + { + return Invalid( + $"Image resource '{resource.Id}' exceeds or fractionalizes " + + "the absolute image limit."); + } + if (extent.Mode != RenderExtentMode.AbsolutePixels + && (extent.Width > 1.0 || extent.Height > 1.0)) + { + return Invalid( + $"Relative resource '{resource.Id}' must use a scale in (0, 1]."); + } + } + + if (!TryAdd(ref declaredBytes, resource.EstimatedResidentBytes)) + return Invalid($"Pack '{descriptor.Id}' resource byte total overflows."); + } + if (declaredBytes > MaximumPackBytes) + { + return Invalid( + $"Pack '{descriptor.Id}' declares {declaredBytes} resident bytes; " + + $"the SDK ceiling is {MaximumPackBytes}."); + } + return RenderPackSdkValidationResult.Valid(); + } + + private static RenderPackSdkValidationResult ValidatePasses( + RenderPackDescriptor descriptor) + { + HashSet resources = descriptor.Resources + .Select(static value => value.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var written = new HashSet(StringComparer.OrdinalIgnoreCase); + Dictionary resourceDeclarations = + descriptor.Resources.ToDictionary( + static value => value.Id, + StringComparer.OrdinalIgnoreCase); + int priorHook = int.MinValue; + + foreach (RenderPassDeclaration? pass in descriptor.Passes) + { + if (pass is null) + return Invalid($"Pack '{descriptor.Id}' contains a null pass declaration."); + if (!Enum.IsDefined(pass.Hook)) + return Invalid($"Pass '{pass.Id}' declares an unknown hook."); + if ((int)pass.Hook < priorHook) + return Invalid($"Pass '{pass.Id}' moves backward in renderer hook order."); + priorHook = (int)pass.Hook; + if (!SafeRelativePath.IsValid(pass.VertexShaderAsset) + || !SafeRelativePath.IsValid(pass.FragmentShaderAsset)) + { + return Invalid($"Pass '{pass.Id}' declares an unsafe shader asset key."); + } + if (pass.SemanticInputs is null + || pass.ResourceReads is null + || pass.ResourceWrites is null) + { + return Invalid($"Pass '{pass.Id}' contains a null binding list."); + } + if (pass.SemanticInputs.Any(static value => !Enum.IsDefined(value))) + return Invalid($"Pass '{pass.Id}' declares an unknown semantic input."); + if (pass.SemanticInputs.Count + != pass.SemanticInputs.Distinct().Count()) + { + return Invalid($"Pass '{pass.Id}' declares a duplicate semantic input."); + } + if (pass.ResourceReads.Count + != pass.ResourceReads.Distinct(StringComparer.OrdinalIgnoreCase).Count() + || pass.ResourceWrites.Count + != pass.ResourceWrites.Distinct(StringComparer.OrdinalIgnoreCase).Count()) + { + return Invalid($"Pass '{pass.Id}' declares a duplicate resource binding."); + } + if (pass.ResourceWrites.Count > 1) + { + return Invalid( + $"Pass '{pass.Id}' writes {pass.ResourceWrites.Count} resources; " + + "render-pack API v1 supports one attachment per declared pass."); + } + if (pass.ResourceWrites.Count == 0 + && pass.Hook is not RenderPassHook.ToneMap + and not RenderPassHook.AfterToneMapBeforePrivateViewports) + { + return Invalid( + $"Pass '{pass.Id}' has no declared output at hook '{pass.Hook}'."); + } + foreach (string read in pass.ResourceReads) + { + if (!resources.Contains(read)) + return Invalid($"Pass '{pass.Id}' reads unknown resource '{read}'."); + if (!written.Contains(read)) + return Invalid($"Pass '{pass.Id}' reads resource '{read}' before it is written."); + if (!resourceDeclarations[read].Usage.HasFlag(RenderResourceUsage.Sampled)) + return Invalid($"Pass '{pass.Id}' samples non-sampled resource '{read}'."); + } + foreach (string write in pass.ResourceWrites) + { + if (!resources.Contains(write)) + return Invalid($"Pass '{pass.Id}' writes unknown resource '{write}'."); + if (pass.ResourceReads.Contains(write, StringComparer.OrdinalIgnoreCase)) + return Invalid($"Pass '{pass.Id}' reads and writes resource '{write}'."); + RenderResourceDeclaration resource = resourceDeclarations[write]; + RenderResourceUsage attachment = + resource.Format == RenderFormatClass.DirectionalDepth + ? RenderResourceUsage.DepthAttachment + : RenderResourceUsage.ColorAttachment; + if (!resource.Usage.HasFlag(attachment)) + return Invalid($"Pass '{pass.Id}' writes non-attachment resource '{write}'."); + written.Add(write); + } + + int sampledInputs = pass.SemanticInputs.Count(static value => + value is RenderSemanticInput.WorldColor + or RenderSemanticInput.SceneDepth + or RenderSemanticInput.SceneNormals); + bool shadowSemantic = pass.SemanticInputs.Contains( + RenderSemanticInput.DirectionalShadowMaps); + sampledInputs += pass.ResourceReads.Count(read => + { + RenderResourceDeclaration resource = resourceDeclarations[read]; + return resource.Usage.HasFlag(RenderResourceUsage.Sampled) + && !(shadowSemantic + && resource.Format == RenderFormatClass.DirectionalDepth); + }); + if (sampledInputs > RenderPackShaderAbi.SampledPassInputCapacity) + { + return Invalid( + $"Pass '{pass.Id}' requires {sampledInputs} sampled inputs; " + + "render-pack API v1 exposes at most four (TextureIndexA-D)." + + " DirectionalShadowMaps uses binding 6 and does not count."); + } + } + return RenderPackSdkValidationResult.Valid(); + } + + private static RenderPackSdkValidationResult ValidateSemanticCapabilities( + RenderPackDescriptor descriptor) + { + foreach (RenderPassDeclaration shadowPass in descriptor.Passes.Where(static pass => + pass.Semantic == RenderPassSemantic.DirectionalShadowDepth)) + { + if (!shadowPass.SemanticInputs.Contains( + RenderSemanticInput.SelectedCelestialDirectionalLight) + || shadowPass.SemanticInputs.Contains(RenderSemanticInput.SunDirection)) + { + return Invalid( + $"Directional-shadow pass '{shadowPass.Id}' must declare " + + $"'{RenderSemanticInput.SelectedCelestialDirectionalLight}' and must not " + + "alias the sun-specific atmospheric direction."); + } + } + + IEnumerable inputs = descriptor.Passes + .SelectMany(static pass => pass?.SemanticInputs ?? []) + .Concat(descriptor.PipelineVariants.SelectMany( + static variant => variant?.SemanticInputs ?? [])); + foreach (RenderSemanticInput input in inputs.Distinct()) + { + RenderCapability? required = input switch + { + RenderSemanticInput.WorldColor => + RenderCapability.MainWorldColorIntermediate, + RenderSemanticInput.SceneDepth => + RenderCapability.SceneDepthSampling, + RenderSemanticInput.SceneNormals => + RenderCapability.SceneNormalSampling, + RenderSemanticInput.SunDirection => + RenderCapability.AuthoredSunDirection, + RenderSemanticInput.SelectedCelestialDirectionalLight => + RenderCapability.AuthoredCelestialDirectionalLight, + RenderSemanticInput.SunScreenPosition => + RenderCapability.AuthoredSunScreenPosition, + RenderSemanticInput.ActiveDayGroup or RenderSemanticInput.Weather => + RenderCapability.AuthoredWeather, + RenderSemanticInput.CameraMatrices or RenderSemanticInput.FrameTime => + RenderCapability.FullscreenPasses, + RenderSemanticInput.ShadowCasterTransforms => + RenderCapability.AnimatedCasterTransforms, + RenderSemanticInput.DirectionalShadowMaps => + RenderCapability.DirectionalShadowMaps, + _ => null, + }; + if (required is { } capability + && !descriptor.RequiredCapabilities.Contains(capability)) + { + return Invalid( + $"Pack '{descriptor.Id}' declares semantic '{input}' but does not " + + $"require capability '{capability}'."); + } + } + return RenderPackSdkValidationResult.Valid(); + } + + private static RenderPackSdkValidationResult ValidateReplays( + RenderPackDescriptor descriptor) + { + const RenderCasterClass known = RenderCasterClass.Terrain + | RenderCasterClass.OpaqueWorld + | RenderCasterClass.AlphaCutoutWorld + | RenderCasterClass.AnimatedOpaque + | RenderCasterClass.AnimatedAlphaCutout; + foreach (SceneReplayDeclaration? replay in descriptor.SceneReplays) + { + if (replay is null) + return Invalid($"Pack '{descriptor.Id}' contains a null scene replay."); + if (!Enum.IsDefined(replay.Semantic) + || !ValidFlags(replay.CasterClasses, known)) + { + return Invalid($"Scene replay '{replay.Id}' declares an unknown enum value."); + } + if (replay.ViewCount is <= 0 or > 4) + return Invalid($"Scene replay '{replay.Id}' must request 1..4 views."); + if (replay.CasterClasses == RenderCasterClass.None) + return Invalid($"Scene replay '{replay.Id}' declares no caster classes."); + } + return RenderPackSdkValidationResult.Valid(); + } + + private static RenderPackSdkValidationResult ValidateVariants( + RenderPackDescriptor descriptor) + { + const RenderMaterialClass known = RenderMaterialClass.Opaque + | RenderMaterialClass.AlphaCutout + | RenderMaterialClass.AnimatedOpaque + | RenderMaterialClass.AnimatedAlphaCutout; + foreach (PipelineVariantDeclaration? variant in descriptor.PipelineVariants) + { + if (variant is null) + return Invalid($"Pack '{descriptor.Id}' contains a null pipeline variant."); + if (!Enum.IsDefined(variant.BaseSemantic) + || !ValidFlags(variant.CompatibleMaterials, known)) + { + return Invalid($"Pipeline variant '{variant.Id}' declares an unknown enum value."); + } + if (!SafeRelativePath.IsValid(variant.VertexShaderAsset) + || !SafeRelativePath.IsValid(variant.FragmentShaderAsset)) + { + return Invalid( + $"Pipeline variant '{variant.Id}' declares an unsafe shader asset key."); + } + if (variant.CompatibleMaterials == RenderMaterialClass.None) + { + return Invalid( + $"Pipeline variant '{variant.Id}' declares no compatible materials."); + } + if (variant.SemanticInputs is null) + return Invalid($"Pipeline variant '{variant.Id}' has a null semantic-input list."); + if (variant.SemanticInputs.Any(static value => !Enum.IsDefined(value))) + { + return Invalid( + $"Pipeline variant '{variant.Id}' declares an unknown semantic input."); + } + } + return RenderPackSdkValidationResult.Valid(); + } + + private static RenderPackSdkValidationResult ValidateSettings( + RenderPackDescriptor descriptor) + { + if (descriptor.Settings.Count > RenderPackShaderAbi.PackSettingScalarCapacity) + { + return Invalid( + $"Pack '{descriptor.Id}' declares {descriptor.Settings.Count} settings; " + + "render-pack API v1 exposes at most " + + $"{RenderPackShaderAbi.PackSettingScalarCapacity} shader setting slots."); + } + foreach (RenderSettingDeclaration? setting in descriptor.Settings) + { + if (setting is null) + return Invalid($"Pack '{descriptor.Id}' contains a null setting."); + if (!Enum.IsDefined(setting.Kind)) + return Invalid($"Setting '{setting.Id}' declares an unknown kind."); + if (string.IsNullOrWhiteSpace(setting.DisplayName)) + return Invalid($"Setting '{setting.Id}' has no display name."); + if (setting.DefaultValue is null || setting.Choices is null) + return Invalid($"Setting '{setting.Id}' contains a null value list."); + if (setting.Minimum is { } min && !double.IsFinite(min) + || setting.Maximum is { } max && !double.IsFinite(max) + || setting.Step is { } step && (!double.IsFinite(step) || step <= 0)) + { + return Invalid($"Setting '{setting.Id}' has invalid bounds."); + } + if (setting.Minimum is { } minimum + && setting.Maximum is { } maximum + && minimum > maximum) + { + return Invalid($"Setting '{setting.Id}' has an inverted range."); + } + if (setting.Kind == RenderSettingKind.Choice + && (setting.Choices.Count == 0 + || !setting.Choices.Contains(setting.DefaultValue, StringComparer.Ordinal))) + { + return Invalid( + $"Choice setting '{setting.Id}' has no choices or an unknown default."); + } + if (!ValidSettingValue(setting, setting.DefaultValue)) + return Invalid($"Setting '{setting.Id}' has an invalid default value."); + } + return RenderPackSdkValidationResult.Valid(); + } + + private static RenderPackSdkValidationResult ValidatePresets( + RenderPackDescriptor descriptor) + { + if (descriptor.QualityPresets.Count == 0) + return Invalid($"Pack '{descriptor.Id}' declares no quality presets."); + HashSet resources = descriptor.Resources + .Select(static value => value.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + HashSet settings = descriptor.Settings + .Select(static value => value.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + Dictionary settingDeclarations = + descriptor.Settings.ToDictionary( + static value => value.Id, + StringComparer.OrdinalIgnoreCase); + + foreach (RenderQualityPreset? preset in descriptor.QualityPresets) + { + if (preset is null) + return Invalid($"Pack '{descriptor.Id}' contains a null quality preset."); + if (string.IsNullOrWhiteSpace(preset.DisplayName)) + return Invalid($"Quality preset '{preset.Id}' has no display name."); + if (preset.RequiredCapabilities is null + || preset.ResourceOverrides is null + || preset.SettingOverrides is null) + { + return Invalid($"Quality preset '{preset.Id}' contains a null declaration list."); + } + if (preset.RequiredCapabilities.Any(static value => !Enum.IsDefined(value))) + return Invalid($"Quality preset '{preset.Id}' declares an unknown capability."); + const RenderQualityExecutionHints supportedHints = + RenderQualityExecutionHints.FusedAtmosphericPostProcess + | RenderQualityExecutionHints.MultiviewDirectionalShadowCascades; + if ((preset.ExecutionHints & ~supportedHints) != 0) + return Invalid($"Quality preset '{preset.Id}' declares an unknown execution hint."); + if ((preset.ExecutionHints & RenderQualityExecutionHints.MultiviewDirectionalShadowCascades) != 0 + && (preset.Semantic != RenderQualitySemantic.Low + || !preset.RequiredCapabilities.Contains( + RenderCapability.MultiviewDirectionalShadowCascades))) + { + return Invalid( + $"Quality preset '{preset.Id}' must be Low and require multiview directional-shadow capability."); + } + if (preset.MaxResidentGpuBytes is < 0 or > MaximumPackBytes) + return Invalid($"Quality preset '{preset.Id}' exceeds the pack memory ceiling."); + if (!FiniteNonNegative(preset.MaxIncrementalGpuMillisecondsP50) + || !FiniteNonNegative(preset.MaxIncrementalGpuMillisecondsP99) + || !FiniteNonNegative(preset.MaxIncrementalCpuMillisecondsP50) + || !FiniteNonNegative(preset.MaxIncrementalCpuMillisecondsP99) + || preset.MaxIncrementalGpuMillisecondsP50 + > preset.MaxIncrementalGpuMillisecondsP99 + || preset.MaxIncrementalCpuMillisecondsP50 + > preset.MaxIncrementalCpuMillisecondsP99) + { + return Invalid($"Quality preset '{preset.Id}' has invalid performance budgets."); + } + foreach (RenderQualityResourceOverride? value in preset.ResourceOverrides) + { + if (value is null) + return Invalid($"Quality preset '{preset.Id}' contains a null resource override."); + if (!resources.Contains(value.ResourceId)) + { + return Invalid( + $"Quality preset '{preset.Id}' overrides unknown resource " + + $"'{value.ResourceId}'."); + } + if (value.SizeBytes < 0 || value.EstimatedResidentBytes < 0) + return Invalid($"Quality preset '{preset.Id}' declares negative resource bytes."); + if (value.Extent is { } extent && !ValidExtent(extent)) + { + return Invalid( + $"Quality preset '{preset.Id}' declares an invalid " + + $"extent for resource '{value.ResourceId}'."); + } + } + var overriddenSettings = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (RenderQualitySettingOverride? value in preset.SettingOverrides) + { + if (value is null) + return Invalid($"Quality preset '{preset.Id}' contains a null setting override."); + if (!settings.Contains(value.SettingId)) + { + return Invalid( + $"Quality preset '{preset.Id}' overrides unknown setting " + + $"'{value.SettingId}'."); + } + if (!overriddenSettings.Add(value.SettingId)) + { + return Invalid( + $"Quality preset '{preset.Id}' overrides setting " + + $"'{value.SettingId}' more than once."); + } + if (!ValidSettingValue( + settingDeclarations[value.SettingId], + value.Value)) + { + return Invalid( + $"Quality preset '{preset.Id}' supplies an invalid value " + + $"for setting '{value.SettingId}'."); + } + } + } + return RenderPackSdkValidationResult.Valid(); + } + + private static RenderPackSdkValidationResult ValidateAtmosphere( + RenderPackDescriptor descriptor) + { + AtmospherePolicyDeclaration? policy = descriptor.AtmospherePolicy; + if (policy is null) + return RenderPackSdkValidationResult.Valid(); + if (policy.SunElevationResponse is null + || policy.ActiveDayGroupMultipliers is null + || policy.DirectionalShadowLightElevationResponse is null + || policy.VolumetricShaftSunElevationResponse is null) + { + return Invalid($"Pack '{descriptor.Id}' has a null atmosphere-policy list."); + } + + RenderPackSdkValidationResult curve = ValidateCurve( + policy.SunElevationResponse, + "sun-elevation"); + if (!curve.Success) return curve; + curve = ValidateCurve( + policy.DirectionalShadowLightElevationResponse, + "directional-shadow light-elevation", + unitInterval: true); + if (!curve.Success) return curve; + curve = ValidateDirectionalShadowHorizon( + policy.DirectionalShadowLightElevationResponse); + if (!curve.Success) return curve; + curve = ValidateCurve( + policy.VolumetricShaftSunElevationResponse, + "volumetric-shaft sun-elevation", + unitInterval: true); + if (!curve.Success) return curve; + + var groups = new HashSet(); + foreach (ActiveDayGroupMultiplier? value in policy.ActiveDayGroupMultipliers) + { + if (value is null + || !groups.Add(value.ActiveDayGroup) + || !FiniteNonNegative(value.Multiplier)) + { + return Invalid( + $"Pack '{descriptor.Id}' has an invalid active-day-group mapping."); + } + } + return RenderPackSdkValidationResult.Valid(); + + RenderPackSdkValidationResult ValidateCurve( + IReadOnlyList points, + string name, + bool unitInterval = false) + { + double priorElevation = double.NegativeInfinity; + foreach (SunElevationResponsePoint? point in points) + { + if (point is null + || !double.IsFinite(point.ElevationDegrees) + || point.ElevationDegrees is < -90 or > 90 + || !FiniteNonNegative(point.Multiplier) + || (unitInterval && point.Multiplier > 1) + || point.ElevationDegrees <= priorElevation) + { + return Invalid( + $"Pack '{descriptor.Id}' has an invalid {name} response curve."); + } + priorElevation = point.ElevationDegrees; + } + return RenderPackSdkValidationResult.Valid(); + } + + RenderPackSdkValidationResult ValidateDirectionalShadowHorizon( + IReadOnlyList points) + { + bool hasExactHorizonPoint = false; + SunElevationResponsePoint? firstAboveHorizon = null; + foreach (SunElevationResponsePoint point in points) + { + if (point.ElevationDegrees <= 0d) + { + if (point.Multiplier != 0d) + return InvalidHorizon(); + hasExactHorizonPoint |= point.ElevationDegrees == 0d; + continue; + } + + firstAboveHorizon = point; + break; + } + + return !hasExactHorizonPoint + && firstAboveHorizon is { Multiplier: not 0d } + ? InvalidHorizon() + : RenderPackSdkValidationResult.Valid(); + + RenderPackSdkValidationResult InvalidHorizon() => Invalid( + $"Pack '{descriptor.Id}' directional-shadow light-elevation " + + "curve must resolve to zero at and below the 0-degree " + + "authored horizon."); + } + } + + private static string? FirstNullList(RenderPackDescriptor value) + { + if (value.RequiredCapabilities is null) return "required-capability"; + if (value.OptionalCapabilities is null) return "optional-capability"; + if (value.Resources is null) return "resource"; + if (value.Passes is null) return "pass"; + if (value.SceneReplays is null) return "scene-replay"; + if (value.PipelineVariants is null) return "pipeline-variant"; + if (value.QualityPresets is null) return "quality-preset"; + if (value.Settings is null) return "setting"; + return null; + } + + private static bool FinitePositive(double value) => + double.IsFinite(value) && value > 0; + + private static bool FiniteNonNegative(double value) => + double.IsFinite(value) && value >= 0; + + private static bool UsesMultiview(RenderPackDescriptor descriptor) => + descriptor.QualityPresets.Any(preset => + (preset.ExecutionHints + & RenderQualityExecutionHints.MultiviewDirectionalShadowCascades) != 0); + + private static bool ValidSettingValue( + RenderSettingDeclaration setting, + string? value) => + RenderPackSettingValueCodec.TryEncode(setting, value, out _); + + private static bool ValidExtent(RenderExtentDeclaration extent) + { + if (!Enum.IsDefined(extent.Mode) + || !FinitePositive(extent.Width) + || !FinitePositive(extent.Height) + || extent.Layers is <= 0 or > MaximumImageLayers) + { + return false; + } + return extent.Mode == RenderExtentMode.AbsolutePixels + ? extent.Width <= MaximumImageDimension + && extent.Height <= MaximumImageDimension + && extent.Width == Math.Truncate(extent.Width) + && extent.Height == Math.Truncate(extent.Height) + : extent.Width <= 1.0 && extent.Height <= 1.0; + } + + private static bool ValidFlags(T value, T known) + where T : struct, Enum + { + ulong actual = unchecked((ulong)Convert.ToInt64(value)); + ulong permitted = unchecked((ulong)Convert.ToInt64(known)); + return (actual & ~permitted) == 0; + } + + private static bool TryAdd(ref long total, long value) + { + if (value > long.MaxValue - total) + return false; + total += value; + return true; + } + + private static RenderPackSdkValidationResult Invalid(string reason) => + RenderPackSdkValidationResult.Invalid(reason); +} diff --git a/tools/RenderPackValidator/RenderPackValidatorCommand.cs b/tools/RenderPackValidator/RenderPackValidatorCommand.cs new file mode 100644 index 00000000..85b8180a --- /dev/null +++ b/tools/RenderPackValidator/RenderPackValidatorCommand.cs @@ -0,0 +1,235 @@ +using System.Reflection; +using System.Runtime.Loader; +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.Tools.RenderPackValidator; + +internal static class RenderPackValidatorCommand +{ + internal static int Run( + IReadOnlyList args, + TextWriter output, + TextWriter error) + { + ArgumentNullException.ThrowIfNull(args); + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(error); + + if (args.Count != 1 || args[0] is "-h" or "--help") + { + TextWriter target = args.Count == 1 ? output : error; + target.WriteLine("usage: RenderPackValidator "); + target.WriteLine("The directory must contain plugin.json and its built entry DLL."); + return args.Count == 1 ? 0 : 2; + } + + string packDirectory; + try + { + packDirectory = Path.GetFullPath(args[0]); + } + catch (Exception exception) when (exception is ArgumentException + or NotSupportedException + or PathTooLongException) + { + error.WriteLine($"FAIL: invalid pack directory: {exception.Message}"); + return 2; + } + + string manifestPath = Path.Combine(packDirectory, "plugin.json"); + if (!File.Exists(manifestPath)) + { + error.WriteLine($"FAIL: plugin.json was not found in '{packDirectory}'."); + return 1; + } + + string manifestJson; + try + { + manifestJson = File.ReadAllText(manifestPath); + } + catch (Exception exception) when (exception is IOException + or UnauthorizedAccessException) + { + error.WriteLine($"FAIL: plugin.json could not be read: {exception.Message}"); + return 1; + } + + ValidationOutcome parsed = PackManifest.Parse(manifestJson); + if (!parsed.Success) + { + error.WriteLine($"FAIL: {parsed.Reason}"); + return 1; + } + + PackManifest manifest = parsed.Manifest!; + string entryPath = ResolveInside(packDirectory, manifest.EntryDll); + if (!File.Exists(entryPath)) + { + error.WriteLine($"FAIL: entry DLL '{manifest.EntryDll}' does not exist."); + return 1; + } + + PackLoadContext? loadContext = null; + try + { + loadContext = new PackLoadContext(entryPath); + Assembly assembly = loadContext.LoadFromAssemblyPath(entryPath); + Type[] entryPoints = GetLoadableTypes(assembly) + .Where(static type => + !type.IsAbstract + && !type.IsInterface + && typeof(IRenderPackPlugin).IsAssignableFrom(type)) + .ToArray(); + if (entryPoints.Length != 1) + { + error.WriteLine( + $"FAIL: entry DLL must contain exactly one public constructible " + + $"IRenderPackPlugin; found {entryPoints.Length}."); + return 1; + } + + if (!entryPoints[0].IsVisible + || entryPoints[0].GetConstructor(Type.EmptyTypes) is null) + { + error.WriteLine( + $"FAIL: render-pack entry point '{entryPoints[0].FullName}' " + + "has no public parameterless constructor."); + return 1; + } + + var plugin = (IRenderPackPlugin)Activator.CreateInstance(entryPoints[0])!; + using var registrations = new CapturingRenderPackRegistry(); + plugin.Register(registrations); + if (registrations.Entries.Count == 0) + { + error.WriteLine("FAIL: render-pack entry point registered no packs."); + return 1; + } + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (CapturedRenderPack entry in registrations.Entries) + { + RenderPackSdkValidationResult descriptor = + RenderPackSdkValidator.ValidateDescriptor(entry.Descriptor); + if (!descriptor.Success) + { + error.WriteLine($"FAIL: {descriptor.Reason}"); + return 1; + } + if (!seen.Add(entry.Descriptor.Id)) + { + error.WriteLine( + $"FAIL: render-pack id '{entry.Descriptor.Id}' was registered more than once."); + return 1; + } + + RenderPackSdkValidationResult assets = + RenderPackSdkValidator.ValidateAssets(entry.Descriptor, entry.Assets); + if (!assets.Success) + { + error.WriteLine($"FAIL: {assets.Reason}"); + return 1; + } + + output.WriteLine( + $"OK: {entry.Descriptor.Id} {entry.Descriptor.PackVersion} " + + $"(API {entry.Descriptor.PackApiVersion}, " + + $"{entry.Descriptor.QualityPresets.Count} preset(s))."); + } + + output.WriteLine( + $"Validated {registrations.Entries.Count} render pack(s) from '{manifest.Id}'."); + return 0; + } + catch (Exception exception) + { + error.WriteLine( + $"FAIL: pack entry point could not be inspected: " + + exception.GetBaseException().Message); + return 1; + } + finally + { + loadContext?.Unload(); + } + } + + private static string ResolveInside(string root, string relativePath) + { + string resolved = Path.GetFullPath(Path.Combine(root, relativePath)); + string prefix = Path.TrimEndingDirectorySeparator(root) + + Path.DirectorySeparatorChar; + StringComparison comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (!resolved.StartsWith(prefix, comparison)) + throw new UnauthorizedAccessException("entryDll escapes the pack directory."); + return resolved; + } + + private static IEnumerable GetLoadableTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException exception) + { + return exception.Types.OfType(); + } + } + + private sealed class PackLoadContext(string entryPath) + : AssemblyLoadContext("render-pack-sdk-validator", isCollectible: true) + { + private const string AbstractionsAssemblyName = "AcDream.Plugin.Abstractions"; + private readonly AssemblyDependencyResolver _resolver = new(entryPath); + + protected override Assembly? Load(AssemblyName assemblyName) + { + if (assemblyName.Name == AbstractionsAssemblyName) + return null; + string? path = _resolver.ResolveAssemblyToPath(assemblyName); + return path is null ? null : LoadFromAssemblyPath(path); + } + } +} + +internal sealed class CapturingRenderPackRegistry : IRenderPackRegistry, IDisposable +{ + private readonly List _entries = []; + private bool _disposed; + + internal IReadOnlyList Entries => _entries; + + public IDisposable Register(RenderPackDescriptor descriptor, IRenderPackAssets assets) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(assets); + var entry = new CapturedRenderPack(descriptor, assets); + _entries.Add(entry); + return new Registration(_entries, entry); + } + + public void Dispose() + { + _disposed = true; + _entries.Clear(); + } + + private sealed class Registration( + List entries, + CapturedRenderPack entry) : IDisposable + { + private List? _entries = entries; + + public void Dispose() => + Interlocked.Exchange(ref _entries, null)?.Remove(entry); + } +} + +internal sealed record CapturedRenderPack( + RenderPackDescriptor Descriptor, + IRenderPackAssets Assets); diff --git a/tools/RenderPackValidator/packages.neutral.lock.json b/tools/RenderPackValidator/packages.neutral.lock.json new file mode 100644 index 00000000..3924e2e2 --- /dev/null +++ b/tools/RenderPackValidator/packages.neutral.lock.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "acdream.plugin.abstractions": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tools/ShaderCompiler/GlslIncludeExpander.cs b/tools/ShaderCompiler/GlslIncludeExpander.cs new file mode 100644 index 00000000..8f2a493f --- /dev/null +++ b/tools/ShaderCompiler/GlslIncludeExpander.cs @@ -0,0 +1,51 @@ +using System.Text; + +namespace AcDream.Tools.ShaderCompiler; + +/// +/// Tiny deterministic include expander for checked-in shader ABI snippets. +/// Only quoted, shader-directory-relative includes are accepted; traversal and +/// cycles fail the compile rather than reaching shaderc with host-dependent +/// search paths. +/// +internal static class GlslIncludeExpander +{ + internal static string Expand(string source, string sourceDirectory) => + Expand(source, Path.GetFullPath(sourceDirectory), []); + + private static string Expand( + string source, + string sourceDirectory, + HashSet active) + { + var output = new StringBuilder(); + foreach (string line in source.Replace("\r\n", "\n").Split('\n')) + { + string trimmed = line.Trim(); + if (!trimmed.StartsWith("#include \"", StringComparison.Ordinal) + || !trimmed.EndsWith('"')) + { + output.AppendLine(line); + continue; + } + + string key = trimmed[10..^1]; + if (key.Length == 0 + || Path.IsPathRooted(key) + || key.Contains("..", StringComparison.Ordinal) + || key.Contains('\\')) + throw new InvalidDataException($"Unsafe GLSL include '{key}'."); + string path = Path.GetFullPath(Path.Combine(sourceDirectory, key)); + if (!path.StartsWith(sourceDirectory + Path.DirectorySeparatorChar, StringComparison.Ordinal) + || !File.Exists(path)) + throw new FileNotFoundException($"GLSL include '{key}' was not found.", path); + if (!active.Add(path)) + throw new InvalidDataException($"Cyclic GLSL include '{key}'."); + output.AppendLine($"// ---- begin include: {key} ----"); + output.Append(Expand(File.ReadAllText(path), sourceDirectory, active)); + output.AppendLine($"// ---- end include: {key} ----"); + active.Remove(path); + } + return output.ToString(); + } +} diff --git a/tools/ShaderCompiler/Program.cs b/tools/ShaderCompiler/Program.cs index f273c110..45380fb6 100644 --- a/tools/ShaderCompiler/Program.cs +++ b/tools/ShaderCompiler/Program.cs @@ -28,15 +28,21 @@ internal static class Program { private static unsafe int Main(string[] args) { - if (args.Length < 2) + if (args.Length < 2 + || args.Length > 3 + || (args.Length == 3 && !string.Equals(args[2], "--force", StringComparison.Ordinal))) { - Console.Error.WriteLine("usage: ShaderCompiler "); + Console.Error.WriteLine("usage: ShaderCompiler [--force]"); return 2; } string sourceDirectory = Path.GetFullPath(args[0]); string outputDirectory = Path.GetFullPath(args[1]); + bool force = args.Length == 3; Directory.CreateDirectory(outputDirectory); + string manifestPath = Path.Combine(outputDirectory, "shaders.manifest.json"); + IReadOnlyDictionary<(string Name, string Stage), ShaderStageResult> previousStages = + force ? new Dictionary<(string, string), ShaderStageResult>() : LoadPreviousStages(manifestPath); string[] names = Directory .EnumerateFiles(sourceDirectory, "*.vert") @@ -70,7 +76,23 @@ internal static class Program { string path = Path.Combine(sourceDirectory, $"{name}.{stage}"); string source = File.ReadAllText(path); + if (source.Contains("#include \"", StringComparison.Ordinal)) + source = GlslIncludeExpander.Expand(source, sourceDirectory); string hash = Sha256(source); + string target = Path.Combine(outputDirectory, $"{name}.{stage}.spv"); + + // An unchanged source already has the exact committed artifact + // described by the prior manifest. Keeping it avoids compiler- + // version-only SPIR-V decoration reordering on the retail path; + // a real source edit changes the hash and recompiles normally. + if (previousStages.TryGetValue((name, stage), out ShaderStageResult? previous) + && previous.Compiled + && string.Equals(previous.SourceSha256, hash, StringComparison.Ordinal) + && File.Exists(target)) + { + stages.Add(new ShaderStageResult(stage, hash, true, null)); + continue; + } string transformed; try @@ -88,13 +110,11 @@ internal static class Program if (TryCompile(shaderc, compiler, options, transformed, $"{name}.{stage}", stage, out byte[] spirv, out string message)) { - string target = Path.Combine(outputDirectory, $"{name}.{stage}.spv"); File.WriteAllBytes(target, spirv); stages.Add(new ShaderStageResult(stage, hash, true, null)); } else { - string target = Path.Combine(outputDirectory, $"{name}.{stage}.spv"); if (File.Exists(target)) File.Delete(target); stages.Add(new ShaderStageResult(stage, hash, false, Summarise(message))); @@ -140,7 +160,6 @@ internal static class Program var manifest = new ShaderManifest( "Campaign V slice V6c. Regenerate with tools/compile-shaders.ps1.", entries.OrderBy(entry => entry.Name, StringComparer.Ordinal).ToList()); - string manifestPath = Path.Combine(outputDirectory, "shaders.manifest.json"); File.WriteAllText( manifestPath, JsonSerializer.Serialize(manifest, ShaderManifestJson.Options) + Environment.NewLine); @@ -218,6 +237,30 @@ internal static class Program byte[] bytes = Encoding.UTF8.GetBytes(text.Replace("\r\n", "\n")); return Convert.ToHexStringLower(SHA256.HashData(bytes)); } + + private static IReadOnlyDictionary<(string Name, string Stage), ShaderStageResult> LoadPreviousStages( + string manifestPath) + { + if (!File.Exists(manifestPath)) + return new Dictionary<(string, string), ShaderStageResult>(); + + try + { + ShaderManifest? manifest = JsonSerializer.Deserialize( + File.ReadAllText(manifestPath), + ShaderManifestJson.Options); + return manifest?.Shaders + .SelectMany(shader => shader.Stages.Select(stage => (shader.Name, Stage: stage))) + .ToDictionary(item => (item.Name, item.Stage.Stage), item => item.Stage) + ?? new Dictionary<(string, string), ShaderStageResult>(); + } + catch (JsonException) + { + // A malformed or obsolete manifest is not an authority. Recompile + // everything and replace it with the current deterministic schema. + return new Dictionary<(string, string), ShaderStageResult>(); + } + } } internal sealed record ShaderStageResult( diff --git a/tools/ShaderCompiler/VulkanGlslPreamble.cs b/tools/ShaderCompiler/VulkanGlslPreamble.cs index 683e2161..6d7b7867 100644 --- a/tools/ShaderCompiler/VulkanGlslPreamble.cs +++ b/tools/ShaderCompiler/VulkanGlslPreamble.cs @@ -18,7 +18,8 @@ namespace AcDream.Tools.ShaderCompiler; /// BatchBuffer (SSBO binding 1) and SceneLighting (UBO binding 1) /// can share a number. Vulkan has one namespace per set, so moving uniform /// buffers to set 1 preserves both numbers. common.glsl has carried this -/// macro since V2 for exactly this moment. +/// macro since V2 for exactly this moment. ACDREAM_PACK_UBO_SET separately +/// names opt-in set 3, which retail pipeline layouts do not contain. /// The texture table becomes a real descriptor array at set 2 binding 0, /// and ACDREAM_TEXTURE_HANDLE becomes an index rather than a packed /// bindless handle. nonuniformEXT is required, not optional: within one @@ -59,6 +60,10 @@ internal static class VulkanGlslPreamble "uTextureIndexB", "uParamA", "uParamB", + // Render-pack logical inputs C/D are packed into the existing spare + // scalar words; no bytes are appended to retail's push block. + "uTextureIndexC", + "uTextureIndexD", ]; /// @@ -66,7 +71,13 @@ internal static class VulkanGlslPreamble /// only affects which stage-specific rewrites are /// emitted. /// - internal static string Build(string stage) + internal static string Build(string stage) => + Build(stage, includePackUniformSet: false, includePackTextureIndices: false); + + private static string Build( + string stage, + bool includePackUniformSet, + bool includePackTextureIndices) { var text = new StringBuilder(); text.AppendLine("// ---- injected by tools/compile-shaders.ps1 (Campaign V slice V6c) ----"); @@ -86,6 +97,11 @@ internal static class VulkanGlslPreamble text.AppendLine("// §3.4 set 1: every uniform buffer. Under GL this macro expands to nothing."); text.AppendLine("#undef ACDREAM_UBO_SET"); text.AppendLine("#define ACDREAM_UBO_SET set = 1,"); + if (includePackUniformSet) + { + text.AppendLine("#undef ACDREAM_PACK_UBO_SET"); + text.AppendLine("#define ACDREAM_PACK_UBO_SET set = 3,"); + } text.AppendLine(); text.AppendLine("// §4.4 set 2: the global sampled-texture table that replaces"); text.AppendLine("// GL_ARB_bindless_texture. Variable count, partially bound,"); @@ -136,6 +152,11 @@ internal static class VulkanGlslPreamble text.AppendLine("#define uTextureIndexB acdreamPush.textureIndexB"); text.AppendLine("#define uParamA acdreamPush.paramA"); text.AppendLine("#define uParamB acdreamPush.paramB"); + if (includePackTextureIndices) + { + text.AppendLine("#define uTextureIndexC floatBitsToUint(acdreamPush.paramA)"); + text.AppendLine("#define uTextureIndexD floatBitsToUint(acdreamPush.paramB)"); + } text.AppendLine(); text.AppendLine("// §4.6: gl_DrawIDARB stays as written — glslang exposes it for Vulkan"); text.AppendLine("// under the same ARB extension name. gl_InstanceIndex already includes"); @@ -158,6 +179,10 @@ internal static class VulkanGlslPreamble internal static string Apply(string source, string stage) { ArgumentNullException.ThrowIfNull(source); + bool includePackUniformSet = source.Contains("ACDREAM_PACK_UBO_SET", StringComparison.Ordinal); + bool includePackTextureIndices = + source.Contains("uTextureIndexC", StringComparison.Ordinal) + || source.Contains("uTextureIndexD", StringComparison.Ordinal); string[] lines = source.Replace("\r\n", "\n").Split('\n'); var output = new StringBuilder(); bool injected = false; @@ -171,7 +196,7 @@ internal static class VulkanGlslPreamble // more keeps it, because a shader that opted into 460 did so for // a feature and quietly downgrading it would be a silent change. output.AppendLine(HighestVersion(trimmed) >= 460 ? "#version 460 core" : "#version 450 core"); - output.Append(Build(stage)); + output.Append(Build(stage, includePackUniformSet, includePackTextureIndices)); injected = true; continue; } diff --git a/tools/ShaderCompiler/packages.win-x64.lock.json b/tools/ShaderCompiler/packages.win-x64.lock.json new file mode 100644 index 00000000..91d7673c --- /dev/null +++ b/tools/ShaderCompiler/packages.win-x64.lock.json @@ -0,0 +1,48 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Silk.NET.Shaderc": { + "type": "Direct", + "requested": "[2.23.0, )", + "resolved": "2.23.0", + "contentHash": "+pXfOhmSCeeMECOo9HMi3C63LVbQ7FBxPFgxPKOT6mXD8Gg/90Wt4fLX4LqUuVbGid5LW6BXAUu1g17XQoawdA==", + "dependencies": { + "Silk.NET.Core": "2.23.0", + "Silk.NET.Shaderc.Native": "2.23.0" + } + }, + "Microsoft.DotNet.PlatformAbstractions": { + "type": "Transitive", + "resolved": "3.1.6", + "contentHash": "jek4XYaQ/PGUwDKKhwR8K47Uh1189PFzMeLqO83mXrXQVIpARZCcfuDedH50YDTepBkfijCZN5U/vZi++erxtg==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "fNGvKct2De8ghm0Bpfq0iWthtzIWabgOTi+gJhNOPhNJIowXNEUE2eZNW/zNCzrHVA3PXg2yZ+3cWZndC2IqYA==" + }, + "Silk.NET.Core": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "D7AT/nnwlB+4RZ84XY8QNGBZMJI5z9l4CSSETIJ1wCfRJzRt/341y3MRZ4HbnFz4r/IGaWOEZr86iE+0/65yyQ==", + "dependencies": { + "Microsoft.DotNet.PlatformAbstractions": "3.1.6", + "Microsoft.Extensions.DependencyModel": "9.0.9" + } + }, + "Silk.NET.Shaderc.Native": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "H6OMLIWdh2HITvkmj+ALs8LTIdQvQ2/JTtkDXinVbJ3xxrQIBXhVmc9jnuTQ67YDybGlENSMrgthzhLwK0rjnQ==" + } + }, + "net10.0/win-x64": { + "Silk.NET.Shaderc.Native": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "H6OMLIWdh2HITvkmj+ALs8LTIdQvQ2/JTtkDXinVbJ3xxrQIBXhVmc9jnuTQ67YDybGlENSMrgthzhLwK0rjnQ==" + } + } + } +} \ No newline at end of file diff --git a/tools/atmospheric-performance-matrix-common.ps1 b/tools/atmospheric-performance-matrix-common.ps1 new file mode 100644 index 00000000..3aaf08b2 --- /dev/null +++ b/tools/atmospheric-performance-matrix-common.ps1 @@ -0,0 +1,325 @@ +Set-StrictMode -Version Latest + +function Get-AtmosphericPresetUnavailableReasonClassification { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$Reason, + [Parameter(Mandatory = $true)] + [ValidateSet('low', 'medium', 'high', 'auto')][string]$Preset) + + if ($Preset -eq 'auto') { + $performancePattern = "^Automatic quality disabled render pack 'acdream\.atmospheric' because Low remained over its declared performance budget for 180 stable samples: GPU p99 (?[0-9]+(?:\.[0-9]+)?) ms \(budget (?[0-9]+(?:\.[0-9]+)?) ms\), CPU p99 (?[0-9]+(?:\.[0-9]+)?) ms \(budget (?[0-9]+(?:\.[0-9]+)?) ms\), resident GPU bytes (?[0-9]+) \(budget (?[0-9]+)\)\.$" + $performance = [Regex]::Match( + $Reason, + $performancePattern, + [Text.RegularExpressions.RegexOptions]::CultureInvariant) + if ($performance.Success) { + $culture = [Globalization.CultureInfo]::InvariantCulture + $gpu = [double]::Parse($performance.Groups['gpu'].Value, $culture) + $gpuBudget = [double]::Parse($performance.Groups['gpuBudget'].Value, $culture) + $cpu = [double]::Parse($performance.Groups['cpu'].Value, $culture) + $cpuBudget = [double]::Parse($performance.Groups['cpuBudget'].Value, $culture) + $resident = [long]::Parse($performance.Groups['resident'].Value, $culture) + $residentBudget = [long]::Parse($performance.Groups['residentBudget'].Value, $culture) + if ($gpu -gt $gpuBudget -or $cpu -gt $cpuBudget -or + $resident -gt $residentBudget) { + return [pscustomobject][ordered]@{ + Supported = $true + Classification = 'PerformanceUnavailable' + Reason = $Reason + } + } + } + foreach ($resolvedPreset in @('low', 'medium', 'high')) { + $classification = Get-AtmosphericPresetUnavailableReasonClassification ` + -Reason $Reason -Preset $resolvedPreset + if ($classification.Supported) { return $classification } + } + return [pscustomobject][ordered]@{ + Supported = $false + Classification = 'UnexpectedFailure' + Reason = $Reason + } + } + + # Keep this allow-list deliberately narrow. A shader, validation, pipeline, + # draw, or ordinary runtime failure must not become a passing + # "unavailable" row merely because its prose contains words such as + # unsupported, resource, memory, or capability. + $escapedPreset = [Regex]::Escape($Preset) + $preparationPrefix = "(?:Render pack 'acdream\.atmospheric' could not be prepared: )?" + $resourcePatterns = @( + "^${preparationPrefix}Render pack preset '$escapedPreset' needs [1-9][0-9]* resident GPU bytes at [1-9][0-9]*x[1-9][0-9]*; its declared ceiling is [1-9][0-9]*\. Select a compatible preset or reduce the main-world resolution\.$", + "^${preparationPrefix}Render pack preset '$escapedPreset' needs [1-9][0-9]* resident GPU bytes at [1-9][0-9]*x[1-9][0-9]*; this host permits [1-9][0-9]* under its .+ policy\.$", + "^${preparationPrefix}Render pack preset '$escapedPreset' needs [1-9][0-9]* transient multisample GPU bytes at [1-9][0-9]*x[1-9][0-9]* x[1-9][0-9]*; this host permits [1-9][0-9]* under its .+ policy\.$", + "^Directional shadow rendering failed: Render pack preset '$escapedPreset' needs [1-9][0-9]* resident GPU bytes after materializing its scene-dependent shadow command buffers; the active pack budget is [1-9][0-9]* bytes\.$", + "^Preset '$escapedPreset' declares a [1-9][0-9]*-byte resident GPU ceiling, but this host permits [1-9][0-9]* bytes under its .+ policy\.$" + ) + foreach ($pattern in $resourcePatterns) { + if ([Regex]::IsMatch($Reason, $pattern, [Text.RegularExpressions.RegexOptions]::CultureInvariant)) { + return [pscustomobject][ordered]@{ + Supported = $true + Classification = 'ResourceUnavailable' + Reason = $Reason + } + } + } + + $capabilityPatterns = @( + "^Pack 'acdream\.atmospheric' requires unsupported capability '[A-Za-z][A-Za-z0-9]*'\.$", + "^Preset '$escapedPreset' requires unsupported capability '[A-Za-z][A-Za-z0-9]*'\.$", + "^Preset '$escapedPreset' resource '[a-z0-9][a-z0-9.-]*' needs [1-9][0-9]* image-array layers; this device provides [0-9]+\.$", + "^Preset '$escapedPreset' resource '[a-z0-9][a-z0-9.-]*' needs [1-9][0-9]*(?:\.[0-9]+)?x[1-9][0-9]*(?:\.[0-9]+)?; this device's maximum 2-D image edge is [1-9][0-9]*\.$", + "^${preparationPrefix}Render pack preset '$escapedPreset' resolves an image to [1-9][0-9]*x[1-9][0-9]* at [1-9][0-9]*x[1-9][0-9]*; this device's maximum 2-D image edge is [1-9][0-9]*\.$", + "^${preparationPrefix}Render pack preset '$escapedPreset' needs [1-9][0-9]* image-array layers; this device provides [0-9]+\.$" + ) + foreach ($pattern in $capabilityPatterns) { + if ([Regex]::IsMatch($Reason, $pattern, [Text.RegularExpressions.RegexOptions]::CultureInvariant)) { + return [pscustomobject][ordered]@{ + Supported = $true + Classification = 'CapabilityUnavailable' + Reason = $Reason + } + } + } + + return [pscustomobject][ordered]@{ + Supported = $false + Classification = 'UnexpectedFailure' + Reason = $Reason + } +} + +function Test-AtmosphericPerformanceMetadataEvidence { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$MetadataPath, + [Parameter(Mandatory = $true)] + [ValidateSet('retail', 'low', 'medium', 'high', 'auto')][string]$Preset, + [Parameter(Mandatory = $true)][int]$ExpectedWidth, + [Parameter(Mandatory = $true)][int]$ExpectedHeight, + [switch]$AllowSafeFallback) + + $failures = [Collections.Generic.List[string]]::new() + function Require([object]$Value, [string]$Name, [string]$Context) { + $property = $Value.PSObject.Properties[$Name] + if ($null -eq $property) { throw "$Context is missing required property '$Name'." } + return $property.Value + } + function Fail([string]$Message) { $null = $failures.Add($Message) } + function Require-FiniteNonNegative([object]$Value, [string]$Name) { + $number = [double](Require $Value $Name 'RenderPack.Performance') + if (-not [double]::IsFinite($number) -or $number -lt 0) { + Fail "$Name must be finite and non-negative" + } + return $number + } + + $metadata = Get-Content -Raw -LiteralPath $MetadataPath | ConvertFrom-Json + if ([int](Require $metadata 'SchemaVersion' 'metadata') -ne 1) { + Fail 'metadata SchemaVersion must be 1' + } + if ([int](Require $metadata 'Width' 'metadata') -ne $ExpectedWidth -or + [int](Require $metadata 'Height' 'metadata') -ne $ExpectedHeight) { + Fail "capture extent must be ${ExpectedWidth}x${ExpectedHeight}" + } + $pack = Require $metadata 'RenderPack' 'metadata' + $enhanced = $Preset -ne 'retail' + $state = [int](Require $pack 'State' 'RenderPack') + $fallback = $enhanced -and $state -eq 3 + $expectedPackId = if ($enhanced -and -not $fallback) { 'acdream.atmospheric' } else { 'retail' } + $expectedVersion = if ($enhanced -and -not $fallback) { '1.0.0' } else { '' } + $expectedPreset = if ($enhanced -and -not $fallback) { $Preset } else { 'off' } + $expectedState = if ($enhanced -and -not $fallback) { 2 } elseif ($fallback) { 3 } else { 0 } + $actualEffectiveQuality = [string]( + Require $pack 'EffectiveQuality' 'RenderPack') + $expectedQuality = if ($enhanced -and -not $fallback -and $Preset -ne 'auto') { + $Preset + } else { 'off' } + foreach ($comparison in @( + @('PackId', $expectedPackId), @('PackVersion', $expectedVersion), + @('PresetId', $expectedPreset))) { + $actual = [string](Require $pack ([string]$comparison[0]) 'RenderPack') + if ($actual -cne [string]$comparison[1]) { + Fail "$($comparison[0]) was '$actual', expected '$($comparison[1])'" + } + } + if ($enhanced -and -not $fallback -and $Preset -eq 'auto') { + if ($actualEffectiveQuality -cnotin @('low', 'medium', 'high')) { + Fail "Automatic EffectiveQuality was '$actualEffectiveQuality', expected low, medium, or high" + } + } + elseif ($actualEffectiveQuality -cne $expectedQuality) { + Fail "EffectiveQuality was '$actualEffectiveQuality', expected '$expectedQuality'" + } + if ($state -ne $expectedState) { + Fail "activation state must be $expectedState" + } + $generation = [int](Require $pack 'ActivationGeneration' 'RenderPack') + if (($fallback -and $generation -lt 1) -or + ($enhanced -and -not $fallback -and $Preset -eq 'auto' -and + $generation -lt 1) -or + ($enhanced -and -not $fallback -and $Preset -ne 'auto' -and + $generation -ne 1) -or + (-not $enhanced -and $generation -ne 0)) { + Fail "activation generation is invalid for state $state" + } + $failureReason = [string](Require $pack 'FailureReason' 'RenderPack') + $unavailableClassification = $null + if ($fallback) { + if (-not $AllowSafeFallback) { + Fail 'FailedToRetail fallback was not explicitly allowed for this capture' + } + if ([string]::IsNullOrWhiteSpace($failureReason)) { + Fail 'FailedToRetail fallback must preserve one exact failure reason' + } + else { + $reasonClassification = Get-AtmosphericPresetUnavailableReasonClassification ` + -Reason $failureReason -Preset $Preset + $unavailableClassification = $reasonClassification.Classification + if (-not $reasonClassification.Supported) { + Fail 'FailedToRetail reason is not a strict resource/capability/Auto-performance unavailability' + } + } + } + elseif (-not [string]::IsNullOrWhiteSpace($failureReason)) { + Fail 'render-pack FailureReason must be empty' + } + + $performance = Require $pack 'Performance' 'RenderPack' + $topResident = [long](Require $pack 'RetainedGpuBytes' 'RenderPack') + $topTransient = [long](Require $pack 'TransientGpuBytes' 'RenderPack') + $nestedResident = [long](Require $performance 'ResidentGpuBytes' 'RenderPack.Performance') + $nestedTransient = [long](Require $performance 'TransientGpuBytes' 'RenderPack.Performance') + if ($topResident -ne $nestedResident) { Fail 'top-level and performance resident GPU bytes disagree' } + if ($topTransient -ne $nestedTransient) { Fail 'top-level and performance transient GPU bytes disagree' } + + $casterCount = [int](Require $pack 'ShadowCasterCount' 'RenderPack') + $cascadeCount = [int](Require $pack 'CascadeDrawCount' 'RenderPack') + $classificationCalls = [int](Require $pack 'CpuClassificationCalls' 'RenderPack') + $drawCalls = [int](Require $pack 'DrawCalls' 'RenderPack') + $dispatchCalls = [int](Require $pack 'DispatchCalls' 'RenderPack') + $imageCount = [int](Require $pack 'ImageCount' 'RenderPack') + $bufferCount = [int](Require $pack 'BufferCount' 'RenderPack') + $passes = @(Require $pack 'Passes' 'RenderPack') + + if ($fallback) { + if ($casterCount -ne 0 -or $cascadeCount -ne 0 -or $classificationCalls -ne 0 -or + $drawCalls -ne 0 -or $dispatchCalls -ne 0 -or $passes.Count -ne 0 -or + $imageCount -ne 0 -or $bufferCount -ne 0 -or + $topResident -ne 0 -or $topTransient -ne 0) { + Fail 'FailedToRetail fallback must have zero pack work and resources' + } + foreach ($countName in @('CpuSampleCount', 'AbsoluteReceiverCpuSampleCount', 'GpuSampleCount')) { + if ([int](Require $performance $countName 'RenderPack.Performance') -ne 0) { + Fail "FailedToRetail fallback must have zero $countName" + } + } + foreach ($metric in @( + 'IncrementalCpuMillisecondsP50', 'IncrementalCpuMillisecondsP95', + 'IncrementalCpuMillisecondsP99', 'AbsoluteReceiverCpuMillisecondsP50', + 'AbsoluteReceiverCpuMillisecondsP95', 'AbsoluteReceiverCpuMillisecondsP99', + 'InclusiveGpuMillisecondsP50', 'InclusiveGpuMillisecondsP95', + 'InclusiveGpuMillisecondsP99', 'ResidentGpuBytes', 'TransientGpuBytes')) { + if ([double](Require $performance $metric 'RenderPack.Performance') -ne 0) { + Fail "FailedToRetail fallback must report zero $metric" + } + } + } + elseif (-not $enhanced) { + if ($casterCount -ne 0 -or $cascadeCount -ne 0 -or $classificationCalls -ne 0 -or + $drawCalls -ne 0 -or $dispatchCalls -ne 0 -or $passes.Count -ne 0 -or + $imageCount -ne 0 -or $bufferCount -ne 0 -or + $topResident -ne 0 -or $topTransient -ne 0) { + Fail 'retail row must have zero pack work, resources, and classification' + } + } + else { + $shapePreset = if ($Preset -eq 'auto') { + $actualEffectiveQuality + } else { $Preset } + $expectedCascades = @{ low = 2; medium = 3; high = 4 }[$shapePreset] + if ($casterCount -le 0) { Fail 'enhanced row must contain at least one shadow caster' } + if ($cascadeCount -ne $expectedCascades) { + Fail "$Preset/$shapePreset must render exactly $expectedCascades cascades" + } + if ($classificationCalls -ne 0) { Fail 'warmed capture must perform zero CPU classifications' } + $expectedPassIds = [Collections.Generic.List[string]]::new() + $expectedPassIds.Add('atmospheric-world-receiver') + if ($shapePreset -eq 'low') { + $expectedPassIds.Add('directional-shadow-multiview') + } + else { + for ($cascade = 0; $cascade -lt $expectedCascades; $cascade++) { + $expectedPassIds.Add("directional-shadow-cascade-$cascade") + } + } + $postPassIds = @( + 'atmospheric-sun-occlusion', 'atmospheric-sun-rays', + 'atmospheric-volumetric-shafts', 'atmospheric-bloom-downsample', + 'atmospheric-bloom-blur-horizontal', 'atmospheric-bloom-blur-vertical', + 'atmospheric-filmic') + foreach ($id in $postPassIds) { $expectedPassIds.Add($id) } + $actualPassIds = @($passes | ForEach-Object { [string](Require $_ 'PassId' 'RenderPack.Passes[]') }) + if (($actualPassIds -join '|') -cne (@($expectedPassIds) -join '|')) { + Fail "pass order/shape was '$($actualPassIds -join ',')'" + } + # The fixed offline scene resolves five prepared shadow submissions: + # terrain plus the stable opaque/alpha-cutout world runs. Low replays + # those once through multiview; Medium/High replay them per cascade. + $shadowDrawsPerPass = 5 + # Low now uses the quarter-resolution separable graph as well. Its + # volumetric pass remains declared for one stable API shape but records + # zero draws because the Low preset disables volumetric strength. + $postDraws = 6 + $expectedDraws = $postDraws + $(if ($shapePreset -eq 'low') { + $shadowDrawsPerPass + } else { + $shadowDrawsPerPass * $expectedCascades + }) + $summedDraws = [int](($passes | Measure-Object -Property DrawCalls -Sum).Sum) + $summedDispatches = [int](($passes | Measure-Object -Property DispatchCalls -Sum).Sum) + if ($drawCalls -ne $expectedDraws -or $summedDraws -ne $expectedDraws) { + Fail "draw shape must total $expectedDraws calls" + } + if ($dispatchCalls -ne 0 -or $summedDispatches -ne 0) { + Fail 'atmospheric pack must issue zero dispatch calls' + } + foreach ($pass in $passes) { + $passId = [string](Require $pass 'PassId' 'RenderPack.Passes[]') + $expectedPassDraws = if ($passId -like 'directional-shadow-*') { + $shadowDrawsPerPass + } + elseif ($passId -in @('atmospheric-world-receiver', 'atmospheric-volumetric-shafts')) { 0 } + else { 1 } + if ([int](Require $pass 'DrawCalls' 'RenderPack.Passes[]') -ne $expectedPassDraws -or + [int](Require $pass 'DispatchCalls' 'RenderPack.Passes[]') -ne 0) { + Fail "pass '$passId' must record exactly $expectedPassDraws draws and zero dispatches" + } + } + foreach ($countName in @('CpuSampleCount', 'AbsoluteReceiverCpuSampleCount', 'GpuSampleCount')) { + if ([int](Require $performance $countName 'RenderPack.Performance') -ne 2048) { + Fail "$countName must contain the complete 2048-sample window" + } + } + foreach ($metric in @( + 'IncrementalCpuMillisecondsP50', 'IncrementalCpuMillisecondsP95', + 'IncrementalCpuMillisecondsP99', 'AbsoluteReceiverCpuMillisecondsP50', + 'AbsoluteReceiverCpuMillisecondsP95', 'AbsoluteReceiverCpuMillisecondsP99', + 'InclusiveGpuMillisecondsP50', 'InclusiveGpuMillisecondsP95', + 'InclusiveGpuMillisecondsP99')) { $null = Require-FiniteNonNegative $performance $metric } + } + + return [pscustomobject][ordered]@{ + Passed = $failures.Count -eq 0 + Failures = @($failures) + Outcome = if ($fallback) { 'Unavailable' } elseif ($enhanced) { 'Active' } else { 'Retail' } + UnavailableClassification = $unavailableClassification + FailureReason = if ($fallback) { $failureReason } else { $null } + EffectiveQuality = $actualEffectiveQuality + ShadowCasterCount = $casterCount + CascadeDrawCount = $cascadeCount + CpuClassificationCalls = $classificationCalls + PassIds = @($passes | ForEach-Object { [string]$_.PassId }) + } +} diff --git a/tools/compile-shaders.ps1 b/tools/compile-shaders.ps1 index 2a352eee..7821f247 100644 --- a/tools/compile-shaders.ps1 +++ b/tools/compile-shaders.ps1 @@ -36,6 +36,12 @@ Use glslc when available. On by default; pass -PreferSdk:$false to force the managed path, which is what a comparison between the two wants. +.PARAMETER ForceRecompile + Recompile even when the existing manifest proves that a source and its + committed SPIR-V artifact are unchanged. Use this only when validating a + compiler/toolchain change; ordinary regeneration preserves exact retail + binaries while compiling every changed shader. + .EXAMPLE tools/compile-shaders.ps1 #> @@ -43,7 +49,8 @@ param( [string]$ShadersDirectory, [string]$OutputDirectory, - [bool]$PreferSdk = $true + [bool]$PreferSdk = $true, + [switch]$ForceRecompile ) $ErrorActionPreference = 'Stop' @@ -149,7 +156,9 @@ if (-not (Test-Path $native)) { Write-Step "shaderc native: $native" Write-Step "compiling $ShadersDirectory -> $OutputDirectory" -& dotnet $binary $ShadersDirectory $OutputDirectory +$compilerArguments = @($binary, $ShadersDirectory, $OutputDirectory) +if ($ForceRecompile) { $compilerArguments += '--force' } +& dotnet @compilerArguments if ($LASTEXITCODE -ne 0) { throw "Shader compilation failed with exit code $LASTEXITCODE." } Write-Step 'done' diff --git a/tools/connected-atmospheric-exposure-comparison.route.txt b/tools/connected-atmospheric-exposure-comparison.route.txt new file mode 100644 index 00000000..abe1c8c2 --- /dev/null +++ b/tools/connected-atmospheric-exposure-comparison.route.txt @@ -0,0 +1,51 @@ +# Connected outdoor/interior exposure comparison for Atmospheric Rendering. +# Captures the retail-faithful path and the High preset at the same authored +# locations, then exercises the UI-default Low preset last so a fail-safe +# fallback records its exact controller reason without losing the comparisons. + +wait world-ready 90000 +wait world-visible 30000 + +# Dense outdoor receiver/caster scene. +command /teleloc 0x09040008 11.4 188.6 87.705 +wait materialized 1 90000 +wait world-visible 30000 +sleep 5000 +renderpack select retail +wait render-pack retail 90000 +sleep 3000 +screenshot exposure_outdoor_retail 15000 + +renderpack select high +wait render-pack high 90000 +sleep 3000 +screenshot exposure_outdoor_atmospheric_high 15000 + +renderpack disable +wait render-pack retail 90000 + +# Facility Hub interior: EnvCell lighting, no outdoor directional-shadow path. +command /teleloc 0x8A020164 70.35 -40.66 -5.9 +wait materialized 2 90000 +wait world-visible 30000 +sleep 5000 +screenshot exposure_interior_retail 15000 + +renderpack select high +wait render-pack high 90000 +sleep 3000 +screenshot exposure_interior_atmospheric_high 15000 + +renderpack disable +wait render-pack retail 90000 + +# Return outdoors and exercise the first/default selectable preset last. +command /teleloc 0x09040008 11.4 188.6 87.705 +wait materialized 3 90000 +wait world-visible 30000 +sleep 3000 +renderpack select low +wait render-pack low 90000 +sleep 3000 +screenshot exposure_outdoor_atmospheric_low 15000 +checkpoint atmospheric_exposure_comparison diff --git a/tools/connected-render-pack-gate-common.ps1 b/tools/connected-render-pack-gate-common.ps1 new file mode 100644 index 00000000..0a0bb9bc --- /dev/null +++ b/tools/connected-render-pack-gate-common.ps1 @@ -0,0 +1,341 @@ +# Shared render-pack selection seam for connected graphical gates. + +$script:ConnectedGateEnvironmentNames = @( + 'ACDREAM_AUTOMATION_ARTIFACT_DIR', 'ACDREAM_CACHE_DIR', + 'ACDREAM_COLLISION_SHADOW_DIR', 'ACDREAM_COLLISION_SHADOW_EVERY', + 'ACDREAM_CONFIG_DIR', 'ACDREAM_DATA_DIR', 'ACDREAM_DAT_DIR', + 'ACDREAM_DEVTOOLS', 'ACDREAM_DUMP_MOVE_TRUTH', 'ACDREAM_FRAME_HISTORY', + 'ACDREAM_FRAME_PROF', 'ACDREAM_LIVE', 'ACDREAM_NET_DROP_DIR', + 'ACDREAM_NET_DROP_PCT', 'ACDREAM_NET_DROP_SEED', 'ACDREAM_NO_AUDIO', + 'ACDREAM_PAK_PATH', 'ACDREAM_RENDER_BACKEND', 'ACDREAM_RETAIL_UI', + 'ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER', 'ACDREAM_DAY_GROUP', + 'ACDREAM_WORLD_TIME', 'ACDREAM_SKY_PHASE_SECONDS', + 'ACDREAM_ORBIT_DISTANCE_METERS', 'ACDREAM_ORBIT_YAW_DEGREES', + 'ACDREAM_ORBIT_PITCH_DEGREES', 'ACDREAM_VULKAN_DEVICE', + 'ACDREAM_VULKAN_FORCE_UNSUPPORTED', 'ACDREAM_VULKAN_PROBE', + 'ACDREAM_VULKAN_PROBE_FRAMES', 'ACDREAM_TEST_HOST', + 'ACDREAM_TEST_PASS', 'ACDREAM_TEST_PORT', 'ACDREAM_TEST_USER', + 'ACDREAM_UI_PROBE_DUMP', 'ACDREAM_UI_PROBE_SCRIPT', + 'ACDREAM_UNCAPPED_RENDER', 'ACDREAM_WB_DIAG' +) + +function Assert-ConnectedGateSafeLeafName { + param([Parameter(Mandatory = $true)][string]$Name) + if ($Name -notmatch '^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$') { + throw "Unsafe screenshot leaf name '$Name'." + } +} + +function Assert-ConnectedGateContainedPath { + param([Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Path) + $rootFull = [IO.Path]::GetFullPath($Root).TrimEnd( + [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + $pathFull = [IO.Path]::GetFullPath($Path) + $prefix = $rootFull + [IO.Path]::DirectorySeparatorChar + if (-not $pathFull.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Path '$pathFull' is not contained by gate root '$rootFull'." + } + return $pathFull +} + +function Assert-ConnectedGateNoReparsePoint { + param([Parameter(Mandatory = $true)][string]$Path) + if ((Get-Item -LiteralPath $Path).Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Gate path must not be a reparse point: '$Path'." + } +} + +function Get-ConnectedRenderPackExpectation { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateSet('retail', 'low', 'medium', 'high', 'auto')] + [string]$Preset + ) + return [pscustomobject][ordered]@{ + RequestedPreset = $Preset + PackId = if ($Preset -eq 'retail') { 'retail' } else { 'acdream.atmospheric' } + PackVersion = if ($Preset -eq 'retail') { $null } else { '1.0.0' } + PresetId = if ($Preset -eq 'retail') { 'off' } else { $Preset } + ExpectedState = if ($Preset -eq 'retail') { 0 } else { 2 } + ExpectedSchemaVersion = 1 + } +} + +function New-ConnectedRenderPackGateState { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)] + [ValidateSet('retail', 'low', 'medium', 'high', 'auto')] + [string]$Preset, + [hashtable]$SettingOverrides = @{} + ) + + if ($Preset -eq 'retail' -and $SettingOverrides.Count -ne 0) { + throw 'Render-pack setting overrides require an enhanced render-pack preset.' + } + $rootFull = [IO.Path]::GetFullPath($Root) + if (-not (Test-Path -LiteralPath $rootFull -PathType Container)) { + throw "Connected gate root does not exist: '$rootFull'." + } + Assert-ConnectedGateNoReparsePoint $rootFull + $stateDirectory = Assert-ConnectedGateContainedPath $rootFull (Join-Path $rootFull 'isolated-state') + if (Test-Path -LiteralPath $stateDirectory) { + throw "Isolated gate state already exists: '$stateDirectory'." + } + + # A connected closeout row must not inherit a diagnostic, content-path, + # budget, camera, weather, or device override from a prior shell run. Keep + # the explicitly-owned launch names for absent-variable restoration and + # also capture every currently-defined ACDREAM_* name so newly-added knobs + # fail isolated without requiring this seam to know their semantics first. + $transactionNames = @( + $script:ConnectedGateEnvironmentNames + Get-ChildItem Env: | + Where-Object { $_.Name -like 'ACDREAM_*' } | + Select-Object -ExpandProperty Name + ) | Sort-Object -Unique + $previous = [ordered]@{} + foreach ($name in $transactionNames) { + $previous[$name] = [Environment]::GetEnvironmentVariable( + $name, [EnvironmentVariableTarget]::Process) + Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue + } + + $configDirectory = Join-Path $stateDirectory 'config' + $dataDirectory = Join-Path $stateDirectory 'data' + $cacheDirectory = Join-Path $stateDirectory 'cache' + $null = New-Item -ItemType Directory -Path $configDirectory, $dataDirectory, $cacheDirectory + foreach ($path in @($stateDirectory, $configDirectory, $dataDirectory, $cacheDirectory)) { + Assert-ConnectedGateNoReparsePoint $path + } + + $packId = if ($Preset -eq 'retail') { 'retail' } else { 'acdream.atmospheric' } + $packVersion = if ($Preset -eq 'retail') { $null } else { '1.0.0' } + $presetId = if ($Preset -eq 'retail') { 'off' } else { $Preset } + $expectedState = if ($Preset -eq 'retail') { 0 } else { 2 } + $orderedOverrides = [ordered]@{} + foreach ($key in @($SettingOverrides.Keys | Sort-Object)) { + if ([string]::IsNullOrWhiteSpace([string]$key)) { + throw 'Render-pack setting override IDs cannot be empty.' + } + $orderedOverrides[[string]$key] = [string]$SettingOverrides[$key] + } + + [ordered]@{ + display = [ordered]@{ renderPack = [ordered]@{ + packId = $packId; packVersion = $packVersion; presetId = $presetId + settingOverrides = $orderedOverrides + } } + version = 3 + } | ConvertTo-Json -Depth 8 | + Set-Content -Encoding utf8 -LiteralPath (Join-Path $configDirectory 'settings.json') + + [Environment]::SetEnvironmentVariable('ACDREAM_CONFIG_DIR', $configDirectory, 'Process') + [Environment]::SetEnvironmentVariable('ACDREAM_DATA_DIR', $dataDirectory, 'Process') + [Environment]::SetEnvironmentVariable('ACDREAM_CACHE_DIR', $cacheDirectory, 'Process') + + return [pscustomobject][ordered]@{ + RequestedPreset = $Preset; PackId = $packId; PackVersion = $packVersion + PresetId = $presetId; ExpectedState = $expectedState; ExpectedSchemaVersion = 1 + SettingOverrides = $orderedOverrides; StateDirectory = $stateDirectory + ConfigDirectory = $configDirectory; DataDirectory = $dataDirectory + CacheDirectory = $cacheDirectory; PreviousEnvironment = $previous + } +} + +function Restore-ConnectedRenderPackGateEnvironment { + [CmdletBinding()] + param([Parameter(Mandatory = $true)][object]$State) + foreach ($entry in $State.PreviousEnvironment.GetEnumerator()) { + if ($null -eq $entry.Value) { + Remove-Item -LiteralPath "Env:$($entry.Key)" -ErrorAction SilentlyContinue + } + else { + [Environment]::SetEnvironmentVariable( + [string]$entry.Key, $entry.Value, [EnvironmentVariableTarget]::Process) + } + } +} + +function Get-ConnectedGateBinaryIdentity { + [CmdletBinding()] + param([Parameter(Mandatory = $true)][string]$Repository, + [Parameter(Mandatory = $true)][string]$Executable, + [switch]$SkipBuild) + $sourceCommit = (& git -C $Repository rev-parse HEAD).Trim().ToLowerInvariant() + $trackedStatus = @(& git -C $Repository status --short --untracked-files=all) + $productVersion = [Diagnostics.FileVersionInfo]::GetVersionInfo($Executable).ProductVersion + $match = [regex]::Match([string]$productVersion, '(?i)(? +[CmdletBinding()] +param( + [ValidateSet('Low', 'Medium', 'High', 'Auto')] + [string]$Preset = 'High', + [ValidatePattern('^[1-9][0-9]*x[1-9][0-9]*$')] + [string]$Resolution = '1920x1080', + [switch]$EnableAudio, + [switch]$NoAudio, + [switch]$SkipBuild +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$repo = Split-Path -Parent $PSScriptRoot +$exe = Join-Path $repo 'src\AcDream.App\bin\Release\net10.0\AcDream.App.exe' +$datDirectory = Join-Path $env:USERPROFILE "Documents\Asheron's Call" +$audioEnabled = [bool]$EnableAudio + +if ($EnableAudio -and $NoAudio) { + throw '-EnableAudio and -NoAudio are mutually exclusive.' +} + +if (-not $SkipBuild) { + Write-Host '[atmospheric-preview] building acdream Release' + & dotnet build (Join-Path $repo 'AcDream.slnx') -c Release --nologo -v q + if ($LASTEXITCODE -ne 0) { + throw "acdream Release build failed with exit code $LASTEXITCODE." + } +} +if (-not (Test-Path -LiteralPath $exe)) { + throw "acdream executable not found at '$exe'. Build it first or omit -SkipBuild." +} +if (-not (Test-Path -LiteralPath $datDirectory)) { + throw "AC DAT directory not found at '$datDirectory'." +} + +$stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff' +$root = Join-Path $repo "artifacts\atmospheric-rendering\visible-$($Preset.ToLowerInvariant())-$stamp" +$config = Join-Path $root 'state\config' +$data = Join-Path $root 'state\data' +$cache = Join-Path $root 'state\cache' +if (Test-Path -LiteralPath $root) { + throw "Preview artifact directory already exists: '$root'." +} +New-Item -ItemType Directory -Path $config, $data, $cache | Out-Null + +$settings = [ordered]@{ + display = [ordered]@{ + resolution = $Resolution + fullscreen = $false + vsync = $true + renderPack = [ordered]@{ + packId = 'acdream.atmospheric' + packVersion = '1.0.0' + presetId = $Preset.ToLowerInvariant() + settingOverrides = [ordered]@{} + } + } + version = 3 +} +$settingsPath = Join-Path $config 'settings.json' +$settings | ConvertTo-Json -Depth 8 | + Set-Content -Encoding utf8 -LiteralPath $settingsPath + +$requiredEnvironmentNames = @( + 'ACDREAM_CONFIG_DIR', + 'ACDREAM_DATA_DIR', + 'ACDREAM_CACHE_DIR', + 'ACDREAM_DAT_DIR', + 'ACDREAM_RETAIL_UI', + 'ACDREAM_NO_AUDIO' +) +$environmentNames = @( + @([Environment]::GetEnvironmentVariables('Process').Keys) | + ForEach-Object { [string]$_ } | + Where-Object { $_.StartsWith('ACDREAM_', [StringComparison]::OrdinalIgnoreCase) } + $requiredEnvironmentNames +) | Sort-Object -Unique +$prior = @{} +foreach ($name in $environmentNames) { + $prior[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') +} + +$stdoutLog = Join-Path $root 'client.stdout.log' +$stderrLog = Join-Path $root 'client.stderr.log' +$launchPath = Join-Path $root 'launch.json' +$binary = Get-Item -LiteralPath $exe +$binaryVersion = [Diagnostics.FileVersionInfo]::GetVersionInfo($exe).ProductVersion +$launch = [ordered]@{ + schemaVersion = 2 + status = 'prepared' + processId = $null + executable = $exe + executableSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $exe).Hash.ToLowerInvariant() + executableProductVersion = $binaryVersion + executableLastWriteUtc = $binary.LastWriteTimeUtc.ToString('O') + preset = $Preset.ToLowerInvariant() + resolution = $Resolution + audio = [ordered]@{ + enabled = $audioEnabled + mode = if ($audioEnabled) { 'enabled-explicit' } else { 'disabled-default' } + } + stateRoot = Join-Path $root 'state' + settings = $settingsPath + stdoutLog = $stdoutLog + stderrLog = $stderrLog + preparedUtc = [DateTimeOffset]::UtcNow.ToString('O') + launchedUtc = $null + startupError = $null +} +$launch | ConvertTo-Json -Depth 6 | + Set-Content -Encoding utf8 -LiteralPath $launchPath + +$process = $null +try { + # The preview is a clean product launch, not an extension of whichever + # connected/automation/diagnostic gate happened to run in this shell. + # Clear every inherited ACDREAM_* value transactionally, then publish only + # the explicit preview inputs below. Prior values are restored without ever + # being serialized into the artifact. + foreach ($name in $environmentNames) { + [Environment]::SetEnvironmentVariable($name, $null, 'Process') + } + $env:ACDREAM_CONFIG_DIR = $config + $env:ACDREAM_DATA_DIR = $data + $env:ACDREAM_CACHE_DIR = $cache + $env:ACDREAM_DAT_DIR = $datDirectory + $env:ACDREAM_RETAIL_UI = '1' + $env:ACDREAM_NO_AUDIO = if ($audioEnabled) { $null } else { '1' } + + try { + $process = Start-Process -FilePath $exe -PassThru ` + -RedirectStandardOutput $stdoutLog ` + -RedirectStandardError $stderrLog + } + catch { + $launch.status = 'start-failed' + $launch.startupError = $_.Exception.Message + $launch | ConvertTo-Json -Depth 6 | + Set-Content -Encoding utf8 -LiteralPath $launchPath + throw + } +} +finally { + foreach ($name in $environmentNames) { + [Environment]::SetEnvironmentVariable($name, $prior[$name], 'Process') + } +} + +$launch.status = 'started' +$launch.processId = $process.Id +$launch.launchedUtc = [DateTimeOffset]::UtcNow.ToString('O') +$launch | ConvertTo-Json -Depth 6 | + Set-Content -Encoding utf8 -LiteralPath $launchPath + +Write-Host "[atmospheric-preview] launched acdream process $($process.Id)" +Write-Host "[atmospheric-preview] preset: $Preset; resolution: $Resolution" +Write-Host "[atmospheric-preview] audio: $($launch.audio.mode)" +Write-Host "[atmospheric-preview] disposable state: $root" +Write-Host "[atmospheric-preview] logs: $stdoutLog and $stderrLog" +Write-Host '[atmospheric-preview] close the acdream window normally when finished' diff --git a/tools/run-atmospheric-performance-matrix.ps1 b/tools/run-atmospheric-performance-matrix.ps1 new file mode 100644 index 00000000..873a058e --- /dev/null +++ b/tools/run-atmospheric-performance-matrix.ps1 @@ -0,0 +1,853 @@ +<# +.SYNOPSIS + Capture the reproducible Atmospheric render-pack performance matrix. + +.DESCRIPTION + Runs the existing isolated offline pixel gate for retail, Low, Medium, + High, and Auto at 1920x1080, 2560x1440, and 3840x2160. Every row receives the same + authored-time, camera, MSAA, and warmup pins. Capped and uncapped modes are + selected explicitly with -FramePacing. + + The declared CPU and resident-memory ceilings are enforced for every + enhanced row. The declared GPU ceilings are specifically 1080p ceilings, + so 1440p and 4K GPU measurements are reported without comparing them to a + threshold that was never declared for those resolutions. The exact 1080p + Low/Medium/High CPU p50/p99, GPU p50/p99, and memory ceilings are immutable + in this script and are emitted in both summaries. + + No credentials or live-session environment values are read or recorded. + This wrapper never compares against or writes the user's normal settings: + run-offline-pixel-gate.ps1 owns a separate isolated state directory for + every row. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Out, + [ValidateSet('capped', 'uncapped', 'both')] + [string]$FramePacing = 'both', + [ValidateSet('retail', 'low', 'medium', 'high', 'auto')] + [string[]]$PresetSet = @('retail', 'low', 'medium', 'high', 'auto'), + [ValidateSet('1920x1080', '2560x1440', '3840x2160')] + [string[]]$ResolutionSet = @('1920x1080', '2560x1440', '3840x2160'), + [ValidateRange(45000, 600000)] + [int]$WarmupMs = 45000, + [int]$DayGroup = 0, + [ValidateRange(0.0, 1.0)] + [double]$WorldDayFraction = 0.5, + [double]$SkyPhaseSeconds = 0, + [ValidateRange(0.0, 5000.0)] + [double]$OrbitDistanceMeters = 0, + [Nullable[double]]$OrbitYawDegrees, + [ValidateRange(-89.0, 89.0)] + [Nullable[double]]$OrbitPitchDegrees, + [switch]$SkipBuild +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $false +. (Join-Path $PSScriptRoot 'atmospheric-performance-matrix-common.ps1') +. (Join-Path $PSScriptRoot 'connected-render-pack-gate-common.ps1') + +$repo = Split-Path -Parent $PSScriptRoot +$pixelGate = Join-Path $PSScriptRoot 'run-offline-pixel-gate.ps1' +$solution = Join-Path $repo 'AcDream.slnx' +$cli = Join-Path $repo 'src\AcDream.Cli\bin\Release\net10.0\AcDream.Cli.dll' +$outputRoot = [IO.Path]::GetFullPath($Out) +$jsonPath = Join-Path $outputRoot 'atmospheric-performance-matrix.json' +$markdownPath = Join-Path $outputRoot 'atmospheric-performance-matrix.md' +$presets = @($PresetSet | ForEach-Object { $_.ToLowerInvariant() } | Select-Object -Unique) +$screenshotLeaf = 'world-offline' +$resolutions = @($ResolutionSet | Select-Object -Unique) +$pacingModes = switch ($FramePacing) { + 'capped' { @('capped') } + 'uncapped' { @('uncapped') } + default { @('capped', 'uncapped') } +} + +# These are the exact declarations in BuiltInAtmosphericRenderPack and the +# campaign plan's Performance budget table. Do not relax them in this tool. +$budgets = @{ + low = [pscustomobject][ordered]@{ + IncrementalCpuMillisecondsP50 = 0.15 + IncrementalCpuMillisecondsP99 = 0.50 + InclusiveGpuMillisecondsP50At1080p = 2.00 + InclusiveGpuMillisecondsP99At1080p = 3.00 + ResidentGpuBytes = 64L * 1024L * 1024L + } + medium = [pscustomobject][ordered]@{ + IncrementalCpuMillisecondsP50 = 0.25 + IncrementalCpuMillisecondsP99 = 0.75 + InclusiveGpuMillisecondsP50At1080p = 3.25 + InclusiveGpuMillisecondsP99At1080p = 4.50 + ResidentGpuBytes = 128L * 1024L * 1024L + } + high = [pscustomobject][ordered]@{ + IncrementalCpuMillisecondsP50 = 0.35 + IncrementalCpuMillisecondsP99 = 1.00 + InclusiveGpuMillisecondsP50At1080p = 4.50 + InclusiveGpuMillisecondsP99At1080p = 6.00 + ResidentGpuBytes = 256L * 1024L * 1024L + } +} + +function Get-RequiredProperty { + param( + [Parameter(Mandatory = $true)][object]$Value, + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$Context + ) + + $property = $Value.PSObject.Properties[$Name] + if ($null -eq $property) { + throw "$Context is missing required property '$Name'." + } + return $property.Value +} + +function Format-Invariant([double]$Value, [string]$Format = '0.###') { + return $Value.ToString($Format, [Globalization.CultureInfo]::InvariantCulture) +} + +function Add-Failure( + [Collections.Generic.List[string]]$RowFailures, + [string]$Message) +{ + $null = $RowFailures.Add($Message) +} + +function Assert-MatrixContainedPath([string]$Root, [string]$Path) { + return Assert-ConnectedGateContainedPath $Root $Path +} + +function Compare-MatrixFallbackFramebuffer { + param( + [Parameter(Mandatory = $true)][string]$Expected, + [Parameter(Mandatory = $true)][string]$Actual, + [Parameter(Mandatory = $true)][string]$ReportPath, + [Parameter(Mandatory = $true)][string]$MaskPath) + + Add-Type -AssemblyName System.Drawing + $image = [System.Drawing.Bitmap]::FromFile($Actual) + try { + $mask = [System.Drawing.Bitmap]::new( + $image.Width, + $image.Height, + [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) + try { + $graphics = [System.Drawing.Graphics]::FromImage($mask) + try { + $graphics.Clear([System.Drawing.Color]::FromArgb(0, 0, 0, 0)) + $opaque = [System.Drawing.SolidBrush]::new( + [System.Drawing.Color]::FromArgb(255, 255, 0, 255)) + try { + $graphics.FillRectangle( + $opaque, + 0, + 0, + $image.Width, + [Math]::Min(280, $image.Height)) + } + finally { $opaque.Dispose() } + } + finally { $graphics.Dispose() } + $mask.Save($MaskPath, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { $mask.Dispose() } + } + finally { $image.Dispose() } + + & dotnet $cli compare-screenshots ` + $Expected $Actual $ReportPath 2 0.001 $MaskPath | Out-Null + $compareExitCode = $LASTEXITCODE + if (-not (Test-Path -LiteralPath $ReportPath -PathType Leaf)) { + return [pscustomobject][ordered]@{ + Passed = $false + DifferentPixelFraction = $null + ExitCode = $compareExitCode + ReportPath = $ReportPath + Failure = 'fallback/default screenshot comparison produced no report' + } + } + $verdict = Get-Content -Raw -LiteralPath $ReportPath | ConvertFrom-Json + $passedProperty = $verdict.PSObject.Properties['passed'] + if ($null -eq $passedProperty) { $passedProperty = $verdict.PSObject.Properties['Passed'] } + $fractionProperty = $verdict.PSObject.Properties['differentPixelFraction'] + if ($null -eq $fractionProperty) { + $fractionProperty = $verdict.PSObject.Properties['DifferentPixelFraction'] + } + $passed = $null -ne $passedProperty -and [bool]$passedProperty.Value + return [pscustomobject][ordered]@{ + Passed = $passed + DifferentPixelFraction = if ($null -eq $fractionProperty) { + $null + } else { [double]$fractionProperty.Value } + ExitCode = $compareExitCode + ReportPath = $ReportPath + Failure = if ($passed) { + $null + } else { 'safe fallback framebuffer differs from its paired default beyond tolerance 2 / 0.001' } + } +} + +if (-not (Test-Path -LiteralPath $pixelGate)) { + throw "Offline pixel gate not found at '$pixelGate'." +} +if (Test-Path -LiteralPath $outputRoot) { + throw "Output directory already exists: '$outputRoot'. Choose a new directory." +} +$outputParent = Split-Path -Parent $outputRoot +if (-not (Test-Path -LiteralPath $outputParent -PathType Container)) { + throw "Output parent directory does not exist: '$outputParent'." +} +Assert-ConnectedGateNoReparsePoint $outputParent +Assert-ConnectedGateSafeLeafName ([IO.Path]::GetFileName($outputRoot)) +if (@(Get-Process -Name AcDream.App -ErrorAction SilentlyContinue).Count -gt 0) { + throw 'An AcDream.App client is already running; close it gracefully before the matrix.' +} + +if (-not $SkipBuild) { + & dotnet build $solution -c Release --no-restore + if ($LASTEXITCODE -ne 0) { + throw "Release build failed with exit code $LASTEXITCODE." + } +} + +$exe = Join-Path $repo 'src\AcDream.App\bin\Release\net10.0\AcDream.App.exe' +if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { + throw "Client executable not found: '$exe'." +} +$binaryIdentity = Get-ConnectedGateBinaryIdentity ` + -Repository $repo -Executable $exe -SkipBuild:$SkipBuild +$null = New-Item -ItemType Directory -Path $outputRoot +Assert-ConnectedGateNoReparsePoint $outputRoot +$sourceCommit = $binaryIdentity.SourceCommit +$sourceStatus = @($binaryIdentity.SourceTrackedStatus) +$powershellExecutable = (Get-Process -Id $PID).Path +$rows = [Collections.Generic.List[object]]::new() +$matrixFailures = [Collections.Generic.List[string]]::new() +$startedUtc = [DateTime]::UtcNow +$expectedCasterCount = $null +$expectedAdapterIdentity = $null + +foreach ($pacing in $pacingModes) { + foreach ($resolution in $resolutions) { + $dimensions = $resolution.Split('x') + $expectedWidth = [int]$dimensions[0] + $expectedHeight = [int]$dimensions[1] + + foreach ($preset in $presets) { + $rowId = "$pacing-$preset-$($resolution.Replace('x', 'x'))" + Assert-ConnectedGateSafeLeafName $rowId + $rowDirectory = Assert-MatrixContainedPath $outputRoot (Join-Path $outputRoot $rowId) + if (Test-Path -LiteralPath $rowDirectory) { + throw "Matrix row directory is not fresh: '$rowDirectory'." + } + $metadataPath = Assert-MatrixContainedPath $rowDirectory ( + Join-Path $rowDirectory "screenshots\$screenshotLeaf.metadata.json") + $rowFailures = [Collections.Generic.List[string]]::new() + $captureExitCode = -1 + $metadata = $null + $pack = $null + $performance = $null + $cpuSampleCount = $null + $absoluteReceiverCpuSampleCount = $null + $gpuSampleCount = $null + $cpuP50 = $null + $cpuP95 = $null + $cpuP99 = $null + $absoluteReceiverCpuP50 = $null + $absoluteReceiverCpuP95 = $null + $absoluteReceiverCpuP99 = $null + $gpuP50 = $null + $gpuP95 = $null + $gpuP99 = $null + $residentGpuBytes = $null + $transientGpuBytes = $null + $casterCount = $null + $cascadeCount = $null + $drawCalls = $null + $dispatchCalls = $null + $imageCount = $null + $bufferCount = $null + $effectiveQuality = $null + $activationState = $null + $failureReason = $null + $availability = if ($preset -eq 'retail') { 'Retail' } else { 'Unknown' } + $unavailableClassification = $null + $metadataSchemaVersion = $null + $packVersion = $null + $activationGeneration = $null + $topResidentGpuBytes = $null + $topTransientGpuBytes = $null + $classificationCalls = $null + $passIds = @() + $framebufferSha256 = $null + $processEvidence = $null + $adapterEvidence = $null + $budget = if ($preset -in @('retail', 'auto')) { + $null + } else { $budgets[$preset] } + $gpuBudgetApplies = $false + + try { + $arguments = @( + '-NoProfile', + '-File', $pixelGate, + '-Out', $rowDirectory, + '-WarmupMs', "$WarmupMs", + '-DayGroup', "$DayGroup", + '-WorldDayFraction', (Format-Invariant $WorldDayFraction '0.################'), + '-SkyPhaseSeconds', (Format-Invariant $SkyPhaseSeconds '0.################'), + '-MsaaSamples', '0', + '-RenderPackPreset', $preset, + '-Resolution', $resolution, + '-OrbitDistanceMeters', (Format-Invariant $OrbitDistanceMeters '0.################'), + '-SkipBuild') + if ($preset -ne 'retail') { + $arguments += @( + '-RequiredRenderPackSamples', '2048', + '-RenderPackSampleTimeoutMs', '300000', + '-AllowSafeRenderPackFallback') + } + if ($null -ne $OrbitYawDegrees) { + $arguments += @( + '-OrbitYawDegrees', + (Format-Invariant ([double]$OrbitYawDegrees) '0.################')) + } + if ($null -ne $OrbitPitchDegrees) { + $arguments += @( + '-OrbitPitchDegrees', + (Format-Invariant ([double]$OrbitPitchDegrees) '0.################')) + } + if ($pacing -eq 'uncapped') { + $arguments += '-Uncapped' + } + + & $powershellExecutable @arguments + $captureExitCode = $LASTEXITCODE + if ($captureExitCode -ne 0) { + Add-Failure $rowFailures ( + "offline capture exited with code $captureExitCode") + } + if (-not (Test-Path -LiteralPath $metadataPath)) { + throw "screenshot metadata is missing at '$metadataPath'." + } + foreach ($capturedPath in @( + $rowDirectory, + (Join-Path $rowDirectory 'screenshots'), + (Join-Path $rowDirectory 'isolated-state'), + (Join-Path $rowDirectory 'isolated-state\cache'))) { + Assert-ConnectedGateNoReparsePoint $capturedPath + } + + $metadata = Get-Content -Raw -LiteralPath $metadataPath | ConvertFrom-Json + $metadataSchemaVersion = [int](Get-RequiredProperty $metadata 'SchemaVersion' 'metadata') + $strictEvidence = Test-AtmosphericPerformanceMetadataEvidence ` + -MetadataPath $metadataPath -Preset $preset ` + -ExpectedWidth $expectedWidth -ExpectedHeight $expectedHeight ` + -AllowSafeFallback:($preset -ne 'retail') + foreach ($strictFailure in @($strictEvidence.Failures)) { + Add-Failure $rowFailures $strictFailure + } + $availability = [string]$strictEvidence.Outcome + $unavailableClassification = $strictEvidence.UnavailableClassification + $classificationCalls = $strictEvidence.CpuClassificationCalls + $passIds = @($strictEvidence.PassIds) + $actualWidth = [int](Get-RequiredProperty $metadata 'Width' 'metadata') + $actualHeight = [int](Get-RequiredProperty $metadata 'Height' 'metadata') + if ($actualWidth -ne $expectedWidth -or $actualHeight -ne $expectedHeight) { + Add-Failure $rowFailures ( + "capture extent was ${actualWidth}x${actualHeight}, expected $resolution") + } + + $pack = Get-RequiredProperty $metadata 'RenderPack' 'metadata' + $activationState = [int](Get-RequiredProperty $pack 'State' 'RenderPack') + $actualPackId = [string](Get-RequiredProperty $pack 'PackId' 'RenderPack') + $actualPresetId = [string](Get-RequiredProperty $pack 'PresetId' 'RenderPack') + $packVersion = [string](Get-RequiredProperty $pack 'PackVersion' 'RenderPack') + $activationGeneration = [int](Get-RequiredProperty $pack 'ActivationGeneration' 'RenderPack') + $topResidentGpuBytes = [long](Get-RequiredProperty $pack 'RetainedGpuBytes' 'RenderPack') + $topTransientGpuBytes = [long](Get-RequiredProperty $pack 'TransientGpuBytes' 'RenderPack') + $effectiveQuality = [string]( + Get-RequiredProperty $pack 'EffectiveQuality' 'RenderPack') + if ($availability -eq 'Active' -and $preset -eq 'auto') { + $budget = $budgets[$effectiveQuality] + if ($null -eq $budget) { + Add-Failure $rowFailures ( + "Automatic resolved unknown effective quality '$effectiveQuality'") + } + } + $gpuBudgetApplies = $availability -eq 'Active' -and + $resolution -eq '1920x1080' + $failureReason = Get-RequiredProperty $pack 'FailureReason' 'RenderPack' + $expectedPackId = if ($preset -eq 'retail' -or $availability -eq 'Unavailable') { + 'retail' + } + else { + 'acdream.atmospheric' + } + $expectedPresetId = if ($preset -eq 'retail' -or $availability -eq 'Unavailable') { + 'off' + } else { $preset } + $expectedState = if ($preset -eq 'retail') { + 0 + } elseif ($availability -eq 'Unavailable') { + 3 + } else { 2 } + if ($actualPackId -cne $expectedPackId) { + Add-Failure $rowFailures ( + "pack was '$actualPackId', expected '$expectedPackId'") + } + if ($actualPresetId -cne $expectedPresetId) { + Add-Failure $rowFailures ( + "preset was '$actualPresetId', expected '$expectedPresetId'") + } + if ($activationState -ne $expectedState) { + Add-Failure $rowFailures ( + "activation state was $activationState, expected $expectedState") + } + if ($availability -ne 'Unavailable' -and + -not [string]::IsNullOrWhiteSpace([string]$failureReason)) { + Add-Failure $rowFailures "render-pack failure: $failureReason" + } + + $casterCount = [int](Get-RequiredProperty $pack 'ShadowCasterCount' 'RenderPack') + $cascadeCount = [int](Get-RequiredProperty $pack 'CascadeDrawCount' 'RenderPack') + $drawCalls = [int](Get-RequiredProperty $pack 'DrawCalls' 'RenderPack') + $dispatchCalls = [int](Get-RequiredProperty $pack 'DispatchCalls' 'RenderPack') + $imageCount = [int](Get-RequiredProperty $pack 'ImageCount' 'RenderPack') + $bufferCount = [int](Get-RequiredProperty $pack 'BufferCount' 'RenderPack') + $performance = Get-RequiredProperty $pack 'Performance' 'RenderPack' + + # Consume only the post-audit metric names. The old absolute + # CpuMilliseconds*/GpuMilliseconds* fields must never be used + # to decide an incremental budget row. + $cpuSampleCount = [int](Get-RequiredProperty ` + $performance 'CpuSampleCount' 'RenderPack.Performance') + $absoluteReceiverCpuSampleCount = [int](Get-RequiredProperty ` + $performance 'AbsoluteReceiverCpuSampleCount' 'RenderPack.Performance') + $gpuSampleCount = [int](Get-RequiredProperty ` + $performance 'GpuSampleCount' 'RenderPack.Performance') + $cpuP50 = [double](Get-RequiredProperty ` + $performance 'IncrementalCpuMillisecondsP50' 'RenderPack.Performance') + $cpuP95 = [double](Get-RequiredProperty ` + $performance 'IncrementalCpuMillisecondsP95' 'RenderPack.Performance') + $cpuP99 = [double](Get-RequiredProperty ` + $performance 'IncrementalCpuMillisecondsP99' 'RenderPack.Performance') + $absoluteReceiverCpuP50 = [double](Get-RequiredProperty ` + $performance 'AbsoluteReceiverCpuMillisecondsP50' 'RenderPack.Performance') + $absoluteReceiverCpuP95 = [double](Get-RequiredProperty ` + $performance 'AbsoluteReceiverCpuMillisecondsP95' 'RenderPack.Performance') + $absoluteReceiverCpuP99 = [double](Get-RequiredProperty ` + $performance 'AbsoluteReceiverCpuMillisecondsP99' 'RenderPack.Performance') + $gpuP50 = [double](Get-RequiredProperty ` + $performance 'InclusiveGpuMillisecondsP50' 'RenderPack.Performance') + $gpuP95 = [double](Get-RequiredProperty ` + $performance 'InclusiveGpuMillisecondsP95' 'RenderPack.Performance') + $gpuP99 = [double](Get-RequiredProperty ` + $performance 'InclusiveGpuMillisecondsP99' 'RenderPack.Performance') + $residentGpuBytes = [long](Get-RequiredProperty ` + $performance 'ResidentGpuBytes' 'RenderPack.Performance') + $transientGpuBytes = [long](Get-RequiredProperty ` + $performance 'TransientGpuBytes' 'RenderPack.Performance') + + if ($availability -eq 'Active') { + if ($null -eq $expectedCasterCount) { $expectedCasterCount = $casterCount } + elseif ($casterCount -ne $expectedCasterCount) { + Add-Failure $rowFailures ( + "shadow caster membership $casterCount differs from matrix oracle $expectedCasterCount") + } + } + + $pngPath = Assert-MatrixContainedPath $rowDirectory ( + Join-Path $rowDirectory "screenshots\$screenshotLeaf.png") + $framebufferSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $pngPath).Hash.ToLowerInvariant() + $processPath = Assert-MatrixContainedPath $rowDirectory ( + Join-Path $rowDirectory 'capture-process.json') + $processEvidence = Get-Content -Raw -LiteralPath $processPath | ConvertFrom-Json + if ([int]$processEvidence.SchemaVersion -ne 1 -or + [long]$processEvidence.WorkingSetBytes -le 0 -or + [long]$processEvidence.PrivateMemoryBytes -le 0) { + Add-Failure $rowFailures 'process-memory evidence is missing or invalid' + } + $capabilityPath = Assert-MatrixContainedPath $rowDirectory ( + Join-Path $rowDirectory 'isolated-state\cache\diagnostics\graphical-capabilities-vulkan.json') + $capability = Get-Content -Raw -LiteralPath $capabilityPath | ConvertFrom-Json + $adapterEvidence = [pscustomobject][ordered]@{ + DeviceName = [string]$capability.DeviceName + DriverInfo = [string]$capability.DriverInfo + DeviceType = [string]$capability.DeviceType + SelectedDeviceIndex = [int]$capability.SelectedDeviceIndex + DeviceApiVersion = [string]$capability.DeviceApiVersion + } + $adapterIdentity = "$($adapterEvidence.DeviceName)|$($adapterEvidence.DriverInfo)|$($adapterEvidence.SelectedDeviceIndex)" + if ([string]::IsNullOrWhiteSpace($adapterEvidence.DeviceName)) { + Add-Failure $rowFailures 'Vulkan adapter identity is missing' + } + elseif ($null -eq $expectedAdapterIdentity) { $expectedAdapterIdentity = $adapterIdentity } + elseif ($adapterIdentity -cne $expectedAdapterIdentity) { + Add-Failure $rowFailures 'Vulkan adapter identity changed between matrix rows' + } + + if ($preset -eq 'retail') { + if ($imageCount -ne 0 -or $bufferCount -ne 0 -or + $drawCalls -ne 0 -or $dispatchCalls -ne 0 -or + $casterCount -ne 0 -or $cascadeCount -ne 0 -or + $residentGpuBytes -ne 0 -or $transientGpuBytes -ne 0) { + Add-Failure $rowFailures ( + 'retail row activated pack resources, submissions, casters, or memory') + } + } + elseif ($availability -eq 'Unavailable') { + # The strict metadata oracle already proved exact retail + # identity, zero samples/work/resources, and an allow-listed + # resource/capability reason. No enhanced budget applies to + # a preset which never became active. + } + else { + if ($cpuSampleCount -le 0 -or $gpuSampleCount -le 0) { + Add-Failure $rowFailures ( + "enhanced row has insufficient samples: cpu=$cpuSampleCount gpu=$gpuSampleCount") + } + if (-not [double]::IsFinite($cpuP50) -or $cpuP50 -lt 0 -or + -not [double]::IsFinite($cpuP99) -or $cpuP99 -lt 0 -or + -not [double]::IsFinite($gpuP50) -or $gpuP50 -lt 0 -or + -not [double]::IsFinite($gpuP99) -or $gpuP99 -lt 0 -or + $residentGpuBytes -lt 0) { + Add-Failure $rowFailures 'performance measurements are non-finite or negative' + } + if ($cpuP50 -gt $budget.IncrementalCpuMillisecondsP50) { + Add-Failure $rowFailures ( + "incremental CPU p50 $(Format-Invariant $cpuP50) ms exceeds " + + "$(Format-Invariant $budget.IncrementalCpuMillisecondsP50) ms") + } + if ($cpuP99 -gt $budget.IncrementalCpuMillisecondsP99) { + Add-Failure $rowFailures ( + "incremental CPU p99 $(Format-Invariant $cpuP99) ms exceeds " + + "$(Format-Invariant $budget.IncrementalCpuMillisecondsP99) ms") + } + if ($residentGpuBytes -gt $budget.ResidentGpuBytes) { + Add-Failure $rowFailures ( + "resident GPU bytes $residentGpuBytes exceed $($budget.ResidentGpuBytes)") + } + if ($gpuBudgetApplies -and + $gpuP50 -gt $budget.InclusiveGpuMillisecondsP50At1080p) { + Add-Failure $rowFailures ( + "inclusive GPU p50 $(Format-Invariant $gpuP50) ms exceeds the 1080p " + + "ceiling $(Format-Invariant $budget.InclusiveGpuMillisecondsP50At1080p) ms") + } + if ($gpuBudgetApplies -and + $gpuP99 -gt $budget.InclusiveGpuMillisecondsP99At1080p) { + Add-Failure $rowFailures ( + "inclusive GPU p99 $(Format-Invariant $gpuP99) ms exceeds the 1080p " + + "ceiling $(Format-Invariant $budget.InclusiveGpuMillisecondsP99At1080p) ms") + } + } + } + catch { + Add-Failure $rowFailures $_.Exception.Message + } + + $passed = $rowFailures.Count -eq 0 + $row = [pscustomobject][ordered]@{ + Id = $rowId + FramePacing = $pacing + Preset = $preset + Resolution = $resolution + CaptureDirectory = $rowDirectory + MetadataPath = $metadataPath + CaptureExitCode = $captureExitCode + Passed = $passed + Failures = @($rowFailures) + Activation = [pscustomobject][ordered]@{ + MetadataSchemaVersion = $metadataSchemaVersion + PackVersion = $packVersion + State = $activationState + Generation = $activationGeneration + EffectiveQuality = $effectiveQuality + FailureReason = $failureReason + Availability = $availability + UnavailableClassification = $unavailableClassification + } + FramebufferSha256 = $framebufferSha256 + PairedDefaultFramebufferSha256 = $null + PairedDefaultComparison = $null + Process = $processEvidence + Adapter = $adapterEvidence + Samples = [pscustomobject][ordered]@{ + IncrementalCpu = $cpuSampleCount + AbsoluteReceiverCpu = $absoluteReceiverCpuSampleCount + InclusiveGpu = $gpuSampleCount + } + PerformanceMilliseconds = [pscustomobject][ordered]@{ + IncrementalCpuP50 = $cpuP50 + IncrementalCpuP95 = $cpuP95 + IncrementalCpuP99 = $cpuP99 + AbsoluteReceiverCpuP50 = $absoluteReceiverCpuP50 + AbsoluteReceiverCpuP95 = $absoluteReceiverCpuP95 + AbsoluteReceiverCpuP99 = $absoluteReceiverCpuP99 + InclusiveGpuP50 = $gpuP50 + InclusiveGpuP95 = $gpuP95 + InclusiveGpuP99 = $gpuP99 + } + Resources = [pscustomobject][ordered]@{ + TopLevelResidentGpuBytes = $topResidentGpuBytes + TopLevelTransientGpuBytes = $topTransientGpuBytes + ResidentGpuBytes = $residentGpuBytes + TransientGpuBytes = $transientGpuBytes + Images = $imageCount + Buffers = $bufferCount + } + Work = [pscustomobject][ordered]@{ + ShadowCasters = $casterCount + CascadeDraws = $cascadeCount + DrawCalls = $drawCalls + DispatchCalls = $dispatchCalls + CpuClassificationCalls = $classificationCalls + PassIds = @($passIds) + } + Budget = if ($null -eq $budget) { + $null + } + else { + [pscustomobject][ordered]@{ + IncrementalCpuMillisecondsP50 = + $budget.IncrementalCpuMillisecondsP50 + IncrementalCpuMillisecondsP99 = + $budget.IncrementalCpuMillisecondsP99 + InclusiveGpuMillisecondsP50At1080p = + $budget.InclusiveGpuMillisecondsP50At1080p + InclusiveGpuMillisecondsP99At1080p = + $budget.InclusiveGpuMillisecondsP99At1080p + ResidentGpuBytes = $budget.ResidentGpuBytes + GpuBudgetApplies = $gpuBudgetApplies + } + } + } + $null = $rows.Add($row) + foreach ($failure in $rowFailures) { + $null = $matrixFailures.Add("${rowId}: $failure") + } + } + } +} + +foreach ($row in @($rows | Where-Object { $_.Preset -ne 'retail' })) { + $defaultRow = @($rows | Where-Object { + $_.Preset -eq 'retail' -and + $_.FramePacing -eq $row.FramePacing -and + $_.Resolution -eq $row.Resolution + }) + if ($defaultRow.Count -ne 1 -or + [string]::IsNullOrWhiteSpace([string]$defaultRow[0].FramebufferSha256)) { + $message = "$($row.Id): paired default framebuffer digest is unavailable" + $null = $matrixFailures.Add($message) + $row.Passed = $false + $row.Failures = @($row.Failures) + $message + } + else { + $row.PairedDefaultFramebufferSha256 = $defaultRow[0].FramebufferSha256 + if ($row.Activation.Availability -eq 'Unavailable') { + $expectedPng = Assert-MatrixContainedPath $defaultRow[0].CaptureDirectory ( + Join-Path $defaultRow[0].CaptureDirectory "screenshots\$screenshotLeaf.png") + $actualPng = Assert-MatrixContainedPath $row.CaptureDirectory ( + Join-Path $row.CaptureDirectory "screenshots\$screenshotLeaf.png") + $comparisonReport = Assert-MatrixContainedPath $row.CaptureDirectory ( + Join-Path $row.CaptureDirectory 'compare-paired-default.json') + $comparisonMask = Assert-MatrixContainedPath $row.CaptureDirectory ( + Join-Path $row.CaptureDirectory 'compare-paired-default-mask.png') + try { + $row.PairedDefaultComparison = Compare-MatrixFallbackFramebuffer ` + -Expected $expectedPng ` + -Actual $actualPng ` + -ReportPath $comparisonReport ` + -MaskPath $comparisonMask + if (-not $row.PairedDefaultComparison.Passed) { + $message = "$($row.Id): $($row.PairedDefaultComparison.Failure)" + $null = $matrixFailures.Add($message) + $row.Passed = $false + $row.Failures = @($row.Failures) + $message + } + } + catch { + $message = "$($row.Id): fallback/default screenshot comparison failed: $($_.Exception.Message)" + $null = $matrixFailures.Add($message) + $row.Passed = $false + $row.Failures = @($row.Failures) + $message + } + } + } +} + +$finishedUtc = [DateTime]::UtcNow +$report = [pscustomobject][ordered]@{ + SchemaVersion = 1 + Scope = 'offline fixed-scene pack diagnostics; final receiver CPU authority requires connected identical pack-off/on A/B' + Passed = $matrixFailures.Count -eq 0 + StartedUtc = $startedUtc.ToString('O') + FinishedUtc = $finishedUtc.ToString('O') + SourceCommit = $sourceCommit + TrackedSourceStatus = @($sourceStatus) + BinaryProductVersion = $binaryIdentity.BinaryProductVersion + BinaryCommit = $binaryIdentity.BinaryCommit + BinaryMatchesSource = $binaryIdentity.BinaryMatchesSource + SkipBuild = $binaryIdentity.SkipBuild + FramePacing = $FramePacing + Pins = [pscustomobject][ordered]@{ + WarmupMs = $WarmupMs + ExplicitPresetPerformanceWindowResetAfterWarmup = $true + AutomaticPerformanceWindowPolicy = + 'settled rolling window; diagnostic reset prohibited' + RequiredEnhancedSamplesPerMetric = 2048 + RenderPackSampleTimeoutMs = 300000 + DayGroup = $DayGroup + WorldDayFraction = $WorldDayFraction + SkyPhaseSeconds = $SkyPhaseSeconds + MsaaSamples = 0 + OrbitDistanceMeters = $OrbitDistanceMeters + OrbitYawDegrees = $OrbitYawDegrees + OrbitPitchDegrees = $OrbitPitchDegrees + Presets = $presets + Resolutions = $resolutions + } + Summary = [pscustomobject][ordered]@{ + TotalRows = $rows.Count + PassedRows = @($rows | Where-Object Passed).Count + ActiveEnhancedRows = @($rows | Where-Object { + $_.Activation.Availability -eq 'Active' -and $_.Passed + }).Count + ResourceUnavailableRows = @($rows | Where-Object { + $_.Activation.UnavailableClassification -eq 'ResourceUnavailable' -and $_.Passed + }).Count + CapabilityUnavailableRows = @($rows | Where-Object { + $_.Activation.UnavailableClassification -eq 'CapabilityUnavailable' -and $_.Passed + }).Count + FailedRows = @($rows | Where-Object { -not $_.Passed }).Count + } + EvidenceOracle = [pscustomobject][ordered]@{ + RequiredSamplesPerMetric = 2048 + EqualEnhancedShadowCasterCount = $expectedCasterCount + CascadeDraws = [pscustomobject][ordered]@{ Low = 2; Medium = 3; High = 4 } + WarmedCpuClassificationCalls = 0 + VulkanAdapterIdentity = $expectedAdapterIdentity + } + DeclaredBudgets = [pscustomobject][ordered]@{ + Low = $budgets.low + Medium = $budgets.medium + High = $budgets.high + } + Rows = @($rows) + Failures = @($matrixFailures) +} +$report | ConvertTo-Json -Depth 12 | + Set-Content -Encoding utf8 -LiteralPath $jsonPath + +$markdown = [Text.StringBuilder]::new() +$null = $markdown.AppendLine('# Atmospheric Rendering Performance Matrix') +$null = $markdown.AppendLine() +$null = $markdown.AppendLine('Scope: offline fixed-scene pack diagnostics. Final receiver CPU authority requires connected identical pack-off/on A/B.') +$null = $markdown.AppendLine() +$null = $markdown.AppendLine("- Result: **$(if ($report.Passed) { 'PASS' } else { 'FAIL' })**") +$null = $markdown.AppendLine("- Source commit: ``$sourceCommit``") +$null = $markdown.AppendLine("- Binary commit: ``$($binaryIdentity.BinaryCommit)`` (source match: $($binaryIdentity.BinaryMatchesSource))") +$null = $markdown.AppendLine("- Vulkan adapter: ``$expectedAdapterIdentity``") +$null = $markdown.AppendLine("- Frame pacing: ``$FramePacing``") +$null = $markdown.AppendLine( + "- Pins: warmup ${WarmupMs} ms; day group $DayGroup; world day fraction " + + "$(Format-Invariant $WorldDayFraction '0.################'); sky phase " + + "$(Format-Invariant $SkyPhaseSeconds '0.################') s; MSAA 0; orbit " + + "$(Format-Invariant $OrbitDistanceMeters) m") +$null = $markdown.AppendLine( + "- Evidence window: explicit presets reset diagnostics after warmup; Auto preserves " + + "its hysteresis-owned rolling window. Every active row waits for a complete 2048-sample " + + "CPU / receiver / GPU window (300000 ms timeout).") +$null = $markdown.AppendLine() +$null = $markdown.AppendLine('## Declared ceilings') +$null = $markdown.AppendLine() +$null = $markdown.AppendLine('| Preset | CPU p50 / p99 (ms) | GPU p50 / p99 at 1080p (ms) | Resident MiB |') +$null = $markdown.AppendLine('|---|---:|---:|---:|') +foreach ($preset in @('low', 'medium', 'high')) { + $budget = $budgets[$preset] + $null = $markdown.AppendLine( + "| $preset | $(Format-Invariant $budget.IncrementalCpuMillisecondsP50) / " + + "$(Format-Invariant $budget.IncrementalCpuMillisecondsP99) | " + + "$(Format-Invariant $budget.InclusiveGpuMillisecondsP50At1080p) / " + + "$(Format-Invariant $budget.InclusiveGpuMillisecondsP99At1080p) | " + + "$([Math]::Round($budget.ResidentGpuBytes / 1MB, 0)) |") +} +$null = $markdown.AppendLine() +$null = $markdown.AppendLine('## Rows') +$null = $markdown.AppendLine() +$null = $markdown.AppendLine('| Pacing | Preset | Resolution | Availability | CPU / receiver / GPU samples | CPU p50 / p95 / p99 ms | Receiver p50 / p95 / p99 ms | GPU p50 / p95 / p99 ms | Resident MiB | Process WS / private MiB | Casters | Cascades | Draw / dispatch | Framebuffer / paired-default SHA-256 | Result |') +$null = $markdown.AppendLine('|---|---|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---|') +foreach ($row in $rows) { + $cpu = if ($null -eq $row.PerformanceMilliseconds.IncrementalCpuP50) { + 'n/a' + } + else { + "$(Format-Invariant $row.PerformanceMilliseconds.IncrementalCpuP50) / " + + "$(Format-Invariant $row.PerformanceMilliseconds.IncrementalCpuP95) / " + + "$(Format-Invariant $row.PerformanceMilliseconds.IncrementalCpuP99)" + } + $gpu = if ($null -eq $row.PerformanceMilliseconds.InclusiveGpuP50) { + 'n/a' + } + else { + "$(Format-Invariant $row.PerformanceMilliseconds.InclusiveGpuP50) / " + + "$(Format-Invariant $row.PerformanceMilliseconds.InclusiveGpuP95) / " + + "$(Format-Invariant $row.PerformanceMilliseconds.InclusiveGpuP99)" + } + $receiver = if ($null -eq $row.PerformanceMilliseconds.AbsoluteReceiverCpuP50) { + 'n/a' + } + else { + "$(Format-Invariant $row.PerformanceMilliseconds.AbsoluteReceiverCpuP50) / " + + "$(Format-Invariant $row.PerformanceMilliseconds.AbsoluteReceiverCpuP95) / " + + "$(Format-Invariant $row.PerformanceMilliseconds.AbsoluteReceiverCpuP99)" + } + $resident = if ($null -eq $row.Resources.ResidentGpuBytes) { + 'n/a' + } + else { + Format-Invariant ($row.Resources.ResidentGpuBytes / 1MB) + } + $processMemory = if ($null -eq $row.Process) { 'n/a' } else { + "$(Format-Invariant ($row.Process.WorkingSetBytes / 1MB)) / " + + "$(Format-Invariant ($row.Process.PrivateMemoryBytes / 1MB))" + } + $digest = if ($null -eq $row.FramebufferSha256) { 'n/a' } else { $row.FramebufferSha256 } + $pairedDigest = if ($null -eq $row.PairedDefaultFramebufferSha256) { 'n/a' } else { $row.PairedDefaultFramebufferSha256 } + $result = if ($row.Passed -and $row.Activation.Availability -eq 'Unavailable') { + 'UNAVAILABLE: ' + $row.Activation.UnavailableClassification + ' - ' + + (([string]$row.Activation.FailureReason) -replace '\|', '/') + } + elseif ($row.Passed) { + 'PASS' + } + else { + 'FAIL: ' + ((@($row.Failures) -join '; ') -replace '\|', '/') + } + $null = $markdown.AppendLine( + "| $($row.FramePacing) | $($row.Preset) | $($row.Resolution) | $($row.Activation.Availability) | " + + "$($row.Samples.IncrementalCpu) / $($row.Samples.AbsoluteReceiverCpu) / $($row.Samples.InclusiveGpu) | " + + "$cpu | $receiver | $gpu | $resident | $processMemory | $($row.Work.ShadowCasters) | " + + "$($row.Work.CascadeDraws) | $($row.Work.DrawCalls) / $($row.Work.DispatchCalls) | " + + "$digest / $pairedDigest | $result |") +} +if ($matrixFailures.Count -ne 0) { + $null = $markdown.AppendLine() + $null = $markdown.AppendLine('## Failures') + $null = $markdown.AppendLine() + foreach ($failure in $matrixFailures) { + $null = $markdown.AppendLine("- $failure") + } +} +$markdown.ToString() | Set-Content -Encoding utf8 -LiteralPath $markdownPath + +Write-Output "JSON=$jsonPath" +Write-Output "MARKDOWN=$markdownPath" +Write-Output "RESULT=$(if ($report.Passed) { 'PASS' } else { 'FAIL' })" +if (-not $report.Passed) { + exit 1 +} diff --git a/tools/run-connected-r6-soak.ps1 b/tools/run-connected-r6-soak.ps1 index d6566a75..a5dfd37f 100644 --- a/tools/run-connected-r6-soak.ps1 +++ b/tools/run-connected-r6-soak.ps1 @@ -9,11 +9,15 @@ param( [switch]$CaptureContention, [switch]$SkipRuntimeCounters, [int]$LoginTimeoutSeconds = 90, - [int]$CollisionShadowEvery = 0 + [int]$CollisionShadowEvery = 0, + [ValidateSet('retail', 'low', 'medium', 'high', 'auto')] + [string]$RenderPackPreset = 'retail', + [hashtable]$RenderPackSettingOverrides = @{} ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'connected-render-pack-gate-common.ps1') if ([string]::IsNullOrWhiteSpace($Account)) { $Account = 'testaccount' } if ([string]::IsNullOrWhiteSpace($Password)) { $Password = 'testpassword' } @@ -722,35 +726,19 @@ if (-not $SkipBuild) { if (-not (Test-Path -LiteralPath $exe)) { throw "client executable not found: $exe" } if (-not (Test-Path -LiteralPath $cliDll)) { throw "CLI assembly not found: $cliDll" } -$sourceCommit = (& git -C $Repository rev-parse HEAD).Trim() -# -SkipBuild is intentionally supported, so the checked-out source commit is -# not necessarily the binary being measured. Read the SDK-stamped -# AssemblyInformationalVersion from the executable and make the BINARY commit -# the baseline identity. Otherwise a docs-only commit after a build can -# silently mislabel every performance artifact. -$binaryProductVersion = [Diagnostics.FileVersionInfo]::GetVersionInfo($exe).ProductVersion -$binaryCommitMatch = [regex]::Match( - [string]$binaryProductVersion, - '\+([0-9a-fA-F]{40})(?:\.|$)') -$binaryCommit = if ($binaryCommitMatch.Success) { - $binaryCommitMatch.Groups[1].Value.ToLowerInvariant() -} -else { - $null -} -$commit = if ($null -ne $binaryCommit) { $binaryCommit } else { $sourceCommit } -$binaryMatchesSource = $null -ne $binaryCommit -and $binaryCommit -eq $sourceCommit -# Generated logs and artifacts may be untracked beside a clean source tree. -# The reproducibility contract is about tracked source modifications. -$sourceStatus = @(& git -C $Repository status --short --untracked-files=no) -if ($null -eq $binaryCommit) { - $failures.Add( - "client binary ProductVersion '$binaryProductVersion' does not identify a 40-character source commit") -} -elseif (-not $binaryMatchesSource) { - $warnings.Add( - "measured binary commit $binaryCommit differs from checked-out source commit $sourceCommit") -} +$renderPackGate = New-ConnectedRenderPackGateState ` + -Root $artifactDir ` + -Preset $RenderPackPreset ` + -SettingOverrides $RenderPackSettingOverrides +try { +$binaryIdentity = Get-ConnectedGateBinaryIdentity ` + -Repository $Repository -Executable $exe -SkipBuild:$SkipBuild +$sourceCommit = $binaryIdentity.SourceCommit +$binaryProductVersion = $binaryIdentity.BinaryProductVersion +$binaryCommit = $binaryIdentity.BinaryCommit +$binaryMatchesSource = $binaryIdentity.BinaryMatchesSource +$sourceStatus = @($binaryIdentity.SourceTrackedStatus) +$commit = $binaryCommit $videoControllers = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | ForEach-Object { [pscustomobject]@{ Name = $_.Name; DriverVersion = $_.DriverVersion; AdapterRam = $_.AdapterRAM } }) @@ -789,7 +777,7 @@ $null = New-Item -ItemType Directory -Force -Path $artifactDir $acdreamEnvVars = Get-ChildItem Env: | Where-Object { $_.Name -like 'ACDREAM_*' } | Sort-Object Name | ForEach-Object { - $sensitive = $_.Name -match '(?i)(PASS|PASSWORD|TOKEN|SECRET|KEY)' + $sensitive = $_.Name -match '(?i)(PASS|PASSWORD|TOKEN|SECRET|KEY|USER|ACCOUNT)' [pscustomobject]@{ Name = $_.Name Value = if ($sensitive) { '' } else { $_.Value } @@ -809,6 +797,7 @@ $envDisclosure = [pscustomobject][ordered]@{ RuntimeCounters = -not [bool]$SkipRuntimeCounters CollisionShadowEvery = $CollisionShadowEvery Route = $routeFileName + RenderPackSelection = (Get-ConnectedRenderPackGateReport $renderPackGate) EnvironmentVariables = @($acdreamEnvVars) } $envDisclosure | ConvertTo-Json -Depth 4 | @@ -924,6 +913,12 @@ try { 30 $canonicalCheckpoints = @(Read-CanonicalCheckpoints) Add-CanonicalCheckpointFailures $process + Add-ConnectedRenderPackMetadataFailures ` + -ArtifactDirectory $artifactDir ` + -ScreenshotNames $expectedCheckpointNames ` + -State $renderPackGate ` + -Failures $failures ` + -Label $runName if ($CollisionShadowEvery -gt 0 -and $canonicalCheckpoints.Count -gt 0) { $lastShadow = $canonicalCheckpoints[-1].resources.PSObject.Properties['collisionShadow'] @@ -1027,6 +1022,7 @@ finally { BinaryMatchesSource = $binaryMatchesSource SessionName = $env:SESSIONNAME CollisionShadowEvery = $CollisionShadowEvery + RenderPackSelection = (Get-ConnectedRenderPackGateReport $renderPackGate) ExitCode = $exitCode GracefulExit = $gracefulExit Failures = @($failures) @@ -1057,5 +1053,9 @@ finally { foreach ($failure in $failures) { Write-Output "FAILURE=$failure" } foreach ($warning in $warnings) { Write-Output "WARNING=$warning" } } +} +finally { + Restore-ConnectedRenderPackGateEnvironment $renderPackGate +} if ($failures.Count -gt 0) { exit 1 } diff --git a/tools/run-connected-world-lifecycle-gate.ps1 b/tools/run-connected-world-lifecycle-gate.ps1 index 0e0b8923..b5db79d6 100644 --- a/tools/run-connected-world-lifecycle-gate.ps1 +++ b/tools/run-connected-world-lifecycle-gate.ps1 @@ -6,11 +6,15 @@ param( [string]$AceLogPath = 'C:\ACE\Server\ACE_Log.txt', [switch]$SkipBuild, [int]$SessionTimeoutSeconds = 420, - [int]$CollisionShadowEvery = 0 + [int]$CollisionShadowEvery = 0, + [ValidateSet('retail', 'low', 'medium', 'high', 'auto')] + [string]$RenderPackPreset = 'retail', + [hashtable]$RenderPackSettingOverrides = @{} ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'connected-render-pack-gate-common.ps1') if ([string]::IsNullOrWhiteSpace($Account)) { $Account = 'testaccount' } if ([string]::IsNullOrWhiteSpace($Password)) { $Password = 'testpassword' } @@ -231,7 +235,8 @@ function Invoke-Session( [string]$RoutePath, [bool]$Uncapped, [string[]]$ExpectedCheckpoints, - [string[]]$ExpectedScreenshots) + [string[]]$ExpectedScreenshots, + [hashtable]$ScreenshotStateOverrides = @{}) { $sessionDir = Join-Path $root $Label $artifactDir = Join-Path $sessionDir 'artifacts' @@ -294,6 +299,8 @@ function Invoke-Session( $client.Refresh() $processSample = [pscustomobject][ordered]@{ + ProcessId = $client.Id + StartTimeUtc = $client.StartTime.ToUniversalTime().ToString('O') WorkingSetMiB = [Math]::Round($client.WorkingSet64 / 1MB, 1) PrivateMiB = [Math]::Round($client.PrivateMemorySize64 / 1MB, 1) HandleCount = $client.HandleCount @@ -335,6 +342,18 @@ function Invoke-Session( $png = Join-Path $artifactDir "screenshots\$name.png" if (-not (Test-Png $png)) { $failures.Add("${Label}: missing or invalid screenshot '$png'") } } + foreach ($name in $ExpectedScreenshots) { + $expectedState = if ($ScreenshotStateOverrides.ContainsKey($name)) { + $ScreenshotStateOverrides[$name] + } + else { $renderPackGate } + Add-ConnectedRenderPackMetadataFailures ` + -ArtifactDirectory $artifactDir ` + -ScreenshotNames @($name) ` + -State $expectedState ` + -Failures $failures ` + -Label $Label + } $graceful = Close-ClientGracefully $client $client.Refresh() @@ -388,6 +407,128 @@ function Invoke-Session( } } +function Add-AtmosphericTransitionSemanticGates([object]$Session) { + if ($null -eq $Session) { return } + $screenshots = Join-Path $Session.ArtifactDirectory 'screenshots' + $rows = @{} + foreach ($name in @( + 'transition_selected_high', + 'transition_disabled_retail', + 'transition_reenabled_high', + 'transition_resized_high', + 'transition_dusk_high', + 'transition_overcast_high', + 'transition_rain_high')) { + $path = Join-Path $screenshots "$name.metadata.json" + if (Test-Path -LiteralPath $path) { + $rows[$name] = Get-Content -Raw -LiteralPath $path | ConvertFrom-Json + } + } + if ($rows.Count -ne 7) { return } + + $selected = $rows.transition_selected_high.RenderPack + $disabled = $rows.transition_disabled_retail.RenderPack + $reenabled = $rows.transition_reenabled_high.RenderPack + if ([long]$disabled.ActivationGeneration -le [long]$selected.ActivationGeneration -or + [long]$reenabled.ActivationGeneration -le [long]$disabled.ActivationGeneration) { + $failures.Add('atmospheric-transitions: select/disable/re-enable activation generations were not strictly monotonic') + } + if ([int]$selected.ShadowCasterCount -le 0) { + $failures.Add('atmospheric-transitions: dense outdoor High row published no directional-shadow casters') + } + if ([int]$selected.ShadowTransformChurn.LiveDynamicRootChanges -le 0) { + $failures.Add('atmospheric-transitions: moving High row published no live-dynamic caster transform') + } + if ([int]$selected.ShadowTransformChurn.EquippedChildChanges -le 0) { + $failures.Add('atmospheric-transitions: moving High row published no equipped-child caster transform') + } + $casterClasses = $selected.ShadowTransformChurn.CasterClasses + if ($null -eq $casterClasses) { + $failures.Add('atmospheric-transitions: High row published no authoritative caster-class diagnostics') + } + else { + foreach ($property in @( + 'TerrainCommands', + 'OutdoorStatics', + 'Buildings', + 'AnimatedStatics', + 'LocalPlayers', + 'RemotePlayers', + 'NonPlayerCreatures', + 'OtherLiveDynamics', + 'EquippedChildren')) { + if ($property -notin @($casterClasses.PSObject.Properties.Name)) { + $failures.Add( + "atmospheric-transitions: caster diagnostics omitted required '$property' metadata") + } + elseif ([int]$casterClasses.$property -lt 0) { + $failures.Add( + "atmospheric-transitions: caster diagnostics published negative '$property' metadata") + } + } + foreach ($required in @( + @('TerrainCommands', 'terrain command'), + @('OutdoorStatics', 'outdoor-static scenery'), + @('Buildings', 'building'), + @('LocalPlayers', 'local-player'), + @('EquippedChildren', 'equipped-child'))) { + $property = [string]$required[0] + if ([int]$casterClasses.$property -le 0) { + $failures.Add( + "atmospheric-transitions: dense outdoor High row published no $($required[1]) caster evidence") + } + } + } + + $resized = $rows.transition_resized_high + if ([int]$resized.Width -ne 1024 -or [int]$resized.Height -ne 768) { + $failures.Add( + "atmospheric-transitions: resized screenshot was $($resized.Width)x$($resized.Height), expected exact 1024x768 framebuffer") + } + $dusk = $rows.transition_dusk_high.RenderPack + if ([Math]::Abs( + [double]$dusk.SunElevationDegrees - + [double]$reenabled.SunElevationDegrees) -lt 0.01) { + $failures.Add('atmospheric-transitions: authored time change did not alter published sun elevation') + } + if ([string]$rows.transition_overcast_high.RenderPack.Weather -cnotmatch '(?i)^overcast$') { + $failures.Add('atmospheric-transitions: first weather edge did not publish Overcast') + } + if ([string]$rows.transition_rain_high.RenderPack.Weather -cnotmatch '(?i)^rain$') { + $failures.Add('atmospheric-transitions: second weather edge did not publish Rain') + } +} + +function Get-FreshContextRecreationGate( + [object]$FirstSession, + [object]$SecondSession) +{ + $definition = 'graceful full graphical-process teardown followed by a fresh process; the fresh process constructs a new Vulkan device/context/swapchain ownership graph' + if ($null -eq $FirstSession -or $null -eq $SecondSession) { + $failures.Add('new-context recreation could not be proven because a required session is missing') + return [pscustomobject][ordered]@{ + Definition = $definition + Passed = $false + FirstProcess = $null + SecondProcess = $null + } + } + $firstIdentity = "$($FirstSession.Process.ProcessId)@$($FirstSession.Process.StartTimeUtc)" + $secondIdentity = "$($SecondSession.Process.ProcessId)@$($SecondSession.Process.StartTimeUtc)" + $passed = $FirstSession.GracefulExit -and + $SecondSession.GracefulExit -and + $firstIdentity -cne $secondIdentity + if (-not $passed) { + $failures.Add('new-context recreation did not prove graceful teardown and a distinct fresh process') + } + return [pscustomobject][ordered]@{ + Definition = $definition + Passed = $passed + FirstProcess = $firstIdentity + SecondProcess = $secondIdentity + } +} + function Add-SameLocationGates([object]$CappedSession) { if ($null -eq $CappedSession) { return } $first = @($CappedSession.Checkpoints | Where-Object { $_.name -eq 'aerlinthe_first' }) | Select-Object -First 1 @@ -413,64 +554,136 @@ function Add-SameLocationGates([object]$CappedSession) { } } -if (@(Get-Process -Name AcDream.App -ErrorAction SilentlyContinue).Count -gt 0) { - throw 'an AcDream.App client is already running; close it gracefully before the gate' +$renderPackGate = New-ConnectedRenderPackGateState ` + -Root $root ` + -Preset $RenderPackPreset ` + -SettingOverrides $RenderPackSettingOverrides +try { + if (@(Get-Process -Name AcDream.App -ErrorAction SilentlyContinue).Count -gt 0) { + throw 'an AcDream.App client is already running; close it gracefully before the gate' + } + if (@(Get-NetUDPEndpoint -LocalPort 9000 -ErrorAction SilentlyContinue).Count -eq 0) { + throw 'local ACE is not listening on UDP port 9000' + } + if (-not (Test-Path -LiteralPath $AceLogPath)) { + throw "ACE log was not found: $AceLogPath" + } + + if (-not $SkipBuild) { + & dotnet build (Join-Path $Repository 'AcDream.slnx') -c Release --no-restore + if ($LASTEXITCODE -ne 0) { throw "Release build failed with exit code $LASTEXITCODE" } + } + if (-not (Test-Path -LiteralPath $exe)) { throw "client executable not found: $exe" } + + $binaryIdentity = Get-ConnectedGateBinaryIdentity ` + -Repository $Repository -Executable $exe -SkipBuild:$SkipBuild + + $capped = Invoke-Session ` + 'capped' ` + (Join-Path $Repository 'tools\connected-world-lifecycle.route.txt') ` + $false ` + @('capped_login', 'aerlinthe_first', 'rynthid', 'facility_hub', 'holtburg_after_dungeon', 'aerlinthe_revisit') ` + @('capped_login', 'aerlinthe_first', 'facility_hub', 'holtburg_after_dungeon', 'aerlinthe_revisit') + + Add-SameLocationGates $capped + + # The second process starts as soon as ACE records accepting the first + # process's transport Disconnect. No elapsed-time settle delay hides a + # shutdown race. + $uncapped = Invoke-Session ` + 'uncapped-reconnect' ` + (Join-Path $Repository 'tools\connected-world-reconnect.route.txt') ` + $true ` + @('uncapped_reconnect') ` + @('uncapped_reconnect') + + $contextRecreation = Get-FreshContextRecreationGate $capped $uncapped + + # The medium matrix row is the one canonical transition row. It starts + # from a known enhanced selection, then proves an in-process High select, + # retail disable, exact High re-enable, live resize, and authored + # sun/weather changes without multiplying this long route across all five + # preset rows. + $transitionSession = $null + if ($RenderPackPreset -eq 'medium') { + $highExpectation = Get-ConnectedRenderPackExpectation -Preset high + $retailExpectation = Get-ConnectedRenderPackExpectation -Preset retail + $transitionSession = Invoke-Session ` + 'atmospheric-transitions' ` + (Join-Path $Repository 'tools\connected-render-pack-transitions.route.txt') ` + $false ` + @('atmospheric_transitions') ` + @( + 'transition_selected_high', + 'transition_disabled_retail', + 'transition_reenabled_high', + 'transition_resized_high', + 'transition_dusk_high', + 'transition_overcast_high', + 'transition_rain_high') ` + @{ + transition_selected_high = $highExpectation + transition_disabled_retail = $retailExpectation + transition_reenabled_high = $highExpectation + transition_resized_high = $highExpectation + transition_dusk_high = $highExpectation + transition_overcast_high = $highExpectation + transition_rain_high = $highExpectation + } + Add-AtmosphericTransitionSemanticGates $transitionSession + } + + $report = [pscustomobject][ordered]@{ + Passed = $failures.Count -eq 0 + StartedUtc = $startedUtc.ToString('O') + FinishedUtc = [DateTime]::UtcNow.ToString('O') + Commit = $binaryIdentity.BinaryCommit + SourceCommit = $binaryIdentity.SourceCommit + BinaryProductVersion = $binaryIdentity.BinaryProductVersion + BinaryCommit = $binaryIdentity.BinaryCommit + BinaryMatchesSource = $binaryIdentity.BinaryMatchesSource + SkipBuild = $binaryIdentity.SkipBuild + SourceStatus = @(& git -C $Repository status --short) + SessionName = $env:SESSIONNAME + CollisionShadowEvery = $CollisionShadowEvery + RenderPackSelection = (Get-ConnectedRenderPackGateReport $renderPackGate) + ContextRecreation = $contextRecreation + TransitionAutomation = [pscustomobject][ordered]@{ + Executed = $null -ne $transitionSession + CanonicalRow = 'medium' + Session = $transitionSession + ProvenCasterRoutes = @( + 'terrain shadow-command publication', + 'outdoor-static scenery publication (including trees, without a tree discriminator)', + 'building caster publication', + 'local-player caster publication', + 'moving live-dynamic root transform publication', + 'equipped-child caster and moving-transform publication') + RemainingCasterClassEvidence = @( + 'a second live client is still required to prove a nonzero remote-player caster count', + 'a deterministic populated connected row is still required to prove nonzero active animated-static and non-player creature counts', + 'create-object render metadata proves non-player creature, not hostile monster versus non-hostile NPC', + 'outdoor DAT scenery has no authoritative tree discriminator, so trees remain grouped with other outdoor statics') + } + VideoControllers = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | + ForEach-Object { [pscustomobject]@{ + Name = $_.Name + DriverVersion = $_.DriverVersion + AdapterRam = $_.AdapterRAM + } }) + Failures = @($failures) + Warnings = @($warnings) + Sessions = @($sessions) + } + $report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $reportPath -Encoding utf8 + + Write-Output "REPORT=$reportPath" + Write-Output "RESULT=$(if ($report.Passed) { 'PASS' } else { 'FAIL' })" + foreach ($failure in $failures) { Write-Output "FAILURE=$failure" } + foreach ($warning in $warnings) { Write-Output "WARNING=$warning" } } -if (@(Get-NetUDPEndpoint -LocalPort 9000 -ErrorAction SilentlyContinue).Count -eq 0) { - throw 'local ACE is not listening on UDP port 9000' +finally { + Restore-ConnectedRenderPackGateEnvironment $renderPackGate } -if (-not (Test-Path -LiteralPath $AceLogPath)) { - throw "ACE log was not found: $AceLogPath" -} - -if (-not $SkipBuild) { - & dotnet build (Join-Path $Repository 'AcDream.slnx') -c Release --no-restore - if ($LASTEXITCODE -ne 0) { throw "Release build failed with exit code $LASTEXITCODE" } -} -if (-not (Test-Path -LiteralPath $exe)) { throw "client executable not found: $exe" } - -$capped = Invoke-Session ` - 'capped' ` - (Join-Path $Repository 'tools\connected-world-lifecycle.route.txt') ` - $false ` - @('capped_login', 'aerlinthe_first', 'rynthid', 'facility_hub', 'holtburg_after_dungeon', 'aerlinthe_revisit') ` - @('capped_login', 'aerlinthe_first', 'facility_hub', 'holtburg_after_dungeon', 'aerlinthe_revisit') - -Add-SameLocationGates $capped - -# The second process starts as soon as ACE records accepting the first -# process's transport Disconnect. No elapsed-time settle delay hides a -# shutdown race. -$null = Invoke-Session ` - 'uncapped-reconnect' ` - (Join-Path $Repository 'tools\connected-world-reconnect.route.txt') ` - $true ` - @('uncapped_reconnect') ` - @('uncapped_reconnect') - -$report = [pscustomobject][ordered]@{ - Passed = $failures.Count -eq 0 - StartedUtc = $startedUtc.ToString('O') - FinishedUtc = [DateTime]::UtcNow.ToString('O') - Commit = (& git -C $Repository rev-parse HEAD).Trim() - SourceStatus = @(& git -C $Repository status --short) - SessionName = $env:SESSIONNAME - CollisionShadowEvery = $CollisionShadowEvery - VideoControllers = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | - ForEach-Object { [pscustomobject]@{ - Name = $_.Name - DriverVersion = $_.DriverVersion - AdapterRam = $_.AdapterRAM - } }) - Failures = @($failures) - Warnings = @($warnings) - Sessions = @($sessions) -} -$report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $reportPath -Encoding utf8 - -Write-Output "REPORT=$reportPath" -Write-Output "RESULT=$(if ($report.Passed) { 'PASS' } else { 'FAIL' })" -foreach ($failure in $failures) { Write-Output "FAILURE=$failure" } -foreach ($warning in $warnings) { Write-Output "WARNING=$warning" } if ($failures.Count -gt 0) { exit 1 } diff --git a/tools/run-offline-pixel-gate.ps1 b/tools/run-offline-pixel-gate.ps1 index 30ac9a66..100e89c4 100644 --- a/tools/run-offline-pixel-gate.ps1 +++ b/tools/run-offline-pixel-gate.ps1 @@ -100,6 +100,23 @@ so a known, registered band (for instance AD-46's treeline) can be quantified separately from the rest of the frame. +.PARAMETER OrbitDistanceMeters + Diagnostic initial orbit-camera distance in metres. Zero keeps acdream's + normal default. + +.PARAMETER OrbitYawDegrees + Diagnostic initial orbit-camera heading in degrees. Omit to keep acdream's + normal default. + +.PARAMETER OrbitPitchDegrees + Diagnostic initial orbit-camera elevation in degrees. Omit to keep + acdream's normal default. A shallow positive angle can put a low sun in + frame for ray and volumetric-shaft captures. + +.PARAMETER Uncapped + Disable both VSync and the normal refresh-rate software limiter. Omit for + the capped product cadence. This is a diagnostic measurement mode only. + .PARAMETER SkipBuild Skip the Release build (use when the caller already built). @@ -122,6 +139,22 @@ param( [int]$Tolerance = 2, [double]$MaxDifferentFraction = 0.001, [int]$MaskTopPixels = 280, + [ValidateSet('retail', 'low', 'medium', 'high', 'auto')] + [string]$RenderPackPreset = 'retail', + [ValidatePattern('^[1-9][0-9]*x[1-9][0-9]*$')] + [string]$Resolution = '1280x720', + [ValidateRange(0.0, 5000.0)] + [double]$OrbitDistanceMeters = 0, + [Nullable[double]]$OrbitYawDegrees, + [ValidateRange(-89.0, 89.0)] + [Nullable[double]]$OrbitPitchDegrees, + [hashtable]$RenderPackSettingOverrides = @{}, + [ValidateRange(0, 2048)] + [int]$RequiredRenderPackSamples = 0, + [ValidateRange(1000, 600000)] + [int]$RenderPackSampleTimeoutMs = 300000, + [switch]$AllowSafeRenderPackFallback, + [switch]$Uncapped, [switch]$SkipBuild ) @@ -129,6 +162,11 @@ $ErrorActionPreference = 'Stop' $repo = Split-Path -Parent $PSScriptRoot $exe = Join-Path $repo 'src\AcDream.App\bin\Release\net10.0\AcDream.App.exe' $cli = Join-Path $repo 'src\AcDream.Cli\bin\Release\net10.0\AcDream.Cli.dll' +. (Join-Path $PSScriptRoot 'atmospheric-performance-matrix-common.ps1') + +if ($AllowSafeRenderPackFallback -and $RenderPackPreset -eq 'retail') { + throw '-AllowSafeRenderPackFallback is valid only for an explicitly selected enhanced preset.' +} function Write-Step($message) { Write-Host "[pixel-gate] $message" } @@ -144,27 +182,114 @@ if (-not (Test-Path $exe)) { throw "Client not found at $exe. Build Release firs if (Test-Path $Out) { Remove-Item -Recurse -Force $Out } New-Item -ItemType Directory -Force -Path $Out | Out-Null +# Every capture owns a complete disposable path set. This prevents an offline +# gate from inheriting or rewriting the user's real pack selection, settings, +# plugins, screenshots, or pipeline cache. +$state = Join-Path $Out 'isolated-state' +$config = Join-Path $state 'config' +$data = Join-Path $state 'data' +$cache = Join-Path $state 'cache' +New-Item -ItemType Directory -Force -Path $config, $data, $cache | Out-Null +$packId = if ($RenderPackPreset -eq 'retail') { 'retail' } else { 'acdream.atmospheric' } +$packVersion = if ($RenderPackPreset -eq 'retail') { $null } else { '1.0.0' } +$presetId = if ($RenderPackPreset -eq 'retail') { 'off' } else { $RenderPackPreset } +$orderedOverrides = [ordered]@{} +foreach ($key in @($RenderPackSettingOverrides.Keys | Sort-Object)) { + $orderedOverrides[$key] = [string]$RenderPackSettingOverrides[$key] +} +$settings = [ordered]@{ + display = [ordered]@{ + resolution = $Resolution + fullscreen = $false + vsync = $false + renderPack = [ordered]@{ + packId = $packId + packVersion = $packVersion + presetId = $presetId + settingOverrides = $orderedOverrides + } + } + version = 3 +} +$settings | ConvertTo-Json -Depth 8 | Set-Content -Encoding utf8 ` + -LiteralPath (Join-Path $config 'settings.json') + $probe = Join-Path $Out 'offline.probe.txt' # The script runner reads one command per line. A single settled capture is the # whole gate: a second stop would need camera movement, which offline has no # deterministic way to drive. -Set-Content -Encoding utf8 -Path $probe -Value @" -sleep $WarmupMs -screenshot world-offline 30000 -sleep 500 -"@ +$probeCommands = [Collections.Generic.List[string]]::new() +$probeCommands.Add("sleep $WarmupMs") +if ($RequiredRenderPackSamples -gt 0) { + # Explicit presets discard startup evidence after warmup. Auto deliberately + # owns a continuous rolling window for its hysteresis policy and rejects a + # diagnostic reset; after the same warmup we wait until its current stable + # resource generation contains one complete window. + if ($RenderPackPreset -ne 'auto') { + $probeCommands.Add('renderpack reset-performance') + } + $probeCommands.Add( + "wait render-pack-samples $RequiredRenderPackSamples $RenderPackSampleTimeoutMs") + if ($AllowSafeRenderPackFallback) { + # The screenshot controller reads the last completed swapchain image. + # A runtime Auto fallback can publish retail at the boundary that + # satisfies the wait while that completed image still belongs to the + # prior enhanced frame. Give the default path time to present fresh + # frames before pairing its pixels with the retail oracle. + $probeCommands.Add('sleep 2000') + } +} +$probeCommands.Add('screenshot world-offline 30000') +# Keep the process alive briefly after the PNG commit so this parent can record +# the matching process envelope, then ask the hidden client itself to execute +# the ordinary IWindow.Close shutdown path. Hidden windows intentionally have +# no MainWindowHandle, so WM_CLOSE cannot be the primary close mechanism. +$probeCommands.Add('sleep 4000') +$probeCommands.Add('close-client') +Set-Content -Encoding utf8 -Path $probe -Value $probeCommands $log = Join-Path $Out 'client.log' # --- 3. Launch offline -------------------------------------------------------- $previousLive = $env:ACDREAM_LIVE +$previousConfigDirectory = $env:ACDREAM_CONFIG_DIR +$previousDataDirectory = $env:ACDREAM_DATA_DIR +$previousCacheDirectory = $env:ACDREAM_CACHE_DIR +$previousOrbitDistance = $env:ACDREAM_ORBIT_DISTANCE_METERS +$previousOrbitYaw = $env:ACDREAM_ORBIT_YAW_DEGREES +$previousOrbitPitch = $env:ACDREAM_ORBIT_PITCH_DEGREES +$previousUncappedRender = $env:ACDREAM_UNCAPPED_RENDER +$previousExactFramebuffer = $env:ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER Remove-Item Env:\ACDREAM_LIVE -ErrorAction SilentlyContinue $env:ACDREAM_DAT_DIR = Join-Path $env:USERPROFILE "Documents\Asheron's Call" +$env:ACDREAM_CONFIG_DIR = $config +$env:ACDREAM_DATA_DIR = $data +$env:ACDREAM_CACHE_DIR = $cache $env:ACDREAM_NO_AUDIO = '1' $env:ACDREAM_RETAIL_UI = '1' $env:ACDREAM_DAY_GROUP = "$DayGroup" $env:ACDREAM_UI_PROBE_SCRIPT = $probe $env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $Out +$env:ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER = '1' +$env:ACDREAM_UNCAPPED_RENDER = if ($Uncapped) { '1' } else { $null } +if ($OrbitDistanceMeters -gt 0) { + $env:ACDREAM_ORBIT_DISTANCE_METERS = $OrbitDistanceMeters.ToString( + [System.Globalization.CultureInfo]::InvariantCulture) +} else { + Remove-Item Env:\ACDREAM_ORBIT_DISTANCE_METERS -ErrorAction SilentlyContinue +} +if ($null -ne $OrbitYawDegrees) { + $env:ACDREAM_ORBIT_YAW_DEGREES = ([double]$OrbitYawDegrees).ToString( + [System.Globalization.CultureInfo]::InvariantCulture) +} else { + Remove-Item Env:\ACDREAM_ORBIT_YAW_DEGREES -ErrorAction SilentlyContinue +} +if ($null -ne $OrbitPitchDegrees) { + $env:ACDREAM_ORBIT_PITCH_DEGREES = ([double]$OrbitPitchDegrees).ToString( + [System.Globalization.CultureInfo]::InvariantCulture) +} else { + Remove-Item Env:\ACDREAM_ORBIT_PITCH_DEGREES -ErrorAction SilentlyContinue +} # The determinism pins, forced rather than inherited. See .DESCRIPTION. $invariant = [System.Globalization.CultureInfo]::InvariantCulture @@ -173,13 +298,17 @@ $env:ACDREAM_SKY_PHASE_SECONDS = $SkyPhaseSeconds.ToString($invariant) if ($MsaaSamples -ge 0) { $env:ACDREAM_MSAA_SAMPLES = "$MsaaSamples" } else { Remove-Item Env:\ACDREAM_MSAA_SAMPLES -ErrorAction SilentlyContinue } -Write-Step "launching offline client (warmup ${WarmupMs}ms, day group $DayGroup, day fraction $WorldDayFraction, sky phase $SkyPhaseSeconds, MSAA $MsaaSamples)" +Write-Step "launching offline client (pack $packId/$presetId, $Resolution, $(if ($Uncapped) { 'uncapped' } else { 'capped' }), orbit ${OrbitDistanceMeters}m/$OrbitYawDegrees deg yaw/$OrbitPitchDegrees deg pitch, warmup ${WarmupMs}ms, day group $DayGroup, day fraction $WorldDayFraction, sky phase $SkyPhaseSeconds, MSAA $MsaaSamples)" $proc = Start-Process -FilePath $exe -RedirectStandardOutput $log ` - -RedirectStandardError "$log.err" -PassThru -WindowStyle Minimized + -RedirectStandardError "$log.err" -PassThru -WindowStyle Hidden try { $shots = Join-Path $Out 'screenshots' - $deadline = (Get-Date).AddMilliseconds($WarmupMs + 60000) + $sampleWaitMs = if ($RequiredRenderPackSamples -gt 0) { + $RenderPackSampleTimeoutMs + } else { 0 } + $deadline = (Get-Date).AddMilliseconds( + $WarmupMs + $sampleWaitMs + 60000) $captured = $false while ((Get-Date) -lt $deadline) { if ((Test-Path $shots) -and (Get-ChildItem $shots -Filter *.png -ErrorAction SilentlyContinue)) { @@ -196,27 +325,111 @@ try { } # Let the probe script finish its trailing sleep so the PNG is fully flushed. Start-Sleep -Milliseconds 1500 + $proc.Refresh() + [pscustomobject][ordered]@{ + SchemaVersion = 1 + CapturedUtc = [DateTime]::UtcNow.ToString('O') + WorkingSetBytes = [long]$proc.WorkingSet64 + PrivateMemoryBytes = [long]$proc.PrivateMemorySize64 + HandleCount = [int]$proc.HandleCount + ThreadCount = [int]$proc.Threads.Count + } | ConvertTo-Json -Depth 3 | Set-Content -Encoding utf8 ` + -LiteralPath (Join-Path $Out 'capture-process.json') } finally { - # Graceful close: WM_CLOSE runs the shutdown path, so the ownership ledger - # converges the way the lifecycle tests expect. No ACE session exists here, - # but keeping the habit means this script is safe to point at a live run too. - $app = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue - if ($app) { - $app.CloseMainWindow() | Out-Null - if (-not $app.WaitForExit(10000)) { - Write-Step 'WM_CLOSE timed out; forcing' - $app | Stop-Process -Force - } + # The probe's close-client verb runs acdream's normal IWindow.Close path. + # Wait for that exact process; never target another AcDream.App instance. + # Force is cleanup-only and makes the gate fail rather than disguising a + # broken ownership/shutdown path as a successful capture. + $shutdownFailure = $null + $proc.Refresh() + if (-not $proc.HasExited -and -not $proc.WaitForExit(15000)) { + $shutdownFailure = 'in-process automation close timed out' + Write-Step "$shutdownFailure; forcing exact capture process" + Stop-Process -Id $proc.Id -Force + $proc.WaitForExit() + } + if ($null -eq $shutdownFailure -and $proc.ExitCode -ne 0) { + $shutdownFailure = "client exited with code $($proc.ExitCode)" } Remove-Item Env:\ACDREAM_MSAA_SAMPLES -ErrorAction SilentlyContinue Remove-Item Env:\ACDREAM_WORLD_TIME -ErrorAction SilentlyContinue Remove-Item Env:\ACDREAM_SKY_PHASE_SECONDS -ErrorAction SilentlyContinue + if ($null -eq $previousOrbitDistance) { + Remove-Item Env:\ACDREAM_ORBIT_DISTANCE_METERS -ErrorAction SilentlyContinue + } else { $env:ACDREAM_ORBIT_DISTANCE_METERS = $previousOrbitDistance } + if ($null -eq $previousOrbitYaw) { + Remove-Item Env:\ACDREAM_ORBIT_YAW_DEGREES -ErrorAction SilentlyContinue + } else { $env:ACDREAM_ORBIT_YAW_DEGREES = $previousOrbitYaw } + if ($null -eq $previousOrbitPitch) { + Remove-Item Env:\ACDREAM_ORBIT_PITCH_DEGREES -ErrorAction SilentlyContinue + } else { $env:ACDREAM_ORBIT_PITCH_DEGREES = $previousOrbitPitch } + if ($null -eq $previousUncappedRender) { + Remove-Item Env:\ACDREAM_UNCAPPED_RENDER -ErrorAction SilentlyContinue + } else { $env:ACDREAM_UNCAPPED_RENDER = $previousUncappedRender } + if ($null -eq $previousExactFramebuffer) { + Remove-Item Env:\ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER -ErrorAction SilentlyContinue + } else { + $env:ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER = $previousExactFramebuffer + } if ($previousLive) { $env:ACDREAM_LIVE = $previousLive } + if ($null -eq $previousConfigDirectory) { + Remove-Item Env:\ACDREAM_CONFIG_DIR -ErrorAction SilentlyContinue + } else { $env:ACDREAM_CONFIG_DIR = $previousConfigDirectory } + if ($null -eq $previousDataDirectory) { + Remove-Item Env:\ACDREAM_DATA_DIR -ErrorAction SilentlyContinue + } else { $env:ACDREAM_DATA_DIR = $previousDataDirectory } + if ($null -eq $previousCacheDirectory) { + Remove-Item Env:\ACDREAM_CACHE_DIR -ErrorAction SilentlyContinue + } else { $env:ACDREAM_CACHE_DIR = $previousCacheDirectory } + if ($null -ne $shutdownFailure) { + throw $shutdownFailure + } } -$captures = Get-ChildItem (Join-Path $Out 'screenshots') -Filter *.png +$captures = @(Get-ChildItem (Join-Path $Out 'screenshots') -Filter *.png) Write-Step "captured $($captures.Count) screenshot(s) into $Out" +$metadataPath = Join-Path $Out 'screenshots\world-offline.metadata.json' +if (-not (Test-Path -LiteralPath $metadataPath)) { + throw "Render-pack screenshot metadata is missing at '$metadataPath'." +} +$metadata = Get-Content -Raw -LiteralPath $metadataPath | ConvertFrom-Json +if ($AllowSafeRenderPackFallback) { + $dimensions = $Resolution.Split('x') + $evidence = Test-AtmosphericPerformanceMetadataEvidence ` + -MetadataPath $metadataPath ` + -Preset $RenderPackPreset ` + -ExpectedWidth ([int]$dimensions[0]) ` + -ExpectedHeight ([int]$dimensions[1]) ` + -AllowSafeFallback + if (-not $evidence.Passed) { + throw ('Capture is neither an active complete evidence window nor a safe ' + + 'resource/capability fallback: ' + (@($evidence.Failures) -join '; ')) + } + Write-Step ("render-pack capture outcome: {0}{1}" -f ` + $evidence.Outcome, + $(if ($evidence.Outcome -eq 'Unavailable') { + " ($($evidence.UnavailableClassification): $($evidence.FailureReason))" + } else { '' })) +} +else { + if (($metadata.RenderPack.PackId -ne $packId) -or + ($metadata.RenderPack.PresetId -ne $presetId)) { + throw ("Capture selected {0}/{1}, expected {2}/{3}. Failure: {4}" -f ` + $metadata.RenderPack.PackId, + $metadata.RenderPack.PresetId, + $packId, + $presetId, + $metadata.RenderPack.FailureReason) + } + $expectedState = if ($RenderPackPreset -eq 'retail') { 0 } else { 2 } + if ([int]$metadata.RenderPack.State -ne $expectedState) { + throw ("Capture render-pack state was {0}, expected {1}. Failure: {2}" -f ` + $metadata.RenderPack.State, + $expectedState, + $metadata.RenderPack.FailureReason) + } +} # The offline window is minimised but still focusable, so a stray scroll or key # press from whoever is at the keyboard can move the camera mid-capture. That