diff --git a/AGENTS.md b/AGENTS.md index cd1b5e55..7e8e0a36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,3 +3,16 @@ ## C++ naming - Never use `b` as a prefix or suffix to denote a `bool`. Prefer concise names that are clear from their context, such as `Cancelled` or `Completed`; use predicates such as `IsVisible`, `HasFocus`, `CanRender`, or `ShouldUpdate` when needed for clarity. + +## C++ documentation + +- Write Doxygen documentation in English for public types, public members, and public functions. +- Follow ISO 24495-1 plain-language principles: state the purpose first, use short direct sentences, and use familiar terms. +- Use `@param`, `@return`, `@tparam`, and `@pre` only when they add information needed to use the API correctly. +- Document public data members when their purpose, unit, ownership, valid range, or relationship to another member is not immediately clear. +- Use a short single-sentence comment for private methods. Document private data only when it is necessary to explain an invariant or a non-obvious relationship. + +## Validation + +- This project targets macOS and Windows. Validate changes for Windows compilation and linking implications even when only macOS is available locally; the CI runs tests on Windows. +- When a public API crosses a shared-library boundary, verify that its out-of-line symbols use `ELIXIR_API` where Windows DLL export and import require it. diff --git a/Assets/.gitignore b/Assets/.gitignore new file mode 100644 index 00000000..99af8207 --- /dev/null +++ b/Assets/.gitignore @@ -0,0 +1,3 @@ +/Meshes + +*.hdr \ No newline at end of file diff --git a/Assets/VFX/FireAndFireworks.json b/Assets/VFX/FireAndFireworks.json index 0470d496..f92f2bc8 100644 --- a/Assets/VFX/FireAndFireworks.json +++ b/Assets/VFX/FireAndFireworks.json @@ -110,7 +110,7 @@ { "name": "FlameCore", "renderMode": "Sprite", - "spriteTexture": "Assets/Textures/SoftGlow.png", + "material": { "color": "$flame_start", "emissive": [1.25, 0.15, 0.01, 0.0], "texture": "Assets/Textures/SoftGlow.png" }, "maxParticles": 2200, "spawnRate": "$spawn_rate", "parameters": { @@ -138,7 +138,7 @@ { "name": "EmberSparks", "renderMode": "Sprite", - "spriteTexture": "Assets/Textures/SoftGlow.png", + "material": { "color": "$ember_start", "emissive": [0.90, 0.08, 0.01, 0.0], "texture": "Assets/Textures/SoftGlow.png" }, "maxParticles": 1200, "spawnRate": "$spawn_rate", "parameters": { @@ -166,7 +166,7 @@ { "name": "SmokePlume", "renderMode": "Sprite", - "spriteTexture": "Assets/Textures/SoftGlow.png", + "material": { "color": "$smoke_start", "texture": "Assets/Textures/SoftGlow.png" }, "maxParticles": 1000, "spawnRate": "$spawn_rate", "parameters": { @@ -193,7 +193,7 @@ { "name": "VortexSprites", "renderMode": "Sprite", - "spriteTexture": "Assets/Textures/SoftGlow.png", + "material": { "color": [0.88, 0.54, 1.00, 0.78], "emissive": [0.40, 0.10, 0.60, 0.0], "texture": "Assets/Textures/SoftGlow.png" }, "maxParticles": 1800, "spawnRate": "$spawn_rate", "parameters": { @@ -223,7 +223,7 @@ { "name": "RocketOrange", "renderMode": "Sprite", - "spriteTexture": "Assets/Textures/SoftGlow.png", + "material": { "color": "$trail_start", "emissive": [0.80, 0.15, 0.02, 0.0], "texture": "Assets/Textures/SoftGlow.png" }, "maxParticles": 220, "spawnRate": "$spawn_rate", "burst": { "count": 14, "interval": 1.25 }, @@ -252,7 +252,7 @@ { "name": "RocketBlue", "renderMode": "Sprite", - "spriteTexture": "Assets/Textures/SoftGlow.png", + "material": { "color": "$trail_start", "emissive": [0.05, 0.25, 1.00, 0.0], "texture": "Assets/Textures/SoftGlow.png" }, "maxParticles": 220, "spawnRate": "$spawn_rate", "burst": { "count": 14, "interval": 1.55 }, @@ -281,7 +281,7 @@ { "name": "RocketGold", "renderMode": "Sprite", - "spriteTexture": "Assets/Textures/SoftGlow.png", + "material": { "color": "$trail_start", "emissive": [0.80, 0.45, 0.05, 0.0], "texture": "Assets/Textures/SoftGlow.png" }, "maxParticles": 240, "spawnRate": "$spawn_rate", "burst": { "count": 16, "interval": 1.85 }, @@ -310,7 +310,7 @@ { "name": "BurstOrange", "renderMode": "Sprite", - "spriteTexture": "Assets/Textures/SoftGlow.png", + "material": { "color": "$burst_a_start", "emissive": [0.80, 0.05, 0.01, 0.0], "texture": "Assets/Textures/SoftGlow.png" }, "maxParticles": 1800, "spawnRate": "$spawn_rate", "trigger": { "source": "RocketOrange", "delay": 1.18 }, @@ -340,7 +340,7 @@ { "name": "BurstBlue", "renderMode": "Sprite", - "spriteTexture": "Assets/Textures/SoftGlow.png", + "material": { "color": "$burst_b_start", "emissive": [0.05, 0.30, 1.00, 0.0], "texture": "Assets/Textures/SoftGlow.png" }, "maxParticles": 1800, "spawnRate": "$spawn_rate", "trigger": { "source": "RocketBlue", "delay": 1.12 }, @@ -370,7 +370,7 @@ { "name": "BurstGold", "renderMode": "Sprite", - "spriteTexture": "Assets/Textures/SoftGlow.png", + "material": { "color": "$burst_c_start", "emissive": [1.00, 0.50, 0.05, 0.0], "texture": "Assets/Textures/SoftGlow.png" }, "maxParticles": 2000, "spawnRate": "$spawn_rate", "trigger": { "source": "RocketGold", "delay": 1.18 }, diff --git a/Assets/VFX/RainStorm.json b/Assets/VFX/RainStorm.json index 31d37580..b00b5c4d 100644 --- a/Assets/VFX/RainStorm.json +++ b/Assets/VFX/RainStorm.json @@ -37,7 +37,7 @@ { "name": "RainDrops", "renderMode": "Sprite", - "spriteTexture": "./Assets/Textures/RainDrop.png", + "material": { "color": "$rain_drop_start", "texture": "./Assets/Textures/RainDrop.png" }, "maxParticles": 5600, "spawnRate": "$spawn_rate", "parameters": { @@ -67,7 +67,7 @@ { "name": "GroundMist", "renderMode": "Sprite", - "spriteTexture": "./Assets/Textures/RainDrop.png", + "material": { "color": "$mist_start", "texture": "./Assets/Textures/RainDrop.png" }, "maxParticles": 640, "spawnRate": "$spawn_rate", "parameters": { diff --git a/Assets/VFX/RibbonGarden.json b/Assets/VFX/RibbonGarden.json index bb8ae9a5..1c21bd69 100644 --- a/Assets/VFX/RibbonGarden.json +++ b/Assets/VFX/RibbonGarden.json @@ -38,6 +38,7 @@ { "name": "PathRibbon", "renderMode": "Ribbon", + "material": { "color": "$canopy_color_start", "emissive": [0.10, 0.25, 0.50, 0.0] }, "maxParticles": 256, "spawnRate": "$spawn_rate", "parameters": { @@ -66,6 +67,7 @@ { "name": "PurpleFountain", "renderMode": "Sprite", + "material": { "color": "$spark_color_start", "emissive": [0.40, 0.05, 0.60, 0.0] }, "maxParticles": 1600, "spawnRate": "$spawn_rate", "parameters": { @@ -96,6 +98,7 @@ { "name": "CrystalShards", "renderMode": "Mesh", + "material": { "color": [0.86, 0.94, 1.0, 0.62] }, "maxParticles": 220, "spawnRate": "$spawn_rate", "parameters": { diff --git a/Assets/VFX/RibbonVortex.json b/Assets/VFX/RibbonVortex.json index 39939b67..18599546 100644 --- a/Assets/VFX/RibbonVortex.json +++ b/Assets/VFX/RibbonVortex.json @@ -38,6 +38,7 @@ { "name": "PathRibbon", "renderMode": "Ribbon", + "material": { "color": "$canopy_color_start", "emissive": [0.10, 0.25, 0.50, 0.0] }, "maxParticles": 256, "spawnRate": "$spawn_rate", "parameters": { @@ -108,6 +109,7 @@ { "name": "PurpleFountain", "renderMode": "Sprite", + "material": { "color": "$spark_color_start", "emissive": [0.40, 0.05, 0.60, 0.0] }, "maxParticles": 1600, "spawnRate": "$spawn_rate", "parameters": { @@ -189,6 +191,7 @@ { "name": "CrystalShards", "renderMode": "Mesh", + "material": { "color": [0.86, 0.94, 1.0, 0.62] }, "maxParticles": 220, "spawnRate": "$spawn_rate", "parameters": { @@ -274,4 +277,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index 4993bfc6..5b450d6f 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -1,16 +1,26 @@ #include "Dissolve.h" +#include "Engine/Materials/Nodes/Parameter.h" + #include #include -#include +#include + +#include +#include +#include +#include +#include +#include +#include -#include "Engine/Aether/Effect.h" +using namespace Elixir::Materials::Nodes; Ref pipeline; -Scope m_ParticlesRenderer; -Aether::FrameSubmission m_ParticleFrameSubmission; std::array, 2> m_ParticleSystems; -std::array, 2> m_ParticleSystemInstances; +std::array, 2> m_ParticleSystemInstances; + +Ref graphMaterial; Dissolve::Dissolve() { @@ -57,93 +67,149 @@ Dissolve::Dissolve() shader->BindConstantBuffer("cbFrame", m_FrameConstantBuffer); - m_ParticlesRenderer = CreateScope(m_GraphicsContext.get(), m_ShaderLoader.get()); - - m_ParticleSystems[0] = Aether::LoadEffectFile("./Assets/VFX/FireAndFireworks.json"); - m_ParticleSystems[1] = Aether::LoadEffectFile("./Assets/VFX/RibbonVortex.json"); - // m_ParticleSystem = CreateScope("Ribbon Garden"); - // m_ParticleSystem->GetParameters().SetFloat("GravityScale", 1.0f); - // - // auto& canopy = m_ParticleSystem->AddEmitter("CanopyMist", 4096, 160.0f); - // canopy.AddSpawnModule(glm::vec3{ 0.0f, -0.86f, 0.0f }, 0.18f); - // canopy.AddSpawnModule(glm::vec3{ 0.0f, 1.0f, 0.0f }, 0.62f, 0.24f, 0.56f); - // canopy.AddSpawnModule(3.8f, 6.2f); - // canopy.AddSpawnModule(14.0f, 28.0f); - // canopy.AddSpawnModule(0.72f, 1.08f); - // canopy.AddSpawnModule(glm::vec4{ 0.70f, 0.96f, 1.0f, 0.72f }); - // - // canopy.AddUpdateModule(glm::vec3{ 0.0f, -0.10f, 0.0f }); - // canopy.AddUpdateModule(0.04f); - // canopy.AddUpdateModule(glm::vec4{ 0.70f, 0.96f, 1.0f, 0.78f }, glm::vec4{ 0.4f, 0.18f, 0.72f, 0.0f }); - // canopy.AddUpdateModule(28.0f, 6.0f); - // canopy.AddUpdateModule(glm::vec3{ -1.45f, -1.2f, -2.0f }, glm::vec3{ 1.45f, 1.35f, 2.0f }); - // - // auto& sparks = m_ParticleSystem->AddEmitter("RoseSparks", 2048, 120.0f); - // sparks.AddSpawnModule(glm::vec3{ 0.0f, -0.8f, 0.0f }, 0.08f); - // sparks.AddSpawnModule(glm::vec3{ 0.0f, 1.0f, 0.0f }, 0.96f, 0.36f, 0.84f); - // sparks.AddSpawnModule(1.8f, 3.0f); - // sparks.AddSpawnModule(8.0f, 16.0f); - // sparks.AddSpawnModule(glm::vec4{ 1.0f, 0.68f, 0.88f, 0.95f }); - // - // sparks.AddUpdateModule(glm::vec3{ 0.0f, -0.28f, 0.0f }); - // sparks.AddUpdateModule(0.08f); - // sparks.AddUpdateModule(glm::vec4{ 1.0f, 0.72f, 0.9f, 0.95f }, glm::vec4{ 1.0f, 0.36f, 0.48f, 0.0f }); - // sparks.AddUpdateModule(16.0f, 2.5f); - // sparks.AddUpdateModule(glm::vec3{ -1.45f, -1.2f, -2.0f }, glm::vec3{ 1.45f, 1.35f, 2.0f }); - // - // auto& sparks2 = m_ParticleSystem->AddEmitter("GreenSparks", 2048, 120.0f); - // sparks2.AddSpawnModule(glm::vec3{ 0.0f, -0.8f, 0.0f }, 0.08f); - // sparks2.AddSpawnModule(glm::vec3{ 0.0f, 1.0f, 0.0f }, 0.96f, 0.36f, 0.84f); - // sparks2.AddSpawnModule(1.8f, 3.0f); - // sparks2.AddSpawnModule(8.0f, 16.0f); - // sparks2.AddSpawnModule(0.9f, 1.35f); - // sparks2.AddSpawnModule(glm::vec4{ 1.0f, 0.68f, 0.88f, 0.95f }); - // - // sparks2.AddUpdateModule(glm::vec3{ 0.0f, -0.28f, 0.0f }); - // sparks2.AddUpdateModule(0.08f); - // sparks2.AddUpdateModule(glm::vec4{ 0.0f, 0.73f, 0.12f, 0.8f }, glm::vec4{ 0.0f, 0.73f, 0.12f, 0.0f }); - // sparks2.AddUpdateModule(16.0f, 2.5f); - // sparks2.AddUpdateModule(1.0f, 0.35f); - // sparks2.AddUpdateModule(glm::vec3{ -1.45f, -1.2f, -2.0f }, glm::vec3{ 1.45f, 1.35f, 2.0f }); - // - // constexpr uint32_t ribbonParticles = 256; - // constexpr float ribbonLifetime = 4.0f; - // constexpr float ribbonSpawnRate = (float)ribbonParticles / ribbonLifetime; - // - // auto& ribbon = m_ParticleSystem->AddEmitter("AuroraRibbon", ribbonParticles, ribbonSpawnRate); - // ribbon.SetRenderMode(Aether::EParticleRenderMode::Ribbon); - // // ribbon.AddSpawnModule( - // // glm::vec3{ 0.0f, -0.25f, 0.0f }, - // // glm::vec3{ 1.05f, 0.38f, 0.28f }, - // // glm::vec3{ 0.32f, 0.16f, 0.18f }, - // // 1.15f - // // ); - // ribbon.AddSpawnModule(glm::vec3{ 0.0f, 0.0f, 0.0f }, 2.0f, 1.0f); - // ribbon.AddSpawnModule(ribbonLifetime, ribbonLifetime); - // ribbon.AddSpawnModule(8.0f, 8.0f); - // ribbon.AddSpawnModule(glm::vec4{ 0.55f, 0.92f, 1.0f, 0.95f }); - // - // ribbon.AddUpdateModule(glm::vec4{ 0.55f, 0.92f, 1.0f, 0.95f }, glm::vec4{ 0.78f, 0.36f, 1.0f, 0.0f }); - // - // auto& shards = m_ParticleSystem->AddEmitter("CrystalShards", 220, 26.0f); - // shards.SetRenderMode(Aether::EParticleRenderMode::Mesh); - // shards.AddSpawnModule(glm::vec3{ 0.0f, -0.64f, 0.0f }, 0.22f); - // shards.AddSpawnModule(glm::vec3{ 0.94f }, 2.20f, 0.14f, 0.36f); - // shards.AddSpawnModule(2.6f, 3.8f); - // shards.AddSpawnModule(10.0f, 18.0f); - // shards.AddSpawnModule(0.82f, 1.45f); - // shards.AddSpawnModule(0.0f, 6.28318530718f); - // shards.AddSpawnModule(glm::vec4{ 0.86f, 0.94f, 1.0f, 0.62f }); - // - // shards.AddUpdateModule(glm::vec3{ 0.0f, -0.28f, 0.0f }); - // shards.AddUpdateModule(0.03f); - // shards.AddUpdateModule(1.2f); - // shards.AddUpdateModule(18.0f, 3.0f); - // shards.AddUpdateModule(1.15f, 0.28f); - // shards.AddUpdateModule(glm::vec3{ -1.45f, -1.2f, -1.45f }, glm::vec3{ 1.45f, 1.35f, 1.45f }); - - m_ParticleSystemInstances[0] = CreateScope(CreateRef(m_ParticleSystems[0]->Compile())); - m_ParticleSystemInstances[1] = CreateScope(CreateRef(m_ParticleSystems[1]->Compile())); + m_ParticleSystems[0] = GetAetherManager().LoadEffect("./Assets/VFX/FireAndFireworks.json"); + EE_CORE_ASSERT( + m_ParticleSystems[0], + "Could not resolve FireAndFireworks effect." + ) + + m_ParticleSystems[1] = GetAetherManager().LoadEffect("./Assets/VFX/RibbonVortex.json"); + EE_CORE_ASSERT( + m_ParticleSystems[1], + "Could not resolve RibbonVortex effect." + ) + + { + MaterialGraph graph; + + graphMaterial = CreateRef("DissolveGraph"); + EE_CORE_ASSERT( + graphMaterial->SetUsage(EMaterialUsage::ParticleSprite, true), + "Dissolve graph material must enable ParticleSprite usage." + ) + + EE_CORE_ASSERT(graphMaterial->DefineParameter("Tint", { + .Kind = EMaterialParameterKind::Value, + .ValueType = EMaterialValueType::Float4, + .DefaultValue = SMaterialParameter::MakeVector({ 1.0f, 0.5f, 0.2f, 1.0f }), + }), "") + + EE_CORE_ASSERT(graphMaterial->DefineParameter("Albedo", { + .Kind = EMaterialParameterKind::Texture, + .DefaultValue = SMaterialParameter::MakeTexture(tex), + }), "") + + const auto albedo = graph.AddNode("Albedo"); + const auto tint = graph.AddNode("Tint", EMaterialValueType::Float4); + + const auto baseColor = graph.AddNode(); + graph.Connect(albedo, baseColor, 0); + graph.Connect(tint, baseColor, 0); + + graph.SetChannel(EMaterialChannel::BaseColor, baseColor); + + graphMaterial->SetGraph(std::move(graph)); + EE_CORE_ASSERT(GetMaterialRegistry().Register(graphMaterial), "GraphMaterial must be unique.") + + auto instance = graphMaterial->CreateInstance(); + EE_CORE_ASSERT( + instance->SetVector("Tint", { 1.0f, 0.35f, 0.1f, 1.0f }), + "Dissolve graph material tint override must match its schema." + ) + + if (auto* emitter = m_ParticleSystems[0]->FindEmitter("FlameCore")) + { + emitter->SetMaterial(instance); + EE_CORE_INFO("Published graph material to the FlameCore particle emitter.") + } + else + { + EE_CORE_ERROR("Dissolve particle emitter 'FlameCore' was not found.") + } + } + + { + MaterialGraph graph1; + + const auto ribbonMaterial = CreateRef("RibbonEnergy"); + EE_CORE_ASSERT( + ribbonMaterial->SetUsage(EMaterialUsage::ParticleRibbon, true), + "Ribbon material must enable ParticleRibbon usage." + ) + EE_CORE_ASSERT( + ribbonMaterial->SetUsage(EMaterialUsage::ParticleMesh, true), + "Ribbon material must enable ParticleRibbon usage." + ) + + EE_CORE_ASSERT(ribbonMaterial->DefineParameter("Tint", { + .Kind = EMaterialParameterKind::Value, + .ValueType = EMaterialValueType::Float4, + .DefaultValue = SMaterialParameter::MakeVector({ 0.2f, 0.5f, 1.0f, 1.0f }), + }), "") + + EE_CORE_ASSERT(ribbonMaterial->DefineParameter("Glow", { + .Kind = EMaterialParameterKind::Value, + .ValueType = EMaterialValueType::Float4, + .DefaultValue = SMaterialParameter::MakeVector({ 0.05f, 0.2f, 1.0f, 1.0f }), + }), "") + + EE_CORE_ASSERT(ribbonMaterial->DefineParameter("Albedo", { + .Kind = EMaterialParameterKind::Texture, + .DefaultValue = SMaterialParameter::MakeTexture(tex), + }), "") + + const auto panner = graph1.AddNode(glm::vec2{ 0.08f, -0.35f }); + + const auto albedo1 = graph1.AddNode("Albedo"); + graph1.Connect(panner, albedo1, 0); + + const auto tint1 = graph1.AddNode( + "Tint", + EMaterialValueType::Float4 + ); + + const auto multiply = graph1.AddNode(); + graph1.Connect(albedo1, multiply, 0); + graph1.Connect(tint1, multiply, 1); + graph1.SetChannel(EMaterialChannel::BaseColor, albedo1); + + // const auto glow = graph1.AddNode("Glow", EMaterialValueType::Float4); + //graph1.SetChannel(EMaterialChannel::Emissive, glow); + + ribbonMaterial->SetGraph(std::move(graph1)); + EE_CORE_ASSERT(GetMaterialRegistry().Register(ribbonMaterial), "RibbonEnergy must be unique.") + + const auto instance = ribbonMaterial->CreateInstance(); + EE_CORE_ASSERT( + instance->SetVector("Tint", { 0.15f, 0.6f, 1.0f, 1.0f }), + "Ribbon tint override must match the schema." + ) + + if (auto* emitter = m_ParticleSystems[1]->FindEmitter("PathRibbon")) + { + emitter->SetMaterial(instance); + EE_CORE_INFO("Published graph material to the PathRibbon particle emitter.") + } + + if (auto* emitter = m_ParticleSystems[1]->FindEmitter("CrystalShards")) + { + emitter->SetMaterial(instance); + EE_CORE_INFO("Published graph material to the CrystalShards particle emitter.") + } + } + + m_ParticleSystemInstances[0] = m_ParticleSystems[0]->CreateInstance(); + m_ParticleSystemInstances[1] = m_ParticleSystems[1]->CreateInstance(); + EE_CORE_ASSERT(m_ParticleSystemInstances[0], "Could not create FireAndFireworks instance.") + EE_CORE_ASSERT(m_ParticleSystemInstances[1], "Could not create RibbonVortex instance.") + + auto& aether = GetAetherManager(); + + bool submitted = aether.Add(m_ParticleSystemInstances[0]); + EE_CORE_ASSERT(submitted, "The particle system instance was submitted more than once.") + + submitted = aether.Add(m_ParticleSystemInstances[1]); + EE_CORE_ASSERT(submitted, "The particle system instance was submitted more than once.") m_GraphicsContext->SetClearColor({ 0.015f, 0.025f, 0.06f, 1.0f }); } @@ -159,31 +225,31 @@ void Dissolve::OnGUI(const Timestep frameTime) Application::OnGUI(frameTime); } -void Dissolve::OnRender(const Timestep frameTime) +void Dissolve::Prepare(const Timestep frameTime) +{ + EE_PROFILE_ZONE_SCOPED() + Application::Prepare(frameTime); +} + +void Dissolve::Render(const Timestep frameTime) { EE_PROFILE_ZONE_SCOPED() - Application::OnRender(frameTime); + Application::Render(frameTime); m_CameraController->Update(frameTime); m_FrameData.ViewProj = m_CameraController->GetCamera().GetViewProjectionMatrix(); m_FrameConstantBuffer->UpdateData(&m_FrameData, sizeof(SFrameData)); - m_ParticlesRenderer->Update(frameTime); + auto& aether = GetAetherManager(); + aether.BeginFrame(frameTime); m_GraphicsContext->Clear(); //DrawGeometry(); - m_ParticleFrameSubmission.Reset(); - - bool submitted = m_ParticleFrameSubmission.Submit(*m_ParticleSystemInstances[0]); - EE_CORE_ASSERT(submitted, "The particle system instance was submitted more than once.") - - submitted = m_ParticleFrameSubmission.Submit(*m_ParticleSystemInstances[1]); - EE_CORE_ASSERT(submitted, "The particle system instance was submitted more than once.") - - m_ParticlesRenderer->Render(m_ParticleFrameSubmission, m_CameraController->GetCamera()); - const auto& metrics = m_ParticlesRenderer->GetLastSubmissionMetrics(); + aether.Render(m_CameraController->GetCamera()); + const auto& simulationMetrics = aether.GetLastSimulationMetrics(); + const auto& renderMetrics = aether.GetLastRenderingMetrics(); } void Dissolve::OnEvent(Event& event) @@ -231,4 +297,4 @@ Application* Elixir::CreateApplication() { EE_PROFILE_ZONE_SCOPED() return new Dissolve(); -} \ No newline at end of file +} diff --git a/Dissolve/Source/Dissolve.h b/Dissolve/Source/Dissolve.h index b019ac2b..d7c3c3e7 100644 --- a/Dissolve/Source/Dissolve.h +++ b/Dissolve/Source/Dissolve.h @@ -14,7 +14,8 @@ class Dissolve final : public Elixir::Application ~Dissolve() override; void OnGUI(Timestep frameTime) override; - void OnRender(Timestep frameTime) override; + void Prepare(Timestep frameTime) override; + void Render(Timestep frameTime) override; void OnEvent(Event& event) override; diff --git a/Documentation/Plans/MaterialSystem.md b/Documentation/Plans/MaterialSystem.md new file mode 100644 index 00000000..484ebf7a --- /dev/null +++ b/Documentation/Plans/MaterialSystem.md @@ -0,0 +1,537 @@ +# MaterialSystem: plano de arquitetura e implementação + +## Objetivo + +Transformar `MaterialSystem` no ponto central de toda renderização baseada em +materiais da aplicação. Aether, meshes e futuros produtores devem descrever o +que precisa ser desenhado, mas não podem preparar nem executar materiais por +conta própria. + +`MaterialSystem` é uma instância única, criada e mantida por `Application`. O +estado preparado do frame deve permanecer dentro do sistema e não deve ser +retornado para outro componente transportar até uma chamada posterior de +renderização. + +Este plano não cria outro renderer de materiais. O +`Elixir::Materials::Rendering::Renderer` existente continua sendo o executor +interno responsável por resolver materiais e preparar shaders, bindings e +pipelines. + +## Problemas do desenho atual + +O fluxo atual permite que `Aether::Rendering::Renderer`: + +1. construa uma `MaterialRenderScene` exclusiva para partículas; +2. chame `MaterialSystem::PrepareFrame`; +3. inicie o escopo de renderização; +4. chame `MaterialSystem::Render`; +5. finalize o escopo de renderização. + +Esse desenho impede que o mesmo frame de materiais represente conjuntamente +partículas, meshes e outros produtores. Também expõe detalhes que deveriam ser +internos, como a separação entre preparação e execução e a necessidade de +associá-las por um `submissionSerial`. + +Se meshes seguirem o mesmo padrão, cada subsistema preparará sua própria tabela +de materiais, controlará sua própria execução e chamará o sistema em momentos +diferentes. Isso elimina a possibilidade de deduplicação global, batching entre +produtores e uma ordenação central dos passes. + +## Decisões arquiteturais + +### Ownership + +- `Application` possui uma única instância de `MaterialSystem`. +- `MaterialSystem` possui o estado de materiais de cada frame em voo. +- `MaterialSystem` possui o `Materials::Rendering::Renderer` existente. +- Aether, meshes e outros subsistemas são produtores de submissões. +- Produtores não controlam o ciclo de vida do frame de materiais. +- Produtores não chamam preparação, batching ou execução de materiais. + +### Responsabilidades do MaterialSystem + +`MaterialSystem` deve: + +- iniciar a coleta de um frame; +- receber draws baseados em materiais de todos os produtores; +- resolver instâncias de materiais em representações próprias para rendering; +- deduplicar materiais e texturas entre todos os draws do frame; +- construir e enviar a `FrameTable`; +- agrupar e ordenar draws por pass, geometria e programa; +- delegar a preparação de shaders, bindings e pipelines ao renderer interno; +- gravar ou coordenar a gravação dos draw commands; +- manter o estado preparado privado; +- expor somente métricas e resultados, nunca um snapshot necessário para uma + chamada posterior. + +### Responsabilidades do renderer interno + +O `Elixir::Materials::Rendering::Renderer` existente deve continuar responsável +por: + +- compilar ou recuperar materiais renderizáveis; +- resolver `MaterialInstance` em `MaterialRenderProxy`; +- selecionar o programa correspondente ao pass; +- criar e reutilizar graphics pipelines; +- preparar descriptor bindings; +- retornar o shader e o pipeline preparados para execução. + +Ele não deve possuir a lista global de draws do frame nem conhecer Aether, +meshes ou `Application`. + +### Responsabilidades dos produtores + +Aether, meshes e outros produtores devem: + +- preparar seus próprios dados de geometria; +- atualizar constant buffers e storage buffers específicos do domínio; +- executar ou agendar trabalho de compute específico do domínio; +- transformar seu estado em submissões genéricas de materiais; +- preservar a ordem necessária entre o trabalho de preparação e os draws + submetidos. + +Eles não devem: + +- chamar `MaterialSystem::PrepareFrame`; +- chamar `MaterialSystem::Render`; +- construir uma tabela de materiais; +- escolher o shader ou pipeline final; +- controlar diretamente a execução dos passes de materiais. + +## Fluxo desejado + +```text +Aether -----------------+ +Meshes -----------------+--> material submissions --> MaterialSystem +Terrain ----------------+ | +Future producers -------+ +--> resolve materials + +--> build frame table + +--> upload frame data + +--> build and sort batches + +--> internal Renderer + +--> GPU draw commands +``` + +O ciclo público do frame deve ser controlado por `Application`: + +```cpp +MaterialSystem.BeginFrame(FrameContext); + +Aether.SubmitRenderItems(Camera, MaterialSystem); +MeshRenderer.SubmitRenderItems(Scene, Camera, MaterialSystem); + +const auto Result = MaterialSystem.RenderFrame(); +``` + +`BeginFrame` abre a coleta. `RenderFrame` fecha a coleta, prepara os dados e +executa todos os draws submetidos. Não existe objeto preparado retornado por uma +função e posteriormente recebido por outra. + +## API pública proposta + +Os nomes definitivos podem ser ajustados durante a implementação, mas a +separação de responsabilidades deve permanecer. + +```cpp +namespace Elixir::Materials::Rendering +{ + /** + * @brief Describes the graphics state shared by material draws in one frame. + */ + struct SMaterialFrameContext + { + uint64_t SubmissionSerial = 0; + Ref ColorTarget; + Ref DepthTarget; + Extent2D RenderArea; + }; + + /** + * @brief Describes one material-backed draw. + */ + struct SMaterialDrawSubmission + { + Ref Material; + EMaterialPass Pass = EMaterialPass::ParticleSprite; + SRenderGeometry Geometry; + SMaterialPushConstants PushConstants; + SDrawCommand Draw; + }; + + /** + * @brief Receives material-backed draws for the current frame. + */ + class ELIXIR_API MaterialSubmissionSink + { + public: + virtual ~MaterialSubmissionSink() = default; + + /** + * @brief Adds a draw to the current material frame. + */ + virtual void Submit(SMaterialDrawSubmission Submission) = 0; + }; +} + +namespace Elixir::Materials +{ + /** + * @brief Coordinates material rendering for the application. + * + * The system collects material-backed draws, prepares shared material + * resources, builds render batches, and records their draw commands. + */ + class ELIXIR_API MaterialSystem final + : public Rendering::MaterialSubmissionSink + { + public: + /** + * @brief Starts material submission for a frame. + */ + void BeginFrame(const Rendering::SMaterialFrameContext& Context); + + /** + * @brief Adds a material-backed draw to the current frame. + */ + void Submit(Rendering::SMaterialDrawSubmission Submission) override; + + /** + * @brief Prepares and renders all draws submitted for the frame. + */ + SMaterialRenderResult RenderFrame(); + + private: + struct SFrameState; + + void PrepareFrame(SFrameState& Frame); + void BuildBatches(SFrameState& Frame); + SMaterialRenderResult ExecuteBatches(SFrameState& Frame); + }; +} +``` + +`MaterialSubmissionSink` evita que produtores dependam da API completa de +`MaterialSystem`. Caso a interface não traga benefício concreto durante a +implementação, o próprio `MaterialSystem` pode ser usado como sink sem mudar o +fluxo nem as responsabilidades. + +## Estado interno do frame + +O estado preparado deve ser uma implementação privada de `MaterialSystem`: + +```cpp +struct MaterialSystem::SFrameState +{ + Rendering::SMaterialFrameContext Context; + Rendering::MaterialRenderScene Scene; + Ref MaterialTable; + std::vector Batches; +}; +``` + +O conteúdo exato pode variar, mas deve incluir: + +- identificação do frame ou submission; +- submissões ou uma `MaterialRenderScene` agregada; +- tabela deduplicada de materiais; +- batches preparados para execução; +- recursos cuja vida útil precisa cobrir o uso pela GPU. + +Deve existir um slot por frame em voo, selecionado pelo frame index ou pelo +submission serial fornecido pelo `GraphicsContext`. Um único `SPreparedFrame` +mutável não é suficiente quando a CPU pode começar outro frame antes de a GPU +terminar o anterior. + +O ciclo interno esperado é: + +```text +Idle --> Collecting --> Preparing --> Rendering --> Submitted + ^ | + +----------- reusable frame slot <-----+ +``` + +`Submit` só é válido durante `Collecting`. `RenderFrame` sela a coleção para que +nenhum produtor altere a cena enquanto batches e comandos estão sendo criados. + +## Representação do material nas submissões + +Preferencialmente, produtores devem submeter `MaterialInstance` ou um +`MaterialHandle` pertencente ao sistema, e não um `MaterialRenderProxy`. + +Isso garante que: + +- todo material seja resolvido pelo sistema central; +- proxies compilados continuem sendo detalhes de rendering; +- fallback e invalidação sejam uniformes; +- materiais de diferentes produtores sejam deduplicados conjuntamente; +- índices de materiais e texturas sejam atribuídos globalmente por frame. + +Se a migração imediata de `MaterialRenderProxy` não for viável, ele pode ser +aceito temporariamente em `SRenderItem`. Essa deve ser uma etapa intermediária, +não o contrato final de submissão. + +## Mudanças no Aether + +`Aether::Rendering::Renderer` deixa de executar materiais. Ele passa a preparar +os recursos específicos de partículas e a publicar draws genéricos: + +```cpp +void Aether::Rendering::Renderer::SubmitRenderItems( + const RenderFrame& Frame, + const Camera& Camera, + Materials::Rendering::MaterialSubmissionSink& Sink +); +``` + +Sua implementação deve: + +1. atualizar os dados de câmera e do frame; +2. montar geometria, buffers externos, push constants e draw ranges; +3. enviar cada draw ao sink; +4. atualizar métricas de submissão que pertencem ao Aether. + +`BeginRendering`, `EndRendering`, `MaterialSystem::PrepareFrame` e +`MaterialSystem::Render` deixam de fazer parte desse fluxo. + +A simulação continua no Aether. Os comandos de compute devem ser enfileirados +antes dos comandos gráficos do `MaterialSystem`, ou a dependência deve ser +representada explicitamente quando existir um render graph. + +`Aether::Manager` não precisa manter uma referência permanente ao +`MaterialSystem` apenas para rendering. A dependência pode ser passada como +`MaterialSubmissionSink&` no momento da submissão. Se `Aether::Runtime` ainda +precisar resolver materiais durante compilação de assets, essa dependência deve +ser analisada separadamente da execução gráfica. + +## Mudanças para meshes + +O renderer de meshes deve usar o mesmo sink e o mesmo formato de submissão. A +primeira implementação pode tratar todos os draws como dinâmicos: + +```cpp +MeshRenderer.SubmitRenderItems(Scene, Camera, MaterialSystem); +``` + +Depois que o fluxo compartilhado estiver estável, meshes estáticas podem ganhar +um caminho persistente: + +```cpp +MaterialPrimitiveHandle RegisterPrimitive( + const SMaterialPrimitiveDescription& Primitive +); + +void UpdatePrimitive( + MaterialPrimitiveHandle Handle, + const SMaterialPrimitiveUpdate& Update +); + +void RemovePrimitive(MaterialPrimitiveHandle Handle); +``` + +Partículas continuam usando submissões dinâmicas por frame. Meshes que mudam +pouco podem reutilizar geometria, classificação por pass e parte dos comandos. + +## Relação com a arquitetura da Unreal Engine + +O desenho segue a mesma separação conceitual da mesh drawing pipeline da Unreal: + +| Elixir | Equivalente conceitual na Unreal | +| --- | --- | +| Aether ou mesh renderer | `FPrimitiveSceneProxy` | +| `SMaterialDrawSubmission` | `FMeshBatch` | +| `MaterialRenderProxy` | `FMaterialRenderProxy` | +| preparação específica do pass | `FMeshPassProcessor` | +| batch executável | `FMeshDrawCommand` | +| `GraphicsContext` e command buffer | RHI e `RHICommandList` | + +A correspondência não é literal. Na Unreal, o scene renderer é o dono da +execução e materiais são entradas dos draws. No Elixir, `MaterialSystem` pode ser +a fachada pública única exigida pela aplicação, desde que internamente preserve +a separação entre: + +- coleta e estado global do frame; +- resolução e recursos de materiais; +- preparação de passes e pipelines; +- emissão de comandos gráficos. + +O `Materials::Rendering::Renderer` existente ocupa a camada interna de +preparação do material e do pipeline. Não deve ser duplicado. + +## API que deve deixar de ser pública + +Ao final da migração, estas operações devem ser privadas ou removidas da API +pública de `MaterialSystem`: + +- `PrepareFrame(const MaterialRenderScene&, uint64_t)`; +- `Render(CommandBuffer, MaterialRenderScene, uint64_t)`; +- `GetProgramKey`; +- `PrepareMaterialPass`; +- acesso direto ao frame buffer de materiais; +- acesso direto ao texture set e sampler quando usados apenas pelo renderer + interno. + +`SPreparedFrame` e qualquer futuro snapshot preparado devem permanecer privados. +`SMaterialRenderResult` pode continuar público porque contém somente métricas do +trabalho realizado e não é necessário para completar a execução do frame. + +## Etapas de implementação + +### 1. Introduzir a coleta central + +- Adicionar o contexto público do frame. +- Adicionar `BeginFrame`, `Submit` e `RenderFrame`. +- Fazer `MaterialSystem` possuir a `MaterialRenderScene` agregada. +- Manter temporariamente os métodos antigos enquanto consumidores são migrados. +- Adicionar asserts para transições inválidas do ciclo do frame. + +### 2. Internalizar preparação e execução + +- Mover a lógica atual de `PrepareFrame` para um método privado que opere sobre + o frame corrente. +- Fazer `RenderFrame` construir a tabela, os batches e executar os draws em uma + única operação pública. +- Reutilizar `Materials::Rendering::Renderer` para resolução, programa, bindings + e pipeline. +- Remover a necessidade de passar `submissionSerial` de volta à execução. + +### 3. Migrar o Aether + +- Substituir `BuildMaterialRenderScene` seguido de preparação e renderização por + submissões ao sink. +- Remover chamadas de ciclo de vida do `MaterialSystem` do renderer do Aether. +- Mover o início e o fim do escopo gráfico para o coordenador central. +- Preservar a execução e a ordenação dos comandos de simulação. +- Ajustar métricas para diferenciar itens submetidos de draws efetivamente + executados. + +### 4. Centralizar o ciclo na Application + +- Iniciar o frame do `MaterialSystem` dentro do callback de renderização. +- Permitir que o `Application::Render` e seus subsistemas apenas submetam draws. +- Chamar `MaterialSystem::RenderFrame` depois que todos os produtores terminarem. +- Renderizar GUI na ordem definida pela aplicação. +- Garantir que nenhuma chamada de produtor possa encerrar o frame central. + +### 5. Adicionar meshes + +- Traduzir meshes para o mesmo contrato de submissão. +- Confirmar que meshes e partículas aparecem na mesma `FrameTable`. +- Validar ordenação de passes e compartilhamento de materiais entre produtores. +- Avaliar posteriormente um caminho retido para primitivas estáticas. + +### 6. Remover a API antiga + +- Remover `PrepareFrame` e `Render` públicos antigos. +- Remover getters usados apenas para vazar recursos internos. +- Remover dependências permanentes desnecessárias do Aether em + `MaterialSystem`. +- Atualizar documentação e exemplos. + +### 7. Suportar múltiplos frames em voo + +- Substituir o único `SPreparedFrame` por slots de frame. +- Vincular cada slot ao mecanismo de frame index ou submission serial do + `GraphicsContext`. +- Reutilizar um slot somente depois que os recursos correspondentes puderem ser + atualizados com segurança. +- Garantir que `FrameTable`, buffers e registros de textura tenham vida útil + suficiente. + +## Pontos que precisam de decisão durante a implementação + +### Ownership do command buffer + +A opção preferida é `MaterialSystem::RenderFrame` criar e enfileirar seu command +buffer secundário usando o `GraphicsContext` que já recebe no construtor. Isso +torna o sistema realmente responsável pela execução dos materiais. + +Se a aplicação precisar compor vários sistemas no mesmo command buffer, o +contexto do frame poderá fornecer um command buffer, mas produtores ainda não +devem fornecê-lo diretamente ao renderer interno. + +### Render targets e múltiplas views + +A primeira versão pode aceitar um color target, um depth target e uma render +area no contexto do frame. Antes de suportar múltiplas câmeras, sombras ou +render-to-texture, será necessário introduzir uma identificação de view ou +render scope nas submissões. + +### MaterialInstance ou MaterialHandle + +`MaterialInstance` simplifica a primeira migração. Um `MaterialHandle` estável +pode ser introduzido depois para remover ownership compartilhado dos draws e +facilitar cache e invalidação. + +### Interface de submissão + +`MaterialSubmissionSink` reduz o acoplamento dos produtores. Ela não deve crescer +para expor preparação, resolução ou recursos internos. Se a única implementação +for `MaterialSystem` e a interface não ajudar testes ou dependências, ela pode +ser removida sem mudar o modelo arquitetural. + +## Invariantes + +- Existe somente um `MaterialSystem` por `Application`. +- Todos os draws que usam materiais entram pelo frame corrente do sistema. +- Apenas `Application` controla o início e a execução do frame de materiais. +- Aether e meshes nunca chamam preparação ou execução de materiais. +- O estado preparado nunca sai do `MaterialSystem`. +- A tabela de materiais representa conjuntamente todos os produtores do frame. +- O renderer interno não conhece os produtores. +- Um frame em voo não sobrescreve recursos ainda utilizados por outro frame. + +## Validação + +### Comportamento + +- Um frame contendo somente partículas mantém o resultado visual atual. +- Um frame contendo somente meshes renderiza pela mesma pipeline central. +- Partículas e meshes podem coexistir no mesmo frame. +- Uma mesma instância de material usada por ambos ocupa uma única entrada na + tabela do frame. +- Um frame sem submissões termina sem comandos de draw. +- Submissões fora de `BeginFrame` e depois do início de `RenderFrame` são + rejeitadas por assert ou por resultado explícito. +- Falhas de resolução usam o fallback definido pelo sistema sem interromper os + demais draws. + +### Ordem e sincronização + +- Compute do Aether termina ou é corretamente sincronizado antes dos draws que + consomem seus buffers. +- Passes respeitam a ordem definida pelo renderer de materiais. +- GUI é executada na ordem prevista em relação aos materiais. +- Slots de frames em voo não compartilham estado mutável de forma insegura. + +### Métricas + +- `MaterialCount` representa materiais únicos do frame inteiro. +- `BatchCount` representa batches executados pelo sistema central. +- `DrawCount` representa draws efetivamente gravados. +- Métricas específicas do Aether continuam disponíveis sem assumir que ele é o + único produtor. + +### Compatibilidade de plataformas + +- Validar compilação e comportamento em macOS e Windows. +- Verificar símbolos que cruzam a fronteira da DLL e aplicar `ELIXIR_API` aos + tipos e funções públicos necessários. +- Confirmar que novas APIs não dependem de símbolos resolvidos apenas por + linking estático no macOS. +- Executar testes de unidade e integração em Windows no CI. +- Manter validações específicas de Vulkan e de lifetime de command buffers em + ambas as plataformas. + +## Critérios de conclusão + +O trabalho estará concluído quando: + +1. `Application` controlar o ciclo de materiais uma única vez por frame; +2. Aether apenas submeter draws e não chamar preparação ou execução do sistema; +3. meshes usarem o mesmo caminho de submissão; +4. `MaterialSystem` construir uma tabela conjunta para todos os produtores; +5. o `Materials::Rendering::Renderer` existente continuar como único executor + interno de materiais; +6. nenhum snapshot preparado fizer parte do contrato público; +7. múltiplos frames em voo forem tratados sem sobrescrita prematura; +8. testes relevantes passarem em macOS e Windows. diff --git a/Documentation/Plans/MaterialSystemCentralRendering.md b/Documentation/Plans/MaterialSystemCentralRendering.md new file mode 100644 index 00000000..d1da958e --- /dev/null +++ b/Documentation/Plans/MaterialSystemCentralRendering.md @@ -0,0 +1,1058 @@ +# MaterialSystem: renderização central e preparação assíncrona + +## Relação com o plano inicial + +Este documento consolida e substitui as decisões arquiteturais do plano +`MaterialSystem.md` sem alterar o arquivo original. Ele incorpora as decisões +tomadas depois da primeira revisão: + +- consumidores submetem somente `MaterialInstance`; +- `MaterialHandle`, aquisição explícita e `MaterialRenderProxy` não fazem parte + da API dos consumidores; +- `MaterialRenderScene` é inicialmente a unidade de submissão; +- cache, snapshots, filas de preparação e proxies são internos ao módulo de + materiais; +- materiais novos ou desatualizados são preparados em worker threads; +- a preparação inicial pode ser síncrona na primeira versão; +- múltiplos frames em voo e um serial gráfico global fazem parte da base da + implementação; +- meshes não fazem parte do escopo desta alteração; +- o `Materials::Rendering::Renderer` existente continua sendo o único renderer + interno de materiais. + +## Objetivo + +Transformar `MaterialSystem` no ponto central de toda renderização baseada em +materiais da aplicação. + +`Application` possui uma única instância de `MaterialSystem`. Aether e outros +produtores descrevem o que precisa ser desenhado por meio de +`MaterialRenderScene`, mas não preparam materiais, não criam tabelas de frame e +não executam passes de materiais. + +Todo o estado específico de materiais deve permanecer no módulo de materiais: + +- instâncias usadas por submissões; +- snapshots imutáveis; +- cache de materiais compilados; +- cache de instâncias preparadas; +- estado de preparação assíncrona; +- `MaterialRenderProxy`; +- `FrameTable`; +- texture registry; +- buffers por frame; +- batches preparados; +- shaders e pipelines. + +O consumidor fornece uma `MaterialInstance`, ponto. O módulo decide se já existe +uma representação preparada, se ela está desatualizada ou se uma nova preparação +deve ser agendada. + +## Fora do escopo + +Esta alteração não inclui: + +- sistema de meshes; +- registro persistente de primitives; +- culling, LOD ou visibility de meshes; +- passes de surface, depth, shadow ou translucency de meshes; +- render graph; +- preparação completamente assíncrona já na primeira etapa; +- mudança geral do modelo de threading de `Material` e `MaterialInstance`. + +A arquitetura deve permitir a adição posterior de um sistema de meshes, mas +nenhuma API de primitives deve ser adicionada ao `MaterialSystem` neste trabalho. + +## Problemas do fluxo atual + +Atualmente, `Aether::Rendering::Renderer`: + +1. constrói uma `MaterialRenderScene` exclusiva para partículas; +2. chama `MaterialSystem::PrepareFrame`; +3. inicia o rendering scope; +4. chama `MaterialSystem::Render`; +5. encerra o rendering scope. + +Esse fluxo torna o Aether responsável pelo ciclo de renderização de materiais e +expõe a separação entre preparação e execução. Também associa o frame de +materiais ao serial de publicação do Aether, que não representa o frame gráfico +global. + +O estado atual mantém somente um `SPreparedFrame` e um único buffer de dados de +materiais. Isso não representa explicitamente os múltiplos frames em voo +suportados pelo `GraphicsContext`. + +## Invariantes arquiteturais + +- Existe um único `MaterialSystem` por `Application`. +- Somente `Application` controla o início e a execução do frame de materiais. +- Produtores somente constroem e submetem `MaterialRenderScene`. +- Uma submissão contém `MaterialInstance`, nunca `MaterialRenderProxy`. +- Cache, handles internos, snapshots e proxies não são expostos aos produtores. +- `PrepareFrame` não faz parte da API pública. +- O estado preparado nunca é retornado ao consumidor. +- O frame de materiais usa frame index e serial do domínio gráfico. +- Cada frame em voo possui armazenamento de material que não pode ser + sobrescrito enquanto estiver em uso pela GPU. +- Preparação assíncrona nunca bloqueia a renderização de um frame normal. +- Um resultado assíncrono obsoleto nunca substitui uma revisão mais recente. +- O renderer interno não conhece Aether, `Application` ou qualquer produtor. +- Aether não inicia nem encerra rendering scopes de materiais. +- Falha de preparação de um material não interrompe os demais draws. + +## Responsabilidades + +### Application + +`Application`: + +- cria e possui `MaterialSystem`; +- inicia o frame de materiais dentro do callback da render thread; +- permite que a implementação de `Render` e seus subsistemas submetam cenas; +- solicita a execução do frame depois de todas as submissões; +- mantém a ordem entre materiais, GUI e outros sistemas de rendering. + +### MaterialSystem + +`MaterialSystem`: + +- recebe `MaterialRenderScene`; +- identifica todas as `MaterialInstance` requeridas no frame; +- consulta o cache interno de instâncias; +- captura snapshots para entradas ausentes ou desatualizadas; +- prepara sincronamente os materiais iniciais enquanto essa política estiver + habilitada; +- agenda preparações posteriores no worker pool; +- consome resultados concluídos sem esperar pelos workers; +- seleciona proxy pronto, último proxy válido ou fallback; +- constrói a `FrameTable` global do frame; +- agrupa e ordena os itens preparados; +- cria e enfileira o command buffer gráfico de materiais; +- delega programa, shader, descriptors e pipeline ao renderer interno; +- mantém métricas globais do frame de materiais. + +### Materials::Rendering::Renderer + +O renderer interno existente: + +- mantém o cache de compilação ou utiliza o serviço interno responsável por ele; +- transforma dados compilados e snapshots de instância em + `MaterialRenderProxy`; +- encontra o programa correspondente ao pass; +- prepara descriptor bindings; +- cria e reutiliza graphics pipelines; +- fornece shader e pipeline para a emissão dos draws. + +Ele não possui a fila global do frame e não controla o ciclo público de +submissão. + +### Aether + +Aether: + +- publica instâncias e dados imutáveis de simulação; +- grava sua simulação e suas barreiras de compute; +- mantém os recursos de partículas vivos durante a submissão; +- constrói uma `MaterialRenderScene` com geometrias e draws de partículas; +- submete essa cena ao `MaterialSystem`; +- mantém métricas específicas de simulação e de itens submetidos. + +Aether não: + +- resolve `MaterialInstance` em proxy; +- prepara a tabela de materiais; +- chama preparação ou execução de materiais; +- escolhe o shader ou graphics pipeline final; +- inicia ou encerra o rendering scope gráfico dos materiais. + +## Fluxo do frame + +```text +Application render callback + | + +-- MaterialSystem.BeginFrame() + | + +-- Aether simulation + | | + | +-- records compute work and barriers + | +-- enqueues compute command buffer + | +-- builds MaterialRenderScene + | +-- MaterialSystem.Submit(scene) + | + +-- other producers submit scenes + | + +-- MaterialSystem.RenderFrame() + | | + | +-- publishes completed preparation jobs + | +-- finds required MaterialInstance objects + | +-- schedules missing or stale entries + | +-- selects ready, previous, or fallback proxies + | +-- builds the frame material table + | +-- builds and sorts batches + | +-- records the graphics command buffer + | +-- enqueues the graphics command buffer + | + +-- GUI rendering +``` + +No modelo atual de command buffers secundários, a simulação do Aether deve ser +enfileirada antes do command buffer gráfico criado por `MaterialSystem`. A ordem +de enfileiramento preserva a dependência enquanto a gravação ocorre +sequencialmente no callback da render thread. + +Se a gravação de command buffers se tornar paralela, a ordem implícita da fila +não será suficiente. Essa evolução deve usar dependências explícitas ou um +render graph. + +## API pública proposta + +### MaterialRenderScene + +`MaterialRenderScene` continua armazenando geometrias separadamente dos draws. +Isso preserva o compartilhamento atual de buffers e bindings entre vários itens. + +```cpp +namespace Elixir::Materials::Rendering +{ + /** + * @brief Describes one material-backed draw for the current frame. + */ + struct SRenderItem + { + /** Material pass used by the draw. */ + EMaterialPass Pass = EMaterialPass::ParticleSprite; + + /** Material instance used by the draw. */ + Ref Material; + + /** Index of geometry stored in the containing scene. */ + uint32_t GeometryIndex = UINT32_MAX; + + /** Push constants applied before the draw. */ + SMaterialPushConstants PushConstants; + + /** Draw range for the item. */ + SDrawCommand Draw; + }; +} +``` + +O consumidor não chama `AcquireMaterial`, não recebe um `MaterialHandle` e não +inclui headers de `MaterialRenderProxy`. + +### MaterialSystem + +```cpp +namespace Elixir::Materials +{ + /** + * @brief Coordinates material rendering for the application. + * + * The system collects material scenes, prepares material instances, builds + * shared frame resources, and records material draw commands. + */ + class ELIXIR_API MaterialSystem final + : public Rendering::MaterialResolver + { + public: + /** + * @brief Starts material submission for the current graphics frame. + */ + void BeginFrame(); + + /** + * @brief Adds a material scene to the current frame. + */ + void Submit(Rendering::MaterialRenderScene Scene); + + /** + * @brief Prepares and renders all material scenes submitted for the frame. + */ + SMaterialRenderResult RenderFrame(); + + /** + * @brief Resolves an instance for compatibility with existing consumers. + * + * New render producers must submit MaterialInstance through + * MaterialRenderScene instead of requesting a render proxy. + */ + Ref Resolve( + const Ref& Instance + ) override; + }; +} +``` + +`MaterialResolver` permanece durante a migração porque o caminho atual de +compilação do Aether depende dele. Depois que todos os consumidores enviarem +`MaterialInstance`, a necessidade de manter esse contrato público deve ser +reavaliada em uma alteração separada. A remoção não é necessária para concluir +este plano. + +## Integração com Application + +O ciclo deve ser inserido ao redor da chamada virtual de rendering: + +```cpp +m_GraphicsContext->RenderFrame([this, FrameTime]() +{ + m_MaterialSystem->BeginFrame(); + + Render(FrameTime); + + m_MaterialSystem->RenderFrame(); + m_GUIManager->Render(); +}); +``` + +`BeginFrame` obtém do `GraphicsContext`: + +- frame number global; +- frame index; +- número de frames em voo; +- color target principal; +- depth-stencil target; +- render extent. + +Esses dados não precisam ser fornecidos pelo Aether nem associados ao serial de +uma publicação do Aether. + +## Submissão de múltiplas cenas + +Cada `MaterialRenderScene` possui índices locais de geometria. Inicialmente, o +sistema deve armazenar as cenas sem combiná-las fisicamente: + +```cpp +struct SFrameSlot +{ + uint64_t FrameSerial = 0; + uint32_t FrameIndex = 0; + + std::vector Scenes; + std::vector PreparedItems; + std::vector Batches; + + Ref MaterialTable; + Ref MaterialBuffer; +}; +``` + +Uma geometria é identificada internamente por cena e índice: + +```cpp +struct SFrameGeometryKey +{ + uint32_t SceneIndex = UINT32_MAX; + uint32_t GeometryIndex = UINT32_MAX; + + bool operator==(const SFrameGeometryKey&) const = default; +}; +``` + +Isso evita: + +- remapeamento de índices durante a submissão; +- cópia de `SRenderGeometry` em cada draw; +- colisões entre índices locais de produtores diferentes; +- perda do compartilhamento de geometria já usado pelo Aether. + +A `FrameTable` percorre todas as cenas e continua sendo única para o frame. + +Uma operação futura de flatten ou merge só deve ser adicionada se medições +mostrarem benefício. + +## Estado interno do frame + +`MaterialSystem` mantém um slot para cada frame em voo: + +```cpp +std::vector m_Frames; +SFrameSlot* m_CurrentFrame = nullptr; +``` + +O slot é selecionado por `GraphicsContext::GetFrameIndex()` e identificado por +`GraphicsContext::GetFrameNumber()`. + +O ciclo do slot é: + +```text +Available --> Collecting --> Preparing --> Recording --> Submitted + ^ | + +------------- GPU completion / slot reuse ---------+ +``` + +`BeginFrame`: + +1. seleciona o slot correspondente ao frame index; +2. confirma que o `GraphicsContext` já tornou o slot seguro para reutilização; +3. limpa cenas, itens preparados e batches anteriores; +4. atualiza frame serial e frame index; +5. inicia o `TextureRegistry` com o serial gráfico global; +6. muda o estado para `Collecting`. + +`Submit` só aceita cenas durante `Collecting`. + +`RenderFrame` sela as submissões, prepara os materiais disponíveis, grava os +comandos e muda o slot para `Submitted`. + +## Recursos GPU por frame em voo + +Não basta armazenar somente `FrameTable` e batches por slot. O buffer GPU que +recebe `SMaterialFrameData` também não pode ser sobrescrito enquanto outro frame +o utiliza. + +A primeira implementação deve escolher uma destas estratégias: + +1. um `DynamicStorageBuffer` por frame em voo; ou +2. um único buffer em formato ring, com uma região e offset por frame. + +Um buffer por frame é a opção inicial recomendada por ser mais simples e menos +propensa a erro. + +O renderer interno atualmente recebe um único frame buffer no construtor. Ele +deve passar a receber o buffer do slot na preparação do pass ou usar um pequeno +objeto de recursos do frame: + +```cpp +struct SMaterialFrameResources +{ + Ref MaterialBuffer; + Ref MaterialTable; +}; +``` + +O buffer e a tabela permanecem vivos pelo menos até que o slot possa ser +reutilizado. + +## Cache interno de materiais + +O cache possui duas camadas conceituais. + +### Cache de material compilado + +Chave lógica: + +```text +Material identity + Material revision +``` + +Resultado: + +```cpp +Ref +``` + +O `CompilationCache` existente já cobre parte dessa responsabilidade e deve ser +reutilizado ou adaptado, não duplicado. + +### Cache de instância preparada + +Chave lógica: + +```text +MaterialInstance identity ++ MaterialInstance revision ++ parent Material revision +``` + +Resultado: + +```cpp +Ref +``` + +Estrutura interna proposta: + +```cpp +enum class EMaterialPreparationState : uint8_t +{ + Pending, + Preparing, + Ready, + Refreshing, + Failed, +}; + +struct SMaterialInstanceCacheEntry +{ + std::weak_ptr Instance; + Ref Proxy; + + uint32_t PreparedInstanceRevision = 0; + uint32_t PreparedMaterialRevision = 0; + uint64_t JobGeneration = 0; + + EMaterialPreparationState State = + EMaterialPreparationState::Pending; + + std::string Diagnostics; +}; +``` + +O mapa pode ser indexado inicialmente pelo endereço da instância. A entrada deve +conter uma referência fraca e uma geração para evitar aceitar resultados de uma +instância destruída ou de um job anterior. Entradas expiradas devem ser removidas +periodicamente. + +O cache não deve manter todas as `MaterialInstance` vivas indefinidamente. As +`MaterialRenderScene` do frame mantêm referências fortes enquanto os draws ainda +precisam das instâncias. + +## Detecção de entradas pendentes + +Durante `RenderFrame`, o sistema percorre todas as cenas e deduplica as instâncias +requeridas por identidade. + +Para cada instância: + +| Condição | Ação | +| --- | --- | +| Não existe no cache | Capturar snapshot e marcar `Pending` | +| Revisões coincidem | Usar proxy `Ready` | +| Revisão mudou e há proxy | Manter proxy anterior e marcar `Refreshing` | +| Revisão mudou e não há proxy | Marcar `Pending` | +| Falha na mesma revisão | Não reagendar automaticamente todo frame | +| Falha e revisão mudou | Capturar novo snapshot e reagendar | +| Instância expirou | Remover entrada quando não houver job válido | + +Cada instância deve gerar no máximo um job para uma combinação de revisões. A +deduplicação acontece antes do envio ao worker pool. + +## Snapshot imutável + +Workers não podem ler diretamente `Material`, `MaterialGraph` ou +`MaterialInstance`, pois esses objetos são mutáveis e atualmente não possuem um +contrato de leitura concorrente. + +Antes de agendar um job, o módulo captura um snapshot imutável: + +```cpp +struct SMaterialDefinitionSnapshot +{ + std::string Name; + MaterialGraph Graph; + std::unordered_map Parameters; + uint32_t UsageMask = 0; + uint32_t Revision = 0; +}; + +struct SMaterialInstanceSnapshot +{ + const MaterialInstance* Identity = nullptr; + uint64_t JobGeneration = 0; + + uint32_t InstanceRevision = 0; + uint32_t MaterialRevision = 0; + + SMaterialDefinitionSnapshot Material; + std::unordered_map ResolvedParameters; +}; +``` + +Os tipos concretos podem ser ajustados para evitar cópias desnecessárias. O +requisito é que o worker receba ownership independente e não consulte objetos +mutáveis depois que o job começa. + +A captura também precisa ser segura em relação aos setters. Opções aceitáveis: + +1. mutex interno usado somente durante mutação e captura; +2. estado copy-on-write publicado atomicamente; +3. fila de alterações aplicada por uma única thread proprietária. + +Para a primeira implementação assíncrona, um mutex de curta duração durante a +captura é aceitável. Copy-on-write pode ser adotado depois caso o lock apareça em +profiles. + +## Pipeline de preparação assíncrona + +```text +Material render thread Worker pool +---------------------- ----------- +collect required instances +detect missing or stale entry +capture immutable snapshot +mark job generation +enqueue request ---------------------> validate graph +continue without waiting generate material code +use previous proxy or fallback compile CPU artifacts + | +consume completed result <--------------+ +validate identity, revisions and generation +finalize render resources when required +publish immutable proxy +``` + +Estados detalhados: + +```text +Missing --> Pending --> Preparing --> Ready + | + +-----------> Failed + +Ready -- revision changed --> Refreshing --> Ready(new revision) + | + +--------> Ready(previous revision) + diagnostics +``` + +Os workers enviam resultados para uma completion queue. Somente a thread que +possui o frame de materiais publica resultados no cache principal. + +O `Executor` existente deve ser usado para enviar trabalhos ao worker pool. A +renderização normal nunca chama `Wait`, espera uma future ou bloqueia por uma +preparação pendente. + +## Separação entre CPU e GPU + +O `Compiler::Compile` atual valida o graph, gera HLSL, chama o compilador e carrega +programas por meio de `ShaderLoader`. Não se deve assumir que todo esse caminho é +seguro em worker threads apenas porque `CompilationCache` usa mutex. + +A implementação assíncrona deve ser dividida conceitualmente em: + +### Worker-safe preparation + +- validação do snapshot; +- geração de código; +- criação do layout de parâmetros; +- compilação para artefatos intermediários; +- criação dos dados resolvidos da instância que não dependem do RHI. + +### Render-safe finalization + +- consumo dos artefatos concluídos; +- criação ou publicação de shaders e recursos que tenham restrição de thread; +- criação do `MaterialRenderProxy` imutável; +- atualização do cache principal; +- registro de diagnósticos. + +Se `ShaderLoader` e o backend forem comprovadamente seguros para uso concorrente, +parte ou toda a finalização poderá ocorrer no worker. Essa capacidade precisa ser +validada no código e nas plataformas suportadas antes de ser habilitada. + +A resolução de texturas em índices bindless permanece no frame de rendering, +pois depende do `TextureRegistry` e do momento em que atualizações de descriptors +se tornam visíveis. + +## Publicação de resultados + +Um resultado de worker contém toda a informação necessária para validar sua +atualidade: + +```cpp +struct SMaterialPreparationResult +{ + const MaterialInstance* Identity = nullptr; + uint64_t JobGeneration = 0; + + uint32_t InstanceRevision = 0; + uint32_t MaterialRevision = 0; + + Ref Proxy; + std::string Diagnostics; +}; +``` + +Ao consumir o resultado, o sistema confirma: + +- a entrada ainda existe; +- a referência fraca ainda identifica o mesmo objeto; +- `JobGeneration` corresponde ao job atual; +- a revisão da instância ainda é a mesma; +- a revisão do parent material ainda é a mesma. + +Se qualquer verificação falhar, o resultado é descartado. Caso a instância ainda +seja necessária, uma nova revisão é marcada como pendente. + +O descarte de resultado obsoleto é comportamento normal, não erro. + +## Política de fallback e atualização + +| Estado | Comportamento no frame | +| --- | --- | +| `Ready` e revisões atuais | Usar o proxy atual | +| Material novo em preparação | Usar material fallback | +| Refresh com proxy anterior | Usar o último proxy válido | +| Falha sem proxy anterior | Usar fallback ou ignorar o draw conforme política | +| Falha com proxy anterior | Usar o proxy anterior e registrar diagnóstico | + +O fallback deve ser criado e preparado sincronamente durante a inicialização do +`MaterialSystem`. Portanto, ele nunca depende de um job pendente. + +Uma falha deve ser armazenada junto às revisões que falharam. O sistema não deve +repetir a mesma compilação em todos os frames. Uma nova tentativa ocorre quando: + +- a revisão muda; +- o consumidor solicita explicitamente uma recompilação; ou +- uma política de retry controlada for adicionada futuramente. + +## Preparação síncrona inicial + +Na primeira versão, os materiais exigidos pelo primeiro frame renderizado podem +ser preparados sincronamente: + +```text +First graphics frame + collect instances + prepare all missing instances synchronously + build frame table + render +``` + +Depois desse bootstrap: + +- materiais novos entram na fila de workers; +- materiais alterados entram em `Refreshing`; +- nenhum frame espera a conclusão desses jobs; +- fallback ou último proxy válido é usado enquanto necessário. + +Essa política deve estar isolada em uma condição interna para que possa ser +removida sem alterar a API pública. Uma evolução posterior pode fazer warmup de +materiais conhecidos antes do primeiro frame e eliminar o bloqueio inicial. + +A preparação síncrona de shader não garante que uma textura recém-adicionada +esteja visível no descriptor set no mesmo callback. O comportamento de fallback +do `TextureRegistry` continua válido até o frame em que a atualização de +descriptor estiver disponível. + +## Construção da cena preparada + +`MaterialRenderScene` representa a entrada pública. A execução utiliza uma +representação interna que associa cada item ao proxy escolhido para aquele frame: + +```cpp +struct SPreparedRenderItem +{ + const Rendering::SRenderItem* Source = nullptr; + Ref Material; + SFrameGeometryKey Geometry; + uint32_t MaterialIndex = UINT32_MAX; +}; +``` + +O processo é: + +1. percorrer os itens de todas as cenas; +2. localizar a entrada de cache da `MaterialInstance`; +3. escolher proxy atual, proxy anterior ou fallback; +4. adicionar o proxy escolhido à `FrameTable`; +5. criar `SPreparedRenderItem` com o índice retornado; +6. agrupar os itens preparados por pass, geometria e programa; +7. ordenar os batches pela política do renderer; +8. preparar pipeline e emitir draws. + +Os ponteiros para `SRenderItem` são válidos porque as cenas permanecem imóveis no +slot até a gravação terminar. Se a preparação passar a sobreviver além da +gravação do frame, os itens devem ser armazenados por valor ou por ownership +explícito. + +## MaterialFrameTable e TextureRegistry + +A tabela é construída uma vez por frame a partir dos proxies efetivamente +selecionados. O mesmo proxy usado por cenas diferentes ocupa uma única entrada. + +O `TextureRegistry` usa o frame serial gráfico global, nunca o serial do Aether. +Quando uma textura é registrada durante um callback, ela pode continuar usando o +fallback até a próxima atualização visível de descriptors. + +A capacidade inicial de `FrameTable` não deve ser tratada como capacidade máxima +silenciosa. Como o frame passa a agregar todos os produtores, o sistema deve: + +- detectar capacidade insuficiente antes do upload; +- aumentar os buffers do slot com uma política definida; ou +- falhar explicitamente com diagnóstico e fallback previsível. + +Ignorar uma falha de `FrameTable::Add` e descobrir o material ausente somente +durante batching não é aceitável. + +## Command buffer e rendering scope + +Para a infraestrutura atual, `MaterialSystem::RenderFrame` deve: + +1. obter um command buffer secundário do `GraphicsContext`; +2. iniciar o command buffer com color target, depth target e render area atuais; +3. iniciar o rendering scope; +4. configurar viewport e scissor; +5. executar todos os batches de materiais; +6. encerrar o rendering scope; +7. enfileirar o command buffer. + +O sistema obtém os targets diretamente do `GraphicsContext`. O Aether não fornece +nem controla esses recursos. + +Um frame sem itens não precisa criar nem enfileirar um command buffer gráfico. + +## Métricas + +`SMaterialRenderResult` representa o frame inteiro: + +```cpp +struct SMaterialRenderResult +{ + uint32_t MaterialCount = 0; + uint32_t SceneCount = 0; + uint32_t BatchCount = 0; + uint32_t DrawCount = 0; + uint32_t FallbackDrawCount = 0; + uint32_t PendingMaterialCount = 0; + uint32_t FailedMaterialCount = 0; +}; +``` + +Métricas do Aether devem representar: + +- sistemas simulados; +- partículas processadas; +- itens de materiais submetidos; +- serial da publicação do Aether. + +Elas não devem copiar os totais globais de batches e materiais do +`MaterialSystem`, pois outros produtores poderão contribuir para o mesmo frame. + +## Threading + +### Render/material owner thread + +Na primeira implementação, `BeginFrame`, `Submit` e `RenderFrame` são chamados +sequencialmente na render thread. O cache principal e os slots de frame são +possuídos por essa thread. + +### Worker threads + +Workers recebem apenas snapshots e produzem resultados imutáveis. Eles não: + +- modificam slots de frame; +- modificam o cache principal; +- acessam cenas submetidas; +- acessam diretamente `MaterialInstance` ou `Material`; +- atualizam `TextureRegistry`; +- gravam command buffers gráficos. + +### Completion queue + +A completion queue sincronizada conecta workers à owner thread. O consumo ocorre +no início de `RenderFrame` ou de `BeginFrame`, sem espera bloqueante. + +### Shutdown + +Durante shutdown: + +- novas preparações deixam de ser aceitas; +- jobs existentes devem ser cancelados cooperativamente ou drenados; +- resultados tardios não podem acessar `MaterialSystem` destruído; +- o worker deve capturar dados por valor, nunca `this` cru sem garantia de vida; +- recursos GPU são destruídos somente depois do mecanismo normal de idle e + retirement do graphics context. + +## Migração do Aether + +### Estado atual + +`SCompiledEmitter` e `Simulation::SRenderItem` mantêm +`Ref`. O Aether resolve o material durante sua +compilação e publica o proxy no frame imutável. + +### Estado desejado + +O Aether mantém `Ref` como parte do estado publicado necessário +ao draw. O `Simulation::RenderFrame` continua imutável enquanto retém uma +referência forte para a instância. + +O Aether não lê parâmetros da instância. Ele somente transfere a referência para +`MaterialRenderScene`. O `MaterialSystem` captura e prepara o estado necessário. + +A validação de compatibilidade entre render mode e material usage pode verificar +o parent `Material` durante a compilação do emitter. A validação definitiva +também ocorre no módulo de materiais antes de criar o batch. + +O método atual do Aether pode evoluir para: + +```cpp +void Aether::Rendering::Renderer::SubmitRenderItems( + const Simulation::RenderFrame& Frame, + const Camera& Camera, + Materials::MaterialSystem& Materials +); +``` + +Ou pode retornar uma cena para que `Aether::Manager` faça a submissão: + +```cpp +Materials::Rendering::MaterialRenderScene +Aether::Rendering::Renderer::BuildMaterialRenderScene( + const Simulation::RenderFrame& Frame, + const Camera& Camera +); +``` + +A segunda forma mantém o renderer do Aether independente do `MaterialSystem` e é +preferível quando não introduz cópia adicional: + +```cpp +auto Scene = Renderer.BuildMaterialRenderScene(*Frame, Camera); +Materials.Submit(std::move(Scene)); +``` + +## Uso do MaterialResolver durante a migração + +`MaterialSystem` continua implementando `MaterialResolver` inicialmente para não +quebrar consumidores existentes em uma única mudança. + +Depois que o Aether deixar de armazenar proxies: + +- localizar outros consumidores de `MaterialResolver`; +- confirmar se o contrato ainda tem uso público; +- se não houver consumidores, tornar a resolução uma operação privada entre + `MaterialSystem`, cache e renderer interno; +- remover includes de `MaterialRenderProxy` dos módulos externos. + +Essa limpeza é posterior e não bloqueia o novo fluxo central. + +## Etapas de implementação + +### Etapa 1: slots de frame e serial global + +- Criar um `SFrameSlot` por frame em voo. +- Criar um material buffer por slot. +- Selecionar o slot por `GraphicsContext::GetFrameIndex()`. +- Usar `GraphicsContext::GetFrameNumber()` como serial gráfico. +- Adaptar o renderer interno para usar os recursos do slot corrente. +- Manter temporariamente a API atual de Aether. + +### Etapa 2: submissão central síncrona + +- Alterar `MaterialRenderScene::SRenderItem` para receber `MaterialInstance`. +- Adicionar `MaterialSystem::BeginFrame`, `Submit` e `RenderFrame`. +- Armazenar múltiplas cenas por slot sem remapear suas geometrias. +- Criar o cache interno de instâncias. +- Preparar materiais ausentes sincronamente. +- Criar e preparar o fallback durante inicialização. +- Construir `FrameTable` e batches internos. +- Mover rendering scope e emissão de comandos para `MaterialSystem`. +- Inserir o ciclo central no callback de `Application`. + +### Etapa 3: migrar o Aether + +- Fazer compiled emitter e simulation render item reterem `MaterialInstance`. +- Remover resolução de proxy do caminho de compilação do Aether quando possível. +- Fazer o renderer do Aether apenas construir `MaterialRenderScene`. +- Enfileirar comandos de simulação antes do frame gráfico de materiais. +- Remover chamadas de `PrepareFrame`, `Render`, `BeginRendering` e + `EndRendering` do Aether. +- Separar métricas de submissão do Aether das métricas globais de materiais. + +### Etapa 4: snapshots e jobs assíncronos + +- Definir snapshots imutáveis de material e instância. +- Tornar a captura segura em relação a mutações. +- Adicionar estados, geração de job e deduplicação de pendências. +- Enviar preparação para o worker pool do `Executor`. +- Criar a completion queue. +- Publicar resultados somente na owner thread. +- Descartar resultados obsoletos por identidade, revisão e geração. +- Usar fallback ou último proxy válido enquanto um job estiver pendente. +- Armazenar falhas por revisão para evitar recompilação contínua. + +### Etapa 5: separar compilação e finalização + +- Auditar a thread-safety de `Compiler`, `ShaderLoader`, shader backend e criação + de recursos gráficos. +- Separar geração e compilação worker-safe da finalização render-safe. +- Medir o custo da finalização na render thread. +- Mover mais trabalho para workers somente quando suportado em macOS e Windows. + +### Etapa 6: limpeza da API + +- Tornar `PrepareFrame`, batching e execução detalhes privados. +- Remover getters públicos de recursos usados somente pelo renderer interno. +- Reavaliar e eventualmente remover `MaterialResolver` público. +- Remover dependências externas de `MaterialRenderProxy`. +- Atualizar documentação Doxygen e exemplos. + +## Validação + +### Unidade: cache e revisões + +- Primeira consulta cria exatamente uma entrada pendente. +- Submissões repetidas da mesma instância não duplicam jobs. +- Cache hit com revisões iguais reutiliza o mesmo proxy. +- Mudança da instância agenda refresh. +- Mudança do parent material agenda recompilação e refresh. +- Falha na mesma revisão não é reagendada a cada frame. +- Mudança após falha permite nova tentativa. +- Resultado com geração antiga é descartado. +- Resultado de instância expirada é descartado. +- Entrada sem referência viva é removida do cache. + +### Unidade: snapshots + +- Snapshot contém graph, usages, schema, defaults e overrides da mesma revisão. +- Worker não acessa o objeto mutável depois do agendamento. +- Alteração posterior da instância não modifica um snapshot existente. +- Resultado preserva as revisões usadas pelo job. + +### Unidade: frames em voo + +- Cada frame index seleciona seu próprio slot e buffer. +- Reutilização limpa apenas o slot seguro. +- Upload de um frame não altera os dados GPU de outro frame em voo. +- Frame serial é global e monotônico. +- Texture readiness usa o serial gráfico, não o serial do Aether. + +### Integração: submissão e rendering + +- Um frame com uma cena mantém o resultado visual atual do Aether. +- Múltiplas cenas compartilham a mesma `FrameTable`. +- Índices locais de geometria não colidem entre cenas. +- Material repetido ocupa uma única entrada na tabela. +- Frame sem cenas não grava draw commands. +- Cena vazia não impede outras cenas de renderizar. +- Capacidade insuficiente é tratada explicitamente. +- `MaterialCount`, `BatchCount` e `DrawCount` representam o frame global. + +### Integração: preparação assíncrona + +- Primeiro frame pode preparar materiais sincronamente. +- Material novo depois do bootstrap não bloqueia o frame. +- Material pendente usa fallback. +- Refresh pendente usa último proxy válido. +- Resultado concluído passa a ser usado em um frame posterior. +- Falha de um material não impede outros materiais de renderizar. +- Shutdown com jobs pendentes não acessa objetos destruídos. + +### Ordem e sincronização + +- Simulação do Aether é executada antes dos draws que consomem seus buffers. +- Barreiras compute-to-graphics continuam válidas após separar os command buffers. +- Material rendering ocorre antes ou depois da GUI conforme a ordem definida. +- Nenhum worker grava command buffer gráfico ou modifica o frame corrente. + +### Plataformas + +- Compilar e validar comportamento em macOS e Windows. +- Confirmar a thread-safety real dos trechos executados em workers em ambas as + plataformas. +- Verificar símbolos que cruzam a fronteira da DLL e aplicar `ELIXIR_API` aos + tipos e funções públicos necessários. +- Confirmar que completion queue, snapshots e resultados não dependem de + comportamento específico do linker do macOS. +- Executar testes relevantes no CI Windows. +- Validar lifetime de command buffers, buffers por frame e recursos Vulkan. + +## Critérios de conclusão + +O trabalho deste plano está concluído quando: + +1. `Application` controla um único ciclo de materiais por frame; +2. Aether somente simula, constrói e submete `MaterialRenderScene`; +3. consumidores submetem `MaterialInstance` e não conhecem proxies ou handles; +4. `MaterialSystem` mantém cache e estado de preparação internamente; +5. materiais iniciais podem ser preparados sincronamente; +6. preparações posteriores são executadas em workers sem bloquear frames; +7. fallback ou último proxy válido é usado durante preparação; +8. resultados obsoletos são descartados com segurança; +9. slots e buffers por frame em voo são independentes; +10. `FrameTable` é construída uma vez para todas as cenas do frame; +11. serial de textura e frame pertence ao domínio gráfico; +12. o renderer interno existente continua sendo o único executor interno; +13. nenhuma API de meshes ou primitives é adicionada; +14. testes relevantes passam em macOS e Windows. diff --git a/Editor/Source/Editor.cpp b/Editor/Source/Editor.cpp index 8bc37771..fc6bcbe0 100644 --- a/Editor/Source/Editor.cpp +++ b/Editor/Source/Editor.cpp @@ -45,10 +45,10 @@ void Editor::OnGUI(const Timestep frameTime) m_EditorUI->Update(frameTime); } -void Editor::OnRender(const Timestep frameTime) +void Editor::Render(const Timestep frameTime) { EE_PROFILE_ZONE_SCOPED() - Application::OnRender(frameTime); + Application::Render(frameTime); m_GraphicsContext->Clear(); } diff --git a/Editor/Source/Editor.h b/Editor/Source/Editor.h index a59767f8..4f89b49d 100644 --- a/Editor/Source/Editor.h +++ b/Editor/Source/Editor.h @@ -11,7 +11,7 @@ class Editor final : public Elixir::Application ~Editor() override; void OnGUI(Timestep frameTime) override; - void OnRender(Timestep frameTime) override; + void Render(Timestep frameTime) override; void OnEvent(Event& event) override; diff --git a/Editor/Source/UI/Panels/ViewportPanel.h b/Editor/Source/UI/Panels/ViewportPanel.h index fc90df4f..1f7c19ba 100644 --- a/Editor/Source/UI/Panels/ViewportPanel.h +++ b/Editor/Source/UI/Panels/ViewportPanel.h @@ -2,6 +2,7 @@ #include "../EditorPanel.h" +#include #include #include diff --git a/Elixir/.clang-tidy b/Elixir/.clang-tidy index 531c06dd..a921f0ea 100644 --- a/Elixir/.clang-tidy +++ b/Elixir/.clang-tidy @@ -1,6 +1,7 @@ Checks: > -*, readability-identifier-naming + '-modernize-use-nodiscard' CheckOptions: - key: readability-identifier-naming.EnumCase diff --git a/Elixir/Source/Engine.h b/Elixir/Source/Engine.h index a93a5ed9..3ca022df 100644 --- a/Elixir/Source/Engine.h +++ b/Elixir/Source/Engine.h @@ -2,7 +2,6 @@ #include #include -#include #include #include #include @@ -46,6 +45,7 @@ #include #include #include +#include #include #include @@ -53,17 +53,12 @@ #include #include -#include -#include - #include #include #include #include #include -#include #include -#include #include -#include +#include diff --git a/Elixir/Source/Engine/Aether/ColorCurveStore.cpp b/Elixir/Source/Engine/Aether/Core/ColorCurveStore.cpp similarity index 94% rename from Elixir/Source/Engine/Aether/ColorCurveStore.cpp rename to Elixir/Source/Engine/Aether/Core/ColorCurveStore.cpp index f18c1126..28aa1f67 100644 --- a/Elixir/Source/Engine/Aether/ColorCurveStore.cpp +++ b/Elixir/Source/Engine/Aether/Core/ColorCurveStore.cpp @@ -1,7 +1,7 @@ #include "epch.h" #include "ColorCurveStore.h" -namespace Elixir::Aether +namespace Elixir::Aether::Core { void ColorCurveStore::SetCurve(std::string name, std::vector samples) { diff --git a/Elixir/Source/Engine/Aether/ColorCurveStore.h b/Elixir/Source/Engine/Aether/Core/ColorCurveStore.h similarity index 96% rename from Elixir/Source/Engine/Aether/ColorCurveStore.h rename to Elixir/Source/Engine/Aether/Core/ColorCurveStore.h index 65e0ef7d..633f313b 100644 --- a/Elixir/Source/Engine/Aether/ColorCurveStore.h +++ b/Elixir/Source/Engine/Aether/Core/ColorCurveStore.h @@ -2,7 +2,7 @@ #include -namespace Elixir::Aether +namespace Elixir::Aether::Core { struct SGPUColorCurve { diff --git a/Elixir/Source/Engine/Aether/CurveStore.cpp b/Elixir/Source/Engine/Aether/Core/CurveStore.cpp similarity index 94% rename from Elixir/Source/Engine/Aether/CurveStore.cpp rename to Elixir/Source/Engine/Aether/Core/CurveStore.cpp index 6d3e976e..d9711c9c 100644 --- a/Elixir/Source/Engine/Aether/CurveStore.cpp +++ b/Elixir/Source/Engine/Aether/Core/CurveStore.cpp @@ -1,7 +1,7 @@ #include "epch.h" #include "CurveStore.h" -namespace Elixir::Aether +namespace Elixir::Aether::Core { void CurveStore::SetCurve(std::string name, std::vector samples) { diff --git a/Elixir/Source/Engine/Aether/CurveStore.h b/Elixir/Source/Engine/Aether/Core/CurveStore.h similarity index 94% rename from Elixir/Source/Engine/Aether/CurveStore.h rename to Elixir/Source/Engine/Aether/Core/CurveStore.h index 4baf1773..d85d457c 100644 --- a/Elixir/Source/Engine/Aether/CurveStore.h +++ b/Elixir/Source/Engine/Aether/Core/CurveStore.h @@ -1,8 +1,8 @@ #pragma once -#include +#include -namespace Elixir::Aether +namespace Elixir::Aether::Core { struct SGPUCurve { diff --git a/Elixir/Source/Engine/Aether/ParameterStore.cpp b/Elixir/Source/Engine/Aether/Core/ParameterStore.cpp similarity index 97% rename from Elixir/Source/Engine/Aether/ParameterStore.cpp rename to Elixir/Source/Engine/Aether/Core/ParameterStore.cpp index 05f64820..2853764a 100644 --- a/Elixir/Source/Engine/Aether/ParameterStore.cpp +++ b/Elixir/Source/Engine/Aether/Core/ParameterStore.cpp @@ -1,7 +1,7 @@ #include "epch.h" #include "ParameterStore.h" -namespace Elixir::Aether +namespace Elixir::Aether::Core { float ParameterStore::GetFloat(const std::string& name, const float fallback) const { diff --git a/Elixir/Source/Engine/Aether/ParameterStore.h b/Elixir/Source/Engine/Aether/Core/ParameterStore.h similarity index 98% rename from Elixir/Source/Engine/Aether/ParameterStore.h rename to Elixir/Source/Engine/Aether/Core/ParameterStore.h index 688beb92..764ede31 100644 --- a/Elixir/Source/Engine/Aether/ParameterStore.h +++ b/Elixir/Source/Engine/Aether/Core/ParameterStore.h @@ -2,7 +2,7 @@ #include -namespace Elixir::Aether +namespace Elixir::Aether::Core { struct SGPUParameter { diff --git a/Elixir/Source/Engine/Aether/Core/Particle.h b/Elixir/Source/Engine/Aether/Core/Particle.h new file mode 100644 index 00000000..d049f794 --- /dev/null +++ b/Elixir/Source/Engine/Aether/Core/Particle.h @@ -0,0 +1,60 @@ +#pragma once + +namespace Elixir::Aether::Core +{ + enum class EParticleAttribute : uint32_t + { + None = 0, + Position, + Rotation, + Scale, + Velocity, + Color, + Size, + Lifetime, + Tangent, + RibbonId, + Temp0, + Temp1, + Temp2, + Temp3, + }; + + enum class EParticleRenderMode : uint8_t + { + Sprite = 0, + Ribbon = 1, + Mesh = 2 + }; + + enum class EParticleSimulationSpace : uint8_t + { + World = 0, + Local = 1, + }; + + enum class EParticleOp : uint32_t + { + SetLiteral = 0, + RandomRange, + SampleDisk, + SampleCone, + SampleBox, + AddWithDelta, + Dampen, + LerpOverLife, + KillOutsideBounds, + AddFromAttribute, + SetPositionOnCircle, + SetPositionCircularPath, + SetPositionVortexRibbonPath, + SetRibbonIdFromSpawnOrder, + SampleCurve, + SampleColorCurve, + Add, + Mul, + Clamp, + CopyFromAttribute, + ApplyVortex, + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Aether/ParticleStateLayout.cpp b/Elixir/Source/Engine/Aether/Core/ParticleStateLayout.cpp similarity index 89% rename from Elixir/Source/Engine/Aether/ParticleStateLayout.cpp rename to Elixir/Source/Engine/Aether/Core/ParticleStateLayout.cpp index 1bf99e7d..92a24438 100644 --- a/Elixir/Source/Engine/Aether/ParticleStateLayout.cpp +++ b/Elixir/Source/Engine/Aether/Core/ParticleStateLayout.cpp @@ -1,9 +1,9 @@ #include "epch.h" #include "ParticleStateLayout.h" -namespace Elixir::Aether +namespace Elixir::Aether::Core { - ParticleStateLayoutRegistry::ParticleStateLayoutRegistry(uint32_t particleCapacity) + ParticleStateLayoutRegistry::ParticleStateLayoutRegistry(const uint32_t particleCapacity) { const bool registered = Register({ .Key = EParticleStateLayout::CoreV1, diff --git a/Elixir/Source/Engine/Aether/Core/ParticleStateLayout.h b/Elixir/Source/Engine/Aether/Core/ParticleStateLayout.h new file mode 100644 index 00000000..abf1efd5 --- /dev/null +++ b/Elixir/Source/Engine/Aether/Core/ParticleStateLayout.h @@ -0,0 +1,88 @@ +#pragma once + +namespace Elixir::Aether::Core +{ + /** + * @brief Identifies a GPU particle-state layout supported by Aether. + * + * A layout defines the binary representation of one particle state in GPU + * memory. The renderer selects compatible buffers, shaders, and pipelines from + * this value. + * + * @note Layout values are part of the CPU-to-GPU contract and must remain + * compatible with their corresponding shader declarations. + */ + enum class EParticleStateLayout : uint8_t + { + CoreV1 = 0 + }; + + // CoreV1 is six float4 values in both C++ and HLSL. + constexpr uint32_t PARTICLE_STATE_CORE_V1_STRIDE = sizeof(glm::vec4) * 6; + + /** + * @brief Describes the GPU memory requirements for one particle-state layout. + * + * The descriptor identifies a layout, the byte stride of one particle state, + * and the maximum number of states that the renderer can allocate. + */ + struct SParticleStateLayoutDescriptor + { + EParticleStateLayout Key = EParticleStateLayout::CoreV1; + uint32_t ParticleStateStride = 0; + uint32_t ParticleCapacity = 0; + }; + + /** + * @brief Stores the particle-state layouts available to the Aether renderer. + * + * The registry is initialized before renderer resources are created. Each + * registered descriptor must have compatible GPU buffers, shaders, and + * pipelines in the renderer. + * + * @thread_safety Immutable after renderer initialization. + */ + class ELIXIR_API ParticleStateLayoutRegistry final + { + public: + /** + * @brief Creates the registry with the built-in particle-state layouts. + * + * @param particleCapacity Maximum particle capacity assigned to each built-in + * layout. + */ + explicit ParticleStateLayoutRegistry(uint32_t particleCapacity); + + /** + * @brief Registers one particle-state layout descriptor. + * + * @param descriptor Layout descriptor to register. + * @return True when the descriptor was registered. + * @return False when its key is already registered or its requirements are + * invalid. + * + * @pre Register layouts before renderer initialization. + */ + bool Register(SParticleStateLayoutDescriptor descriptor); + + /** + * @brief Finds the descriptor for a particle-state layout. + * + * @param key Layout to find. + * @return The matching descriptor, or null when the layout is unsupported. + */ + const SParticleStateLayoutDescriptor* Find(EParticleStateLayout key) const; + + /** + * @brief Returns all registered particle-state layout descriptors. + * @return Read-only descriptors in registration order. + */ + const std::vector& GetDescriptors() const + { + return m_Descriptors; + } + + private: + std::vector m_Descriptors; + }; +} diff --git a/Elixir/Source/Engine/Aether/Core/ResourceAllocation.h b/Elixir/Source/Engine/Aether/Core/ResourceAllocation.h new file mode 100644 index 00000000..a5f2cc50 --- /dev/null +++ b/Elixir/Source/Engine/Aether/Core/ResourceAllocation.h @@ -0,0 +1,62 @@ +#pragma once + +namespace Elixir::Aether::Core +{ + /** + * @brief Identifies a contiguous range in an Aether resource table. + * + * The range is expressed in elements, not bytes. Its offset is relative to the + * beginning of the table that owns it. + */ + struct SBufferRange + { + uint32_t Offset = 0; + uint32_t Count = 0; + + explicit operator bool() const { return Count != 0; } + }; + + /** + * @brief Defines the logical capacities available to Aether system instances. + * + * These limits bound the shared ranges allocated for compiled systems. They do + * not create GPU buffers; the renderer creates GPU resources compatible with + * these capacities. + */ + struct SResourcePoolLimits + { + uint32_t MaxSystemInstances = 256; + uint32_t ParticleCapacity = 1'000'000; + uint32_t EmitterCapacity = 4'096; + uint32_t OpCapacity = 65'536; + uint32_t ParameterCapacity = 16'384; + uint32_t TriggerTargetCapacity = 4'096; + uint32_t TriggerEventCapacityPerEmitter = 64; + }; + + /** + * @brief Stores the logical resource ranges assigned to one system instance. + * + * ResourcePool creates this allocation for a compiled system. The renderer + * uses its ranges to upload system data and address the matching GPU tables. + * + * @note Ranges are valid only while the allocation remains owned by the pool. + */ + struct SSystemInstanceAllocation + { + uint32_t InstanceIndex = UINT32_MAX; + EParticleStateLayout ParticleStateLayout = EParticleStateLayout::CoreV1; + uint32_t Generation = 1; + + SBufferRange Particles; + SBufferRange Emitters; + SBufferRange Ops; + SBufferRange Parameters; + SBufferRange TriggerTargets; + + SBufferRange EmitterStates; + SBufferRange SpawnRequests; + SBufferRange TriggerEvents; + SBufferRange TriggerQueueStates; + }; +} diff --git a/Elixir/Source/Engine/Aether/Effect.h b/Elixir/Source/Engine/Aether/Effect.h deleted file mode 100644 index ff74c561..00000000 --- a/Elixir/Source/Engine/Aether/Effect.h +++ /dev/null @@ -1,8 +0,0 @@ -#pragma once - -#include - -namespace Elixir::Aether -{ - ELIXIR_API Ref LoadEffectFile(const std::filesystem::path& filepath); -} \ No newline at end of file diff --git a/Elixir/Source/Engine/Aether/Effect.cpp b/Elixir/Source/Engine/Aether/Effect/Effect.cpp similarity index 94% rename from Elixir/Source/Engine/Aether/Effect.cpp rename to Elixir/Source/Engine/Aether/Effect/Effect.cpp index 8e6cd68a..f6579739 100644 --- a/Elixir/Source/Engine/Aether/Effect.cpp +++ b/Elixir/Source/Engine/Aether/Effect/Effect.cpp @@ -2,13 +2,13 @@ #include "Effect.h" #include - -#include - -#include #include +#include -namespace Elixir::Aether +#include +#include + +namespace Elixir::Aether::Effect { namespace { @@ -45,7 +45,7 @@ namespace Elixir::Aether { public: explicit EffectParser(std::filesystem::path filepath) - : m_Filepath(std::move(filepath)) {} + : m_Filepath(std::move(filepath)) {} Ref Parse(od::object& root); @@ -842,13 +842,66 @@ namespace Elixir::Aether } } + glm::vec4 ResolveMaterialColor( + const Float4Field& field, + const Emitter& emitter, + const System& system + ) const + { + if (field.Param.empty()) return field.Value; + + const auto systemValue = system.GetParameters().GetFloat4( + field.Param, + field.Value + ); + + return emitter.GetParameters().GetFloat4(field.Param, systemValue); + } + + std::optional ParseMaterial( + od::object& json, + const Emitter& emitter, + const System& system + ) + { + auto field = json["material"]; + if (field.error()) return std::nullopt; + + SMaterialDescription desc{}; + + od::object material; + if (field.get_object().get(material)) + { + Fail("'material' must be an object."); + return std::nullopt; + } + + const auto color = ResolveMaterialColor( + ParseFloat4(material, "color", glm::vec4(1.0f)), + emitter, + system + ); + + const auto emissive = ResolveMaterialColor( + ParseFloat4(material, "emissive", glm::vec4(0.0f)), + emitter, + system + ); + + desc.BaseColor = glm::vec3(color); + desc.Opacity = color.w; + desc.Emissive = glm::vec3(emissive); + desc.BaseColorTexturePath = ParseString(material, "texture"); + + return desc; + } + void ParseEmitter(const Ref& system, od::object& json) { if (m_Failed) return; const std::string name = RequireString(json, "name"); const auto renderMode = ParseRenderMode(json, "renderMode"); - const auto spriteTexture = ParseString(json, "spriteTexture", ""); const uint32_t maxParticles = RequireUInt(json, "maxParticles"); const auto spawnRate = ParseScalar(json, "spawnRate"); @@ -857,6 +910,9 @@ namespace Elixir::Aether auto& emitter = system->AddEmitter(name, maxParticles, spawnRate.Value); emitter.SetRenderMode(renderMode); + LoadParameters(json, emitter.GetParameters()); + if (m_Failed) return; + if (HasField(json, "burst")) { od::object burst; @@ -891,12 +947,8 @@ namespace Elixir::Aether if (m_Failed) return; - if (!spriteTexture.empty()) - { - const auto texture = TextureLoader::Load(spriteTexture); - const auto tex2d = std::static_pointer_cast(texture); - emitter.SetSpriteTexture(tex2d); - } + if (const auto material = ParseMaterial(json, emitter, *system)) + emitter.SetMaterialDescription(std::move(*material)); if (!spawnRate.Param.empty()) emitter.SetSpawnRateParamName(spawnRate.Param); diff --git a/Elixir/Source/Engine/Aether/Effect/Effect.h b/Elixir/Source/Engine/Aether/Effect/Effect.h new file mode 100644 index 00000000..fc6782ed --- /dev/null +++ b/Elixir/Source/Engine/Aether/Effect/Effect.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +namespace Elixir::Aether::Effect +{ + /** + * @brief Loads an Aether effect asset into mutable authoring data. + * + * The function parses an effect file and creates its System, emitters, modules, + * parameters, curves, and serialized material descriptions. It does not resolve + * materials, compile the system, allocate GPU resources, or create a runtime + * SystemInstance. + * + * @param filepath Path to the effect asset to load. + * @return The parsed System when loading succeeds. + * @return Null when the file cannot be read or contains invalid effect data. + */ + ELIXIR_API Ref LoadEffectFile(const std::filesystem::path& filepath); +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Aether/Effect/MaterialDescription.h b/Elixir/Source/Engine/Aether/Effect/MaterialDescription.h new file mode 100644 index 00000000..bc9819e4 --- /dev/null +++ b/Elixir/Source/Engine/Aether/Effect/MaterialDescription.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +namespace Elixir::Aether::Effect +{ + /** + * @brief Stores material authoring data serialized in an Aether effect. + * + * This structure represents the material data embedded in an effect asset. It + * is not a runtime Material, MaterialInstance, or GPU render proxy. + * + * Effect::MaterialResolver converts this description into a material when the + * owning System is compiled. + */ + struct SMaterialDescription + { + glm::vec3 BaseColor{ 1.0f }; + float Opacity = 1.0f; + glm::vec3 Emissive{ 0.0f }; + std::string BaseColorTexturePath; + }; +} diff --git a/Elixir/Source/Engine/Aether/Effect/MaterialFactory.cpp b/Elixir/Source/Engine/Aether/Effect/MaterialFactory.cpp new file mode 100644 index 00000000..43aa3aaa --- /dev/null +++ b/Elixir/Source/Engine/Aether/Effect/MaterialFactory.cpp @@ -0,0 +1,89 @@ +#include "epch.h" +#include "MaterialFactory.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace Elixir::Aether::Effect +{ + using namespace Elixir::Materials::Nodes; + + EMaterialUsage GetMaterialUsage(const Core::EParticleRenderMode mode) + { + switch (mode) + { + case Core::EParticleRenderMode::Sprite: return EMaterialUsage::ParticleSprite; + case Core::EParticleRenderMode::Ribbon: return EMaterialUsage::ParticleRibbon; + case Core::EParticleRenderMode::Mesh: return EMaterialUsage::ParticleMesh; + } + + return EMaterialUsage::ParticleSprite; + } + + Ref CreateMaterial( + std::string name, + const Core::EParticleRenderMode renderMode, + const SMaterialDescription& desc + ) + { + const auto material = CreateRef(std::move(name)); + + const auto result = material->SetUsage(GetMaterialUsage(renderMode), true); + EE_CORE_ASSERT(result, "Particle material usage must be enabled.") + + MaterialGraph graph; + + const auto baseColor = graph.AddNode( + glm::vec4{ desc.BaseColor, 0.0f }, + EMaterialValueType::Float3 + ); + graph.SetChannel(EMaterialChannel::BaseColor, baseColor); + + const auto opacity = graph.AddNode( + glm::vec4{ desc.Opacity, 0.0f, 0.0f, 0.0f }, + EMaterialValueType::Float + ); + graph.SetChannel(EMaterialChannel::Opacity, opacity); + + const auto emissive = graph.AddNode( + glm::vec4{ desc.Emissive, 0.0f }, + EMaterialValueType::Float3 + ); + graph.SetChannel(EMaterialChannel::Emissive, emissive); + + if (renderMode == Core::EParticleRenderMode::Sprite && + !desc.BaseColorTexturePath.empty()) + { + constexpr auto texParam = "BaseColorTexture"; + const auto tex = TextureLoader::Load(desc.BaseColorTexturePath); + material->DefineParameter(texParam, { + .Kind = EMaterialParameterKind::Texture, + .DefaultValue = SMaterialParameter::MakeTexture(tex), + }); + + const auto texture = graph.AddNode(texParam); + const auto alpha = graph.AddNode(3); + graph.Connect(texture, alpha, 0); + + const auto baseColorMul = graph.AddNode(); + graph.Connect(baseColor, baseColorMul, 0); + graph.Connect(texture, baseColorMul, 1); + graph.SetChannel(EMaterialChannel::BaseColor, baseColorMul); + + const auto opacityMul = graph.AddNode(); + graph.Connect(opacity, opacityMul, 0); + graph.Connect(alpha, opacityMul, 1); + graph.SetChannel(EMaterialChannel::Opacity, opacityMul); + } + + material->SetGraph(std::move(graph)); + return material; + } +} diff --git a/Elixir/Source/Engine/Aether/Effect/MaterialFactory.h b/Elixir/Source/Engine/Aether/Effect/MaterialFactory.h new file mode 100644 index 00000000..babe086d --- /dev/null +++ b/Elixir/Source/Engine/Aether/Effect/MaterialFactory.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include + +namespace Elixir::Aether::Effect +{ + using namespace Materials; + + /** + * @brief Returns the material usage required by an Aether render mode. + * + * @param mode Particle geometry mode selected by an emitter. + * @return Material usage compatible with the selected render mode. + * + * @note An invalid enum value falls back to EMaterialUsage::ParticleSprite. + */ + EMaterialUsage GetMaterialUsage(Core::EParticleRenderMode mode); + + /** + * @brief Creates a material from Aether effect authoring data. + * + * The function creates a raw Material with the usage required by renderMode and + * builds its material graph from desc. BaseColor, Opacity, and Emissive become + * constant graph inputs. + * + * For sprite emitters, a non-empty BaseColorTexturePath creates a texture + * parameter and multiplies its sampled RGB and alpha values into BaseColor and + * Opacity, respectively. + * + * @param name Name assigned to the created material. + * @param renderMode Particle geometry mode that determines material usage. + * @param desc Serialized material data from the effect asset. + * @return A new unregistered Material. + * + * @note The caller owns registration and later creation of a MaterialInstance. + */ + Ref CreateMaterial( + std::string name, + Core::EParticleRenderMode renderMode, + const SMaterialDescription& desc + ); +} diff --git a/Elixir/Source/Engine/Aether/Effect/MaterialResolver.cpp b/Elixir/Source/Engine/Aether/Effect/MaterialResolver.cpp new file mode 100644 index 00000000..bf6a7732 --- /dev/null +++ b/Elixir/Source/Engine/Aether/Effect/MaterialResolver.cpp @@ -0,0 +1,50 @@ +#include "epch.h" +#include "MaterialResolver.h" + +#include +#include +#include + +namespace Elixir::Aether::Effect +{ + MaterialResolver::MaterialResolver(MaterialRegistry& registry) + : m_Registry(registry) {} + + bool MaterialResolver::Resolve(const System& system) const + { + for (const auto& emitter : system.GetEmitters()) + { + // A caller may replace an effect-authored instance before creating a + // SystemInstance. Do not overwrite that explicit choice. + if (emitter->GetMaterial()) + continue; + + Ref material; + + if (const auto& desc = emitter->GetMaterialDescription()) + { + const auto name = "Aether." + system.GetId() + "." + emitter->GetName(); + + material = m_Registry.Find(name); + if (!material) + { + material = CreateMaterial(name, emitter->GetRenderMode(), *desc); + if (!m_Registry.Register(material)) + { + EE_CORE_ERROR("Aether material '{}' could not be registered.", name) + return false; + } + } + } + else + { + material = m_Registry.GetDefault(GetMaterialUsage(emitter->GetRenderMode())); + } + + emitter->SetMaterial(material); + if (!emitter->GetMaterial()) return false; + } + + return true; + } +} diff --git a/Elixir/Source/Engine/Aether/Effect/MaterialResolver.h b/Elixir/Source/Engine/Aether/Effect/MaterialResolver.h new file mode 100644 index 00000000..1e5f9b4b --- /dev/null +++ b/Elixir/Source/Engine/Aether/Effect/MaterialResolver.h @@ -0,0 +1,50 @@ +#pragma once + +namespace Elixir::Materials { class MaterialRegistry; } +namespace Elixir::Aether { class System; } + +namespace Elixir::Aether::Effect +{ + using namespace Elixir::Materials; + + /** + * @brief Resolves effect-authored material data into emitter material instances. + * + * The resolver bridges Aether::Effect authoring data and the application-wide + * material system. It creates materials for serialized SMaterialDescription + * values, retrieves default materials when no description is present, and + * assigns an instance to each unresolved emitter. + * + * The resolver does not compile materials or create GPU render proxies. + * MaterialSystem performs those operations during System compilation. + * + * @note The referenced MaterialRegistry must outlive this resolver. + */ + class ELIXIR_API MaterialResolver final + { + public: + /** + * @brief Creates a resolver backed by the application material registry. + * + * @param registry Registry used to find, register, and retrieve materials. + */ + explicit MaterialResolver(MaterialRegistry& registry); + + /** + * @brief Resolves material instances for every emitter in an effect system. + * + * Emitters that already have a material instance keep their explicit + * selection. For other emitters, the resolver creates or reuses an + * effect-authored material when SMaterialDescription exists; otherwise, it + * assigns the default material for the emitter render mode. + * + * @param system Effect system whose emitters require material instances. + * @return True when every emitter has a resolved material instance. + * @return False when a material cannot be created, registered, or assigned. + */ + bool Resolve(const System& system) const; + + private: + MaterialRegistry& m_Registry; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Aether/Emitter.cpp b/Elixir/Source/Engine/Aether/Emitter.cpp index b0ee7200..3589190e 100644 --- a/Elixir/Source/Engine/Aether/Emitter.cpp +++ b/Elixir/Source/Engine/Aether/Emitter.cpp @@ -1,8 +1,6 @@ #include "epch.h" #include "Emitter.h" -#include "System.h" - namespace Elixir::Aether { Emitter::Emitter( @@ -13,6 +11,17 @@ namespace Elixir::Aether m_MaxParticles(maxParticles), m_SpawnRate(spawnRate) {} + void Emitter::SetMaterial(const Ref& material) + { + if (!material) + { + EE_CORE_ERROR("Trying to set a null material to emitter.") + return; + } + + SetMaterial(material->CreateInstance()); + } + void Emitter::SetBurst(const uint32_t count, const float intervalSeconds) { m_BurstCount = count; @@ -33,10 +42,8 @@ namespace Elixir::Aether { SCompiledEmitter emitter; emitter.Id = m_Id; - emitter.Name = m_Name; emitter.RenderMode = m_RenderMode; emitter.SimulationSpace = m_SimulationSpace; - emitter.SpriteTexture = m_SpriteTexture; emitter.MaxParticles = m_MaxParticles; emitter.GravityScale = paramStore.GetFloat("GravityScale", 1.0f); emitter.SpawnOpOffset = (uint32_t)ops.size(); @@ -50,6 +57,9 @@ namespace Elixir::Aether if (spawnRateParamIndex != UINT32_MAX) emitter.SpawnRatePerSecond = params[spawnRateParamIndex].Value.x; + if (m_Material) + emitter.Material = m_Material; + for (const auto& module : m_SpawnModules) { if (const auto* typed = dynamic_cast(module.get())) diff --git a/Elixir/Source/Engine/Aether/Emitter.h b/Elixir/Source/Engine/Aether/Emitter.h index 35366fdb..251b338d 100644 --- a/Elixir/Source/Engine/Aether/Emitter.h +++ b/Elixir/Source/Engine/Aether/Emitter.h @@ -1,22 +1,43 @@ #pragma once #include -#include -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include +#include namespace Elixir::Aether { - class ParameterStore; + using namespace Core; + using namespace Modules; + using namespace Materials; + using namespace Materials::Rendering; + /** + * @brief Stores immutable GPU-ready data for one compiled emitter. + * + * System::Compile() creates this structure from an authored Emitter. The + * particle renderer uses its ranges, material proxy, and render settings to + * simulate and draw the emitter. + * + * Offsets refer to data owned by the containing SCompiledSystem unless stated + * otherwise. They are not physical GPU addresses. + * + * @note Equality and hashing use ID only. Each compiled emitter must keep the + * UUID of its authored source emitter. + */ struct SCompiledEmitter { UUID Id; - std::string Name; EParticleRenderMode RenderMode = EParticleRenderMode::Sprite; EParticleSimulationSpace SimulationSpace = EParticleSimulationSpace::World; - Ref SpriteTexture; + + // Material instance submitted to the material system with each frame. + Ref Material; float SpawnRatePerSecond = 1.0f; uint32_t BurstCount = 0u; @@ -44,7 +65,7 @@ namespace Elixir::Aether return Id == other.Id; } - auto GetHashParams() const + UUID GetHashParams() const { return Id; } @@ -55,14 +76,30 @@ GENERATE_HASH_FUNCTION(Elixir::Aether::SCompiledEmitter) namespace Elixir::Aether { + /** + * @brief Defines one particle source within an authored Aether system. + * + * An emitter owns spawn and update modules, local parameters and curves, and + * material selection data. Compile() converts this authoring data into an + * SCompiledEmitter and appends its GPU operations to the parent system. + * + * An emitter does not own runtime particle state. The renderer allocates that + * state after its parent system is compiled and instantiated. + * + * @note An emitter is movable but not copyable. Its UUID identifies the + * emitter across compiled system revisions. + */ class ELIXIR_API Emitter final { + friend class System; + public: /** - * Create a new Emitter. - * @param name Emitter name. - * @param maxParticles Max particles in the emitter. - * @param spawnRate Spawn rate per second. + * @brief Creates an emitter with a default spawn rate. + * + * @param name Display name for the emitter. + * @param maxParticles Maximum number of particles the emitter can own. + * @param spawnRate Default spawn rate in particles per second. */ Emitter(const std::string& name, uint32_t maxParticles, float spawnRate); @@ -72,6 +109,16 @@ namespace Elixir::Aether Emitter(const Emitter&) = delete; Emitter& operator=(const Emitter&) = delete; + /** + * @brief Adds a module that runs when particles spawn. + * + * The emitter owns the returned module. + * + * @tparam Module A type derived from ParticleSpawnModule. + * @tparam Args Constructor argument types for Module. + * @param args Arguments forwarded to the module constructor. + * @return The newly created spawn module. + */ template Module& AddSpawnModule(Args&&... args) { @@ -81,6 +128,16 @@ namespace Elixir::Aether return ref; } + /** + * @brief Adds a module that runs while particles update. + * + * The emitter owns the returned module. + * + * @tparam Module A type derived from ParticleUpdateModule. + * @tparam Args Constructor argument types for Module. + * @param args Arguments forwarded to the module constructor. + * @return The newly created update module. + */ template Module& AddUpdateModule(Args&&... args) { @@ -90,56 +147,200 @@ namespace Elixir::Aether return ref; } + /** + * @brief Returns the stable identity of this emitter. + * @return The UUID of this emitter. + */ + const UUID& GetId() const { return m_Id; } + + /** + * @brief Returns the display name of this emitter. + * @return The authored emitter name. + */ + const std::string& GetName() const { return m_Name; } + + /** + * @brief Returns the geometry mode used to render this emitter. + * @return The selected particle render mode. + */ + EParticleRenderMode GetRenderMode() const { return m_RenderMode; } + + /** + * @brief Sets the geometry mode used to render this emitter. + * @param mode Particle render mode to use during compilation. + * @note The emitter's material must support this mode. + */ void SetRenderMode(const EParticleRenderMode mode) { m_RenderMode = mode; } + /** + * @brief Returns the simulation space used by this emitter. + * @return The selected simulation space. + */ EParticleSimulationSpace GetSimulationSpace() const { return m_SimulationSpace; } + + /** + * @brief Sets the simulation space used by this emitter. + * @param space World or local particle simulation space. + */ void SetSimulationSpace(const EParticleSimulationSpace space) { m_SimulationSpace = space; } - void SetBurst(uint32_t count, float intervalSeconds); + /** + * @brief Returns the maximum particle capacity. + * @return Maximum number of particles owned by this emitter. + */ + uint32_t GetMaxParticles() const { return m_MaxParticles; } - void SetTriggerEmitter(std::string emitterName, float delaySeconds); + /** + * @brief Returns the material data parsed from an effect asset. + * @return The authored material description, when one exists. + * @note This is effect-format data, not a Material instance. + */ + const std::optional& GetMaterialDescription() const + { + return m_MaterialDescription; + } - SCompiledEmitter Compile( - const ParameterStore& paramStore, - const std::vector& params, - std::vector& ops - ) const; + /** + * @brief Stores material data parsed from an effect asset. + * @param description Effect-format material data for this emitter. + * @note Effect::MaterialResolver converts this data into a material instance. + */ + void SetMaterialDescription(Effect::SMaterialDescription description) + { + m_MaterialDescription = std::move(description); + } - const std::string& GetName() const { return m_Name; } - uint32_t GetMaxParticles() const { return m_MaxParticles; } + /** + * @brief Returns the selected material instance. + * @return The material instance, or null when none has been assigned. + */ + const Ref& GetMaterial() const { return m_Material; } + + /** + * @brief Creates and assigns a default instance of material. + * @param material Material used to create the assigned instance. + * @note A null material logs an error and preserves the current selection. + */ + void SetMaterial(const Ref& material); - const Ref& GetSpriteTexture() const { return m_SpriteTexture; } - void SetSpriteTexture(const Ref& texture) { m_SpriteTexture = texture; } + /** + * @brief Assigns a material instance to this emitter. + * @param material Material instance to assign. + * @note Pass a null reference to clear the current selection. + */ + void SetMaterial(Ref material) { m_Material = std::move(material); } + + /** + * @brief Configure periodic burst emission. + * + * @param count Number of particles requested by each burst. + * @param intervalSeconds Time between consecutive bursts. + */ + void SetBurst(uint32_t count, float intervalSeconds); + /** + * @brief Makes this emitter react to another emitter's trigger events. + * + * The source name is resolved when the parent system is compiled. + * + * @param emitterName Name of the source emitter. + * @param delaySeconds Delay before the triggered burst is requested. + */ + void SetTriggerEmitter(std::string emitterName, float delaySeconds); + + /** + * @brief Returns the number of particles requested by each burst. + * @return Configured burst particle count. + */ uint32_t GetBurstCount() const { return m_BurstCount; } + + /** + * @brief Returns the time between periodic bursts. + * @return Burst interval in seconds. + */ float GetBurstIntervalSeconds() const { return m_BurstIntervalSeconds; } + /** + * @brief Returns the configured trigger source name. + * @return Source emitter name, or an empty string when no trigger is set. + */ const std::string& GetTriggerEmitterName() const { return m_TriggerEmitterName; } + + /** + * @brief Returns the delay applied after a trigger event. + * @return Trigger delay in seconds. + */ float GetTriggerDelaySeconds() const { return m_TriggerDelaySeconds; } + /** + * @brief Returns this emitter's parameter store. + * @return Mutable emitter-local parameters. + */ ParameterStore& GetParameters() { return m_Parameters; } + + /** + * @brief Returns this emitter's parameter store. + * @return Read-only emitter-local parameters. + */ const ParameterStore& GetParameters() const { return m_Parameters; } + + /** + * @brief Returns this emitter's scalar curve store. + * @return Mutable emitter-local scalar curves. + */ CurveStore& GetCurves() { return m_Curves; } + + /** + * @brief Returns this emitter's scalar curve store. + * @return Read-only emitter-local scalar curves. + */ const CurveStore& GetCurves() const { return m_Curves; } + + /** + * @brief Returns this emitter's color curve store. + * @return Mutable emitter-local color curves. + */ ColorCurveStore& GetColorCurves() { return m_ColorCurves; } + + /** + * @brief Returns this emitter's color curve store. + * @return Read-only emitter-local color curves. + */ const ColorCurveStore& GetColorCurves() const { return m_ColorCurves; } + /** + * @brief Returns the parameter name that overrides the spawn rate. + * @return Parameter name, or an empty string when no override is set. + */ const std::string& GetSpawnRateParamName() const { return m_SpawnRateParamName; } + + /** + * @brief Sets the parameter name that overrides the spawn rate. + * @param paramName System-level or emitter-local parameter name. + */ void SetSpawnRateParamName(const std::string& paramName) { m_SpawnRateParamName = paramName; } private: + // Compiles this emitter into internal GPU-ready runtime data. + SCompiledEmitter Compile( + const ParameterStore& paramStore, + const std::vector& params, + std::vector& ops + ) const; + UUID m_Id; std::string m_Name; EParticleRenderMode m_RenderMode = EParticleRenderMode::Sprite; EParticleSimulationSpace m_SimulationSpace = EParticleSimulationSpace::World; - Ref m_SpriteTexture; + std::optional m_MaterialDescription; + Ref m_Material; uint32_t m_MaxParticles; std::vector> m_SpawnModules; std::vector> m_UpdateModules; std::string m_SpawnRateParamName; - float m_SpawnRate = 0.0f; // per second + float m_SpawnRate = 0.0f; // particles per second uint32_t m_BurstCount = 0u; float m_BurstIntervalSeconds = 0.0f; std::string m_TriggerEmitterName; diff --git a/Elixir/Source/Engine/Aether/FrameSubmission.h b/Elixir/Source/Engine/Aether/FrameSubmission.h deleted file mode 100644 index 64053f13..00000000 --- a/Elixir/Source/Engine/Aether/FrameSubmission.h +++ /dev/null @@ -1,41 +0,0 @@ -#pragma once - -#include -#include - -#include - -namespace Elixir::Aether -{ - // Non-owning list of the system instances selected for one rendering frame. - // Submitted instances must remain alive and unchanged until Renderer::Render() - // returns. An instance can be submitted at most once per frame. - class ELIXIR_API FrameSubmission final - { - public: - bool Submit(const SystemInstance& instance) - { - const auto [_, inserted] = m_InstanceIds.insert(instance.GetId()); - if (!inserted) return false; - - m_Instances.push_back(&instance); - return true; - } - - void Reset() - { - m_Instances.clear(); - m_InstanceIds.clear(); - } - - bool IsEmpty() const { return m_Instances.empty(); } - - size_t GetInstanceCount() const { return m_Instances.size(); } - - const std::vector& GetInstances() const { return m_Instances; } - - private: - std::vector m_Instances; - std::unordered_set m_InstanceIds; - }; -} \ No newline at end of file diff --git a/Elixir/Source/Engine/Aether/Manager.cpp b/Elixir/Source/Engine/Aether/Manager.cpp new file mode 100644 index 00000000..2c3f138c --- /dev/null +++ b/Elixir/Source/Engine/Aether/Manager.cpp @@ -0,0 +1,115 @@ +#include "epch.h" +#include "Manager.h" + +#include +#include +#include +#include +#include + +namespace Elixir::Aether +{ + using namespace Effect; + + Manager::Manager( + const GraphicsContext* context, + const ShaderLoader* shaderLoader, + MaterialRegistry& materialRegistry, + MaterialSystem& materialSystem + ) : m_Runtime(CreateScope(materialRegistry)), + m_Simulator(CreateScope(context, shaderLoader)), + m_Renderer(CreateScope(context)), + m_MaterialSystem(materialSystem), + m_GraphicsContext(context) {} + + Manager::~Manager() = default; + + Ref Manager::LoadEffect(const std::filesystem::path& filepath) + { + return LoadEffectFile(filepath); + } + + bool Manager::Recompile(const Ref& system) + { + return GetRuntime().Recompile(system); + } + + bool Manager::Add(const Ref& instance) + { + return GetRuntime().Register(instance); + } + + bool Manager::Remove(const Ref& instance) + { + const auto detached = GetRuntime().Unregister(instance); + if (!detached) return false; + + m_PendingRetirements.Enqueue(detached); + + return true; + } + + void Manager::BeginFrame(const Timestep& timestep) + { + GetRuntime().PublishActiveInstances(); + GetSimulator().BeginFrame(timestep); + RetireDestroyedInstances(); + } + + void Manager::Render(const Camera& camera) + { + const auto submission = GetRuntime().AcquireSubmission(); + if (!submission) return; + + const auto cmd = m_GraphicsContext->GetSecondaryCommandBuffer(); + + cmd->Begin({ + .ColorAttachment = m_GraphicsContext->GetRenderTarget(), + .DepthStencilAttachment = m_GraphicsContext->GetDepthStencilRenderTarget(), + .RenderArea = m_GraphicsContext->GetRenderTarget()->GetExtent(), + }); + + const auto frame = GetSimulator().Simulate(*submission, cmd); + cmd->End(); + m_GraphicsContext->EnqueueSecondaryCommandBuffer(cmd); + + MaterialRenderScene scene = GetRenderer().BuildRenderScene(*frame, camera); + m_MaterialSystem.Submit(scene); + } + + const SSimulationMetrics& Manager::GetLastSimulationMetrics() const + { + return GetSimulator().GetLastMetrics(); + } + + const SRenderingMetrics& Manager::GetLastRenderingMetrics() const + { + return GetRenderer().GetLastMetrics(); + } + + InstanceRegistry& Manager::GetRuntime() const + { + EE_CORE_ASSERT(m_Runtime, "Aether runtime is unavailable.") + return *m_Runtime; + } + + Simulator& Manager::GetSimulator() const + { + EE_CORE_ASSERT(m_Simulator, "Aether simulator is unavailable.") + return *m_Simulator; + } + + Renderer& Manager::GetRenderer() const + { + EE_CORE_ASSERT(m_Renderer, "Aether renderer is unavailable.") + return *m_Renderer; + } + + void Manager::RetireDestroyedInstances() + { + const auto instances = m_PendingRetirements.Drain(); + + for (const auto& instance : instances) + GetSimulator().Retire(instance->GetKey()); + } +} diff --git a/Elixir/Source/Engine/Aether/Manager.h b/Elixir/Source/Engine/Aether/Manager.h new file mode 100644 index 00000000..015dc1c7 --- /dev/null +++ b/Elixir/Source/Engine/Aether/Manager.h @@ -0,0 +1,211 @@ +#pragma once + +#include +#include + +namespace Elixir +{ + class Camera; + class GraphicsContext; + class ShaderLoader; + class Timestep; + + namespace Materials + { + class MaterialSystem; + class MaterialRegistry; + + namespace Rendering + { + class Resolver; + class RenderContext; + } + } + + namespace Aether + { + namespace Runtime { class InstanceRegistry; } + + namespace Simulation + { + class Simulator; + struct SSimulationMetrics; + } + + namespace Rendering + { + class Renderer; + struct SRenderingMetrics; + } + } +} + +namespace Elixir::Aether +{ + using namespace Runtime; + using namespace Simulation; + using namespace Rendering; + using namespace Materials; + + /** + * @brief Coordinates Aether effects, runtime instances, simulation, and rendering. + * + * Manager is the application-scoped entry point for Aether. It loads effect + * assets, resolves their material definitions, compiles immutable system data, + * and manages runtime system instances. + * + * Manager owns Simulator and Renderer. Simulator owns particle allocations, + * simulation resources, compute pipelines, and deferred resource retirement. + * Renderer consumes immutable render frames and records particle draw commands. + * + * Systems create unregistered SystemInstance objects. Submit() registers each + * instance once and keeps it active until DestroyInstance() removes it. + * BeginFrame() publishes immutable state for every active instance. + * + * @note GraphicsContext, MaterialRegistry, and MaterialSystem must outlive + * this manager. + * + * @thread_safety Instance registration and frame-submission publication are + * synchronized. Call BeginFrame() and Render() from the render-frame path. + */ + class ELIXIR_API Manager final + { + public: + /** + * @brief Creates an Aether manager. + * + * @param context Graphics context used for simulation and rendering. + * @param shaderLoader Loader used to create the simulation shaders. + * @param materialRegistry Stores default and effect-generated materials. + * @param materialSystem Compiles and renders particle materials. + * + * @pre context is not null and outlives the manager. + * @pre shaderLoader is not null. + * @pre materialRegistry outlives the manager. + * @pre materialSystem outlives the manager. + */ + Manager( + const GraphicsContext* context, + const ShaderLoader* shaderLoader, + MaterialRegistry& materialRegistry, + MaterialSystem& materialSystem + ); + + /** + * @brief Destroys the manager and its simulation and rendering services. + */ + ~Manager(); + + Manager(const Manager&) = delete; + Manager& operator=(const Manager&) = delete; + Manager(Manager&&) = delete; + Manager& operator=(Manager&&) = delete; + + /** + * @brief Loads an effect asset into mutable authoring data. + * + * This function parses the effect file. It does not resolve materials or + * compile GPU-ready system data. + * + * @param filepath Path to the effect asset. + * @return The loaded system, or null when parsing fails. + */ + static Ref LoadEffect(const std::filesystem::path& filepath); + + /** + * @brief Recompiles a System and updates its registered instances. + * + * @param system System asset to recompile. + * @return True when compilation succeeds. + */ + bool Recompile(const Ref& system); + + /** + * @brief Registers an instance for persistent simulation and rendering. + * + * The method compiles and activates an instance created by + * System::CreateInstance(). A successful submission remains active in + * every frame until DestroyInstance() removes it. + * + * @param instance Unregistered runtime instance to activate. + * @return True when the instance was registered. + * @return False when the instance is null or was previously submitted. + * + * @thread_safety May be called from any thread. Calls are serialized with + * active-instance publication. + */ + bool Add(const Ref& instance); + + /** + * @brief Detaches a runtime instance from future frames. + * + * The method removes the instance from Manager and from the published + * submission. Simulator retires the GPU allocation during a later frame + * and releases it after the GPU finishes using it. + * + * @param instance Registered instance to destroy. + * @return True when the manager owned and detached the instance. + * @return False when instance is null or belongs to another manager. + * + * @note RegisterInstance() rejects this instance after the method returns true. + */ + bool Remove(const Ref& instance); + + /** + * @brief Prepares Aether for a new frame. + * + * The method captures all active instances, updates simulation time, + * releases completed allocations, and forwards destroyed instances to + * Simulator. + * + * @param timestep Elapsed time for the current frame. + * + * @note Call this method once per frame, after the graphics context prepares + * the current frame slot. + */ + void BeginFrame(const Timestep& timestep); + + /** + * @brief Simulates and renders the latest published submission. + * + * The method does nothing when no submission is available. + * + * @param camera Camera used to render particle geometry. + * + * @note Call this method from the render-frame path after BeginFrame(). + */ + void Render(const Camera& camera); + + /** + * @brief Returns statistics from the most recent particle simulation. + * @return Statistics for the latest submission processed by Simulator. + * @note Processing another submission replaces these values. + */ + const SSimulationMetrics& GetLastSimulationMetrics() const; + + /** + * @brief Returns statistics from the most recent particle rendering operation. + * @return Statistics for the latest RenderFrame processed by Renderer. + * @note Rendering another frame replaces these values. + */ + const SRenderingMetrics& GetLastRenderingMetrics() const; + + private: + InstanceRegistry& GetRuntime() const; + Simulator& GetSimulator() const; + Renderer& GetRenderer() const; + + // Forwards detached instances to Simulator for fence-safe GPU retirement. + void RetireDestroyedInstances(); + + Scope m_Runtime; + Scope m_Simulator; + Scope m_Renderer; + + MaterialSystem& m_MaterialSystem; + + SystemInstanceRetirementQueue m_PendingRetirements; + + const GraphicsContext* m_GraphicsContext = nullptr; + }; +} diff --git a/Elixir/Source/Engine/Aether/Modules.cpp b/Elixir/Source/Engine/Aether/Modules/Modules.cpp similarity index 99% rename from Elixir/Source/Engine/Aether/Modules.cpp rename to Elixir/Source/Engine/Aether/Modules/Modules.cpp index d6fa87e3..d001442b 100644 --- a/Elixir/Source/Engine/Aether/Modules.cpp +++ b/Elixir/Source/Engine/Aether/Modules/Modules.cpp @@ -1,9 +1,7 @@ #include "epch.h" #include "Modules.h" -#include "Particle.h" - -namespace Elixir::Aether +namespace Elixir::Aether::Modules { /* SetPositionDisk */ diff --git a/Elixir/Source/Engine/Aether/Modules.h b/Elixir/Source/Engine/Aether/Modules/Modules.h similarity index 96% rename from Elixir/Source/Engine/Aether/Modules.h rename to Elixir/Source/Engine/Aether/Modules/Modules.h index 19f944a2..e125262a 100644 --- a/Elixir/Source/Engine/Aether/Modules.h +++ b/Elixir/Source/Engine/Aether/Modules/Modules.h @@ -1,34 +1,10 @@ #pragma once -#include "Particle.h" -namespace Elixir::Aether -{ - class ParameterStore; +#include - enum class EParticleOp : uint32_t - { - SetLiteral = 0, - RandomRange, - SampleDisk, - SampleCone, - SampleBox, - AddWithDelta, - Dampen, - LerpOverLife, - KillOutsideBounds, - AddFromAttribute, - SetPositionOnCircle, - SetPositionCircularPath, - SetPositionVortexRibbonPath, - SetRibbonIdFromSpawnOrder, - SampleCurve, - SampleColorCurve, - Add, - Mul, - Clamp, - CopyFromAttribute, - ApplyVortex, - }; +namespace Elixir::Aether::Modules +{ + using namespace Elixir::Aether::Core; struct SGPUParticleOp { diff --git a/Elixir/Source/Engine/Aether/Particle.h b/Elixir/Source/Engine/Aether/Particle.h deleted file mode 100644 index 584d6a83..00000000 --- a/Elixir/Source/Engine/Aether/Particle.h +++ /dev/null @@ -1,44 +0,0 @@ -#pragma once - -#include - -namespace Elixir::Aether -{ - enum class EParticleAttribute : uint32_t - { - None = 0, - Position, - Rotation, - Scale, - Velocity, - Color, - Size, - Lifetime, - Tangent, - RibbonId, - Temp0, - Temp1, - Temp2, - Temp3, - }; - - enum class EParticleRenderMode : uint8_t - { - Sprite = 0, - Ribbon = 1, - Mesh = 2 - }; - - enum class EParticleSimulationSpace : uint8_t - { - World = 0, - Local = 1, - }; - - // CoreV1 is byte-for-byte compatible with the current SGPUParticleState. - // Future pool arenas and shader permutations will be selected from this key. - enum class EParticleStateLayout : uint8_t - { - CoreV1 = 0 - }; -} \ No newline at end of file diff --git a/Elixir/Source/Engine/Aether/ParticleResourcePool.h b/Elixir/Source/Engine/Aether/ParticleResourcePool.h deleted file mode 100644 index b4a1018a..00000000 --- a/Elixir/Source/Engine/Aether/ParticleResourcePool.h +++ /dev/null @@ -1,87 +0,0 @@ -#pragma once - -#include -#include - -namespace Elixir::Aether -{ - struct SBufferRange - { - uint32_t Offset = 0; - uint32_t Count = 0; - - explicit operator bool() const { return Count != 0; } - }; - - struct SParticlePoolLimits - { - uint32_t MaxSystemInstances = 256; - uint32_t ParticleCapacity = 1'000'000; - uint32_t EmitterCapacity = 4'096; - uint32_t OpCapacity = 65'536; - uint32_t ParameterCapacity = 16'384; - uint32_t TriggerTargetCapacity = 4'096; - uint32_t TriggerEventCapacityPerEmitter = 64; - }; - - struct SSystemInstanceAllocation - { - uint32_t InstanceIndex = UINT32_MAX; - EParticleStateLayout ParticleStateLayout = EParticleStateLayout::CoreV1; - uint32_t Generation = 1; - - SBufferRange Particles; - SBufferRange Emitters; - SBufferRange Ops; - SBufferRange Parameters; - SBufferRange TriggerTargets; - - SBufferRange EmitterStates; - SBufferRange SpawnRequests; - SBufferRange TriggerEvents; - SBufferRange TriggerQueueStates; - }; - - class ELIXIR_API ParticleResourcePool final - { - public: - explicit ParticleResourcePool( - const SParticlePoolLimits& limits, - const ParticleStateLayoutRegistry& layouts - ); - - std::optional Allocate(const SCompiledSystem& system); - void Release(const SSystemInstanceAllocation& allocation); - - const SParticlePoolLimits& GetLimits() const { return m_Limits; } - - private: - static SBufferRange AllocateRange(std::vector& freeRanges, uint32_t count); - static void ReleaseRange(std::vector& freeRanges, SBufferRange range); - - static std::vector MakeFreeRanges(uint32_t capacity); - - struct SParticleStateLayoutAllocator - { - EParticleStateLayout Key = EParticleStateLayout::CoreV1; - std::vector FreeParticleRanges; - }; - - std::vector* FindParticleFreeRanges(EParticleStateLayout layout); - - SParticlePoolLimits m_Limits; - - std::vector m_FreeInstanceSlots; - std::vector m_InstanceGenerations; - - std::vector m_ParticleStateLayoutAllocators; - std::vector m_FreeEmitters; - std::vector m_FreeOps; - std::vector m_FreeParameters; - std::vector m_FreeTriggerTargets; - std::vector m_FreeEmitterStates; - std::vector m_FreeSpawnRequests; - std::vector m_FreeTriggerEvents; - std::vector m_FreeTriggerQueueStates; - }; -} diff --git a/Elixir/Source/Engine/Aether/ParticleStateLayout.h b/Elixir/Source/Engine/Aether/ParticleStateLayout.h deleted file mode 100644 index 4eeee551..00000000 --- a/Elixir/Source/Engine/Aether/ParticleStateLayout.h +++ /dev/null @@ -1,34 +0,0 @@ -#pragma once - -#include - -namespace Elixir::Aether -{ - // CoreV1 is six float4 values in both C++ and HLSL. - constexpr uint32_t PARTICLE_STATE_CORE_V1_STRIDE = sizeof(glm::vec4) * 6; - - struct SParticleStateLayoutDescriptor - { - EParticleStateLayout Key = EParticleStateLayout::CoreV1; - uint32_t ParticleStateStride = 0; - uint32_t ParticleCapacity = 0; - }; - - // Immutable after renderer initialization. Each registered descriptor must - // have a corresponding renderer runtime with compatible GPU resources, - // shaders and pipelines. - class ELIXIR_API ParticleStateLayoutRegistry final - { - public: - explicit ParticleStateLayoutRegistry(uint32_t particleCapacity); - - bool Register(SParticleStateLayoutDescriptor descriptor); - - const SParticleStateLayoutDescriptor* Find(EParticleStateLayout key) const; - - const std::vector& GetDescriptors() const { return m_Descriptors;} - - private: - std::vector m_Descriptors; - }; -} diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp deleted file mode 100644 index e6f560a3..00000000 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ /dev/null @@ -1,1402 +0,0 @@ -#include "epch.h" -#include "Renderer.h" - -#include "Engine/Core/Color.h" -#include "Engine/Graphics/CommandBuffer.h" -#include "Engine/Graphics/SamplerBuilder.h" -#include "Engine/Graphics/Pipeline/PipelineBuilder.h" - -namespace Elixir::Aether -{ - struct MeshVertex - { - glm::vec3 Position; - glm::vec3 Normal; - }; - - struct SSpritePushConstants - { - glm::mat4 WorldTransform{ 1.0f }; - uint32_t SpriteIndex = 0; - }; - - struct SMeshPushConstants - { - glm::mat4 WorldTransform{ 1.0f }; - }; - - struct SRibbonPushConstants - { - glm::mat4 WorldTransform{ 1.0f }; - uint32_t EmitterIndex = 0; - uint32_t ParticleBaseOffset = 0; - }; - - SEmitterData ToEmitterDescription( - const SCompiledEmitter& emitter, - uint32_t opBaseOffset, - uint32_t triggerTargetBaseOffset - ) - { - SEmitterData desc{}; - desc.MetaA = { - emitter.LocalParticleOffset, - emitter.MaxParticles, - opBaseOffset + emitter.SpawnOpOffset, - emitter.SpawnOpCount - }; - - desc.MetaB = { - opBaseOffset + emitter.UpdateOpOffset, - emitter.UpdateOpCount, - triggerTargetBaseOffset + emitter.TriggerTargetOffset, - emitter.TriggerTargetCount - }; - - desc.MetaC = { - (float)emitter.RenderMode, - emitter.SpawnRatePerSecond, - emitter.GravityScale, - emitter.BurstIntervalSeconds - }; - - desc.MetaD = { - (float)emitter.BurstCount, - emitter.IsTriggerDriven ? 1.0f : 0.0f, - 0.0f, - 0.0f - }; - - return desc; - } - - STriggerTargetData ToTriggerTargetDescription(const SCompiledTriggerTarget& target) - { - return { - .TargetEmitterIndex = target.TargetEmitterIndex, - .BurstCount = target.BurstCount, - .DelaySeconds = target.DelaySeconds, - }; - } - - SParticleOpData ToOpDescription(const SGPUParticleOp& op, uint32_t parameterBaseOffset) - { - const auto ResolveParameterIndex = [parameterBaseOffset](const uint32_t parameterIndex) - { - return parameterIndex == UINT32_MAX - ? -1.0f - : (float)(parameterBaseOffset + parameterIndex); - }; - - SParticleOpData desc{}; - - desc.Header = { - (float)(uint32_t)op.Type, - (float)op.Target, - ResolveParameterIndex(op.Parameter0Index), - ResolveParameterIndex(op.Parameter1Index) - }; - - desc.Data0 = op.Data0; - desc.Data1 = op.Data1; - desc.Data2 = op.Data2; - - return desc; - } - - SParameterData ToParameterDescription(const SGPUParameter& parameter) - { - SParameterData desc{}; - desc.Value = parameter.Value; - - return desc; - } - - uint32_t GetRenderModeOrder(const EParticleRenderMode mode) - { - switch (mode) - { - case EParticleRenderMode::Mesh: return 0; - case EParticleRenderMode::Ribbon: return 1; - case EParticleRenderMode::Sprite: return 2; - } - - return UINT32_MAX; - } - - glm::mat4 GetParticleRenderTransform( - const SCompiledEmitter& emitter, - const SystemInstance& instance - ) - { - return emitter.SimulationSpace == EParticleSimulationSpace::Local - ? instance.GetWorldTransform() - : glm::mat4{ 1.0f }; - } - - Renderer::Renderer( - const GraphicsContext* context, - const ShaderLoader* shaderLoader, - const SParticlePoolLimits& limits - ) : m_ParticlePoolLimits(limits), - m_ParticleStateLayouts(m_ParticlePoolLimits.ParticleCapacity), - m_ParticleResourcePool(m_ParticlePoolLimits, m_ParticleStateLayouts), - m_GraphicsContext(context) - { - static_assert(sizeof(SGPUParticleState) == PARTICLE_STATE_CORE_V1_STRIDE); - EE_CORE_INFO("Initializing Aether Renderer.") - - Init(shaderLoader); - CreateBuffers(); - InitPerFrameData(); - BindShaderParameters(); - } - - void Renderer::Update(const Timestep& timestep) - { - ProcessCompletedRetirements(); - - m_LastDeltaTimeSeconds = timestep.GetSeconds(); - m_ElapsedTimeSeconds += timestep.GetSeconds(); - } - - void Renderer::Render(const FrameSubmission& submission, const Camera& camera) - { - const auto& instances = submission.GetInstances(); - - m_LastSubmissionMetrics = { - .SubmissionSerial = ++m_SubmissionSerial, - .DeltaTimeSeconds = m_LastDeltaTimeSeconds, - .ElapsedTimeSeconds = m_ElapsedTimeSeconds, - .RequestedSystemInstanceCount = instances.size(), - .TriggerEventCapacityPerEmitter = - m_ParticlePoolLimits.TriggerEventCapacityPerEmitter, - }; - - m_RenderExtent = m_GraphicsContext->GetRenderTarget()->GetExtent(); - - m_FrameData.View = camera.GetViewMatrix(); - m_FrameData.Proj = camera.GetProjectionMatrix(); - m_FrameData.ViewProj = camera.GetViewProjectionMatrix(); - m_FrameData.CameraPos = camera.GetPosition(); - m_FrameConstantBuffer->UpdateData(&m_FrameData, sizeof(SFrameData)); - - std::vector submittedInstances; - submittedInstances.reserve(instances.size()); - - for (const auto* instance : instances) - { - const auto& system = instance->GetCompiledSystem(); - - m_LastSubmissionMetrics.RequestedEmitterCount += system.Emitters.size(); - m_LastSubmissionMetrics.RequestedParticleCapacity += system.TotalMaxParticles; - - if (!IsParticleStateLayoutSupported(system.ParticleStateLayout)) - { - if (m_UnsupportedParticleStateLayoutInstances.insert(instance->GetId()).second) - { - EE_CORE_ERROR( - "Aether does not support particle state layout '{}' for system instance '{}'.", - (uint32_t)system.ParticleStateLayout, - system.Name - ) - } - - continue; - } - - m_UnsupportedParticleStateLayoutInstances.erase(instance->GetId()); - - auto* record = ResolveInstanceRecord(*instance); - if (!record) continue; - - UpdateBuffers(*instance, *record); - - const auto emitterCount = record->Allocation.Emitters.Count; - const auto particleCount = record->Allocation.Particles.Count; - - ++m_LastSubmissionMetrics.SubmittedSystemInstanceCount; - m_LastSubmissionMetrics.SubmittedEmitterCount += emitterCount; - m_LastSubmissionMetrics.SubmittedParticleCapacity += particleCount; - - submittedInstances.push_back({ - .Instance = instance, - .Allocation = record->Allocation, - .ParticleStateLayout = system.ParticleStateLayout, - }); - } - - if (submittedInstances.empty()) - return; - - const auto simulationBatches = BuildSimulationBatches(submittedInstances); - const auto renderBatches = BuildRenderBatches(submittedInstances); - - m_LastSubmissionMetrics.SimulationBatchCount = simulationBatches.size(); - m_LastSubmissionMetrics.RenderBatchCount = renderBatches.size(); - - for (const auto& batch : renderBatches) - m_LastSubmissionMetrics.SubmittedRenderItemCount += batch.Items.size(); - - const auto cmd = m_GraphicsContext->GetSecondaryCommandBuffer(); - cmd->Begin({ - .ColorAttachment = m_GraphicsContext->GetRenderTarget(), - .DepthStencilAttachment = m_GraphicsContext->GetDepthStencilRenderTarget(), - .RenderArea = m_RenderExtent - }); - - for (const auto& batch : simulationBatches) - SimulateBatch(cmd, batch); - - for (const auto& batch : simulationBatches) - { - const auto* runtime = FindParticleStateLayoutRuntime(batch.ParticleStateLayout); - EE_CORE_ASSERT(runtime, "Aether particle state layout runtime is missing.") - if (!runtime) continue; - - runtime->ParticleStateBuffer->Barrier( - cmd, - EPipelineStage::VertexShader | EPipelineStage::VertexInput, - EPipelineAccess::ShaderRead | EPipelineAccess::VertexAttributeRead - ); - } - - BeginRendering(cmd); - - for (const auto& batch : renderBatches) - RenderBatch(cmd, batch); - - EndRendering(cmd); - } - - void Renderer::Retire(const SystemInstance& instance) - { - const auto found = m_InstanceRecords.find(instance.GetId()); - if (found == m_InstanceRecords.end()) - return; - - QueueRetirement(found->second.Allocation); - m_InstanceRecords.erase(found); - m_AllocationFailures.erase(instance.GetId()); - m_UnsupportedParticleStateLayoutInstances.erase(instance.GetId()); - } - - const SParticleSubmissionMetrics& Renderer::GetLastSubmissionMetrics() const - { - return m_LastSubmissionMetrics; - } - - void Renderer::Init(const ShaderLoader* shaderLoader) - { - m_SchedulerBeginShader = shaderLoader->LoadShader( - "./Shaders/Aether/", - std::array{ "ParticlesSchedulerBegin" }, - "ParticlesSchedulerBegin", - EShaderStage::Compute - ); - - m_SchedulerInitEmittersShader = shaderLoader->LoadShader( - "./Shaders/Aether/", - std::array{ "ParticlesSchedulerInitEmitters" }, - "ParticlesSchedulerInitEmitters", - EShaderStage::Compute - ); - - m_SchedulerScheduleEmittersShader = shaderLoader->LoadShader( - "./Shaders/Aether/", - std::array{ "ParticlesSchedulerScheduleEmitters" }, - "ParticlesSchedulerScheduleEmitters", - EShaderStage::Compute - ); - - m_SchedulerFinalizeShader = shaderLoader->LoadShader( - "./Shaders/Aether/", - std::array{ "ParticlesSchedulerFinalize" }, - "ParticlesSchedulerFinalize", - EShaderStage::Compute - ); - - SPipelineCreateInfo pipelineInfo{}; - pipelineInfo.Shader = m_SchedulerBeginShader; - m_SchedulerBeginPipeline = ComputePipeline::Create(m_GraphicsContext, pipelineInfo); - - pipelineInfo.Shader = m_SchedulerInitEmittersShader; - m_SchedulerInitEmittersPipeline = ComputePipeline::Create(m_GraphicsContext, pipelineInfo); - - pipelineInfo.Shader = m_SchedulerScheduleEmittersShader; - m_SchedulerScheduleEmittersPipeline = ComputePipeline::Create( - m_GraphicsContext, - pipelineInfo - ); - - pipelineInfo.Shader = m_SchedulerFinalizeShader; - m_SchedulerFinalizePipeline = ComputePipeline::Create(m_GraphicsContext, pipelineInfo); - - m_Sprites = TextureSet::Create(m_GraphicsContext); - m_SpriteSampler = SamplerBuilder().Build(m_GraphicsContext); - - CreateCoreV1ParticleStateLayoutRuntime(shaderLoader); - } - - void Renderer::CreateCoreV1ParticleStateLayoutRuntime(const ShaderLoader* shaderLoader) - { - EE_CORE_ASSERT( - m_ParticleStateLayouts.Find(EParticleStateLayout::CoreV1), - "Aether requires a CoreV1 particle state layout descriptor." - ) - - EE_CORE_ASSERT( - !FindParticleStateLayoutRuntime(EParticleStateLayout::CoreV1), - "Aether cannot create the CoreV1 particle state runtime twice." - ) - - m_ParticleStateLayoutRuntimes.push_back({ - .Key = EParticleStateLayout::CoreV1, - }); - - auto& runtime = m_ParticleStateLayoutRuntimes.back(); - - runtime.SpawnShader = shaderLoader->LoadShader( - "./Shaders/Aether/", - std::array{ "ParticlesSpawn" }, - "ParticlesSpawn", - EShaderStage::Compute - ); - - runtime.UpdateShader = shaderLoader->LoadShader( - "./Shaders/Aether/", - std::array{ "ParticlesUpdate" }, - "ParticlesUpdate", - EShaderStage::Compute - ); - - runtime.SpriteShader = shaderLoader->LoadShader( - "./Shaders/Aether/", - std::array{ "Sprite" }, - "SpriteRenderer" - ); - - runtime.RibbonShader = shaderLoader->LoadShader( - "./Shaders/Aether/", - std::array{ "Ribbon" }, - "RibbonRenderer" - ); - - runtime.MeshShader = shaderLoader->LoadShader( - "./Shaders/Aether/", - std::array{ "Mesh" }, - "MeshRenderer" - ); - - SPipelineCreateInfo pipelineInfo{}; - pipelineInfo.Shader = runtime.SpawnShader; - runtime.SpawnPipeline = ComputePipeline::Create(m_GraphicsContext, pipelineInfo); - - pipelineInfo.Shader = runtime.UpdateShader; - runtime.UpdatePipeline = ComputePipeline::Create(m_GraphicsContext, pipelineInfo); - - const BufferLayout spriteBufferLayout({ - { - { - { EDataType::Vec4, "PositionSize" }, - { EDataType::Vec4, "VelocityAge" }, - { EDataType::Vec4, "Transform" }, - { EDataType::Vec4, "TangentRibbonId" }, - { EDataType::Vec4, "Color" }, - { EDataType::Vec4, "Metadata" } - }, - EInputRate::Instance - } - }); - - PipelineBuilder spriteBuilder; - spriteBuilder.SetShader(runtime.SpriteShader); - spriteBuilder.SetInputTopology(EPrimitiveTopology::TriangleList); - spriteBuilder.SetPolygonMode(EPolygonMode::Fill); - spriteBuilder.SetCullMode(ECullMode::None, EFrontFace::CounterClockwise); - spriteBuilder.EnableAlphaBlending(); - spriteBuilder.DisableDepthTest(); - spriteBuilder.SetColorAttachmentFormat(EImageFormat::R8G8B8A8_SRGB); - spriteBuilder.SetDepthAttachmentFormat(EDepthStencilImageFormat::D32_SFLOAT); - spriteBuilder.SetBufferLayout(spriteBufferLayout); - runtime.SpritePipeline = spriteBuilder.Build(m_GraphicsContext); - - PipelineBuilder ribbonBuilder; - ribbonBuilder.SetShader(runtime.RibbonShader); - ribbonBuilder.SetInputTopology(EPrimitiveTopology::TriangleList); - ribbonBuilder.SetPolygonMode(EPolygonMode::Fill); - ribbonBuilder.SetCullMode(ECullMode::None, EFrontFace::CounterClockwise); - ribbonBuilder.EnableAlphaBlendingMax(); - ribbonBuilder.DisableDepthTest(); - ribbonBuilder.SetColorAttachmentFormat(EImageFormat::R8G8B8A8_SRGB); - ribbonBuilder.SetDepthAttachmentFormat(EDepthStencilImageFormat::D32_SFLOAT); - ribbonBuilder.SetBufferLayout({}); - runtime.RibbonPipeline = ribbonBuilder.Build(m_GraphicsContext); - - const BufferLayout meshBufferLayout({ - { - { - { EDataType::Vec3, "Position" }, - { EDataType::Vec3, "Normal" }, - }, - EInputRate::Vertex - }, - { - { - { EDataType::Vec4, "PositionSize" }, - { EDataType::Vec4, "VelocityAge" }, - { EDataType::Vec4, "Transform" }, - { EDataType::Vec4, "TangentRibbonId" }, - { EDataType::Vec4, "Color" }, - { EDataType::Vec4, "Metadata" } - }, - EInputRate::Instance - } - }); - - PipelineBuilder meshBuilder; - meshBuilder.SetShader(runtime.MeshShader); - meshBuilder.SetInputTopology(EPrimitiveTopology::TriangleList); - meshBuilder.SetPolygonMode(EPolygonMode::Fill); - meshBuilder.SetCullMode(ECullMode::Back, EFrontFace::CounterClockwise); - meshBuilder.EnableAlphaBlendingMax(); - meshBuilder.SetColorAttachmentFormat(EImageFormat::R8G8B8A8_SRGB); - meshBuilder.SetDepthAttachmentFormat(EDepthStencilImageFormat::D32_SFLOAT); - meshBuilder.SetBufferLayout(meshBufferLayout); - - auto info = meshBuilder.GetCreateInfo(); - info.DepthStencil.DepthTestEnable = true; - info.DepthStencil.DepthWriteEnable = true; - info.DepthStencil.DepthCompareOp = ECompareOp::LessOrEqual; - - runtime.MeshPipeline = GraphicsPipeline::Create(m_GraphicsContext, info); - } - - void Renderer::CreateBuffers() - { - for (const auto& descriptor : m_ParticleStateLayouts.GetDescriptors()) - { - auto* runtime = FindParticleStateLayoutRuntime(descriptor.Key); - EE_CORE_ASSERT( - runtime, - "Every particle state layout descriptor requires a renderer runtime." - ) - if (!runtime) continue; - - runtime->ParticleStateBuffer = StorageBuffer::Create( - m_GraphicsContext, - descriptor.ParticleStateStride * descriptor.ParticleCapacity - ); - } - - EE_CORE_ASSERT( - m_ParticleStateLayoutRuntimes.size() == - m_ParticleStateLayouts.GetDescriptors().size(), - "Every particle state layout runtime requires a registered descriptor." - ) - - EE_CORE_ASSERT( - FindParticleStateLayoutRuntime(EParticleStateLayout::CoreV1), - "Aether requires a CoreV1 particle state layout runtime." - ) - - m_EmitterStateBuffer = StorageBuffer::Create( - m_GraphicsContext, - sizeof(SEmitterInstanceStateData) * m_ParticlePoolLimits.EmitterCapacity - ); - - m_SpawnRequestBuffer = StorageBuffer::Create( - m_GraphicsContext, - sizeof(SSpawnRequestData) * m_ParticlePoolLimits.EmitterCapacity - ); - - m_TriggerTargetBuffer = DynamicStorageBuffer::Create( - m_GraphicsContext, - sizeof(STriggerTargetData) * m_ParticlePoolLimits.TriggerTargetCapacity - ); - - for (auto& buffer : m_TriggerEventBuffers) - { - buffer = StorageBuffer::Create( - m_GraphicsContext, - sizeof(STriggerEventData) * - m_ParticlePoolLimits.EmitterCapacity * - m_ParticlePoolLimits.TriggerEventCapacityPerEmitter - ); - buffer->Clear(); - } - - m_TriggerQueueStateBuffer = StorageBuffer::Create( - m_GraphicsContext, - sizeof(STriggerQueueStateData) * m_ParticlePoolLimits.EmitterCapacity * 2 - ); - - m_SystemInstanceBuffer = DynamicStorageBuffer::Create( - m_GraphicsContext, - sizeof(SSystemInstanceData) * m_ParticlePoolLimits.MaxSystemInstances - ); - - m_SystemSchedulerStateBuffer = StorageBuffer::Create( - m_GraphicsContext, - sizeof(SSystemSchedulerStateData) * m_ParticlePoolLimits.MaxSystemInstances - ); - - m_EmitterStateBuffer->Clear(); - m_SpawnRequestBuffer->Clear(); - m_TriggerQueueStateBuffer->Clear(); - m_SystemSchedulerStateBuffer->Clear(); - - m_EmitterBuffer = DynamicStorageBuffer::Create( - m_GraphicsContext, - sizeof(SEmitterData) * m_ParticlePoolLimits.EmitterCapacity - ); - - m_OpBuffer = DynamicStorageBuffer::Create( - m_GraphicsContext, - sizeof(SParticleOpData) * m_ParticlePoolLimits.OpCapacity - ); - - m_ParameterBuffer = DynamicStorageBuffer::Create( - m_GraphicsContext, - sizeof(SParameterData) * m_ParticlePoolLimits.ParameterCapacity - ); - - m_ParamsBuffer = UniformBuffer::Create( - m_GraphicsContext, - sizeof(SParamsData) - ); - - CreateMeshVertexBuffer(); - } - - void Renderer::CreateMeshVertexBuffer() - { - static constexpr std::array vertices = {{ - {{-0.5f, -0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}}, - {{0.5f, -0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}}, - {{0.5f, 0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}}, - {{-0.5f, -0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}}, - {{0.5f, 0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}}, - {{-0.5f, 0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}}, - - {{0.5f, -0.5f, -0.5f}, {0.0f, 0.0f, -1.0f}}, - {{-0.5f, -0.5f, -0.5f}, {0.0f, 0.0f, -1.0f}}, - {{-0.5f, 0.5f, -0.5f}, {0.0f, 0.0f, -1.0f}}, - {{0.5f, -0.5f, -0.5f}, {0.0f, 0.0f, -1.0f}}, - {{-0.5f, 0.5f, -0.5f}, {0.0f, 0.0f, -1.0f}}, - {{0.5f, 0.5f, -0.5f}, {0.0f, 0.0f, -1.0f}}, - - {{-0.5f, -0.5f, -0.5f}, {-1.0f, 0.0f, 0.0f}}, - {{-0.5f, -0.5f, 0.5f}, {-1.0f, 0.0f, 0.0f}}, - {{-0.5f, 0.5f, 0.5f}, {-1.0f, 0.0f, 0.0f}}, - {{-0.5f, -0.5f, -0.5f}, {-1.0f, 0.0f, 0.0f}}, - {{-0.5f, 0.5f, 0.5f}, {-1.0f, 0.0f, 0.0f}}, - {{-0.5f, 0.5f, -0.5f}, {-1.0f, 0.0f, 0.0f}}, - - {{0.5f, -0.5f, 0.5f}, {1.0f, 0.0f, 0.0f}}, - {{0.5f, -0.5f, -0.5f}, {1.0f, 0.0f, 0.0f}}, - {{0.5f, 0.5f, -0.5f}, {1.0f, 0.0f, 0.0f}}, - {{0.5f, -0.5f, 0.5f}, {1.0f, 0.0f, 0.0f}}, - {{0.5f, 0.5f, -0.5f}, {1.0f, 0.0f, 0.0f}}, - {{0.5f, 0.5f, 0.5f}, {1.0f, 0.0f, 0.0f}}, - - {{-0.5f, 0.5f, 0.5f}, {0.0f, 1.0f, 0.0f}}, - {{0.5f, 0.5f, 0.5f}, {0.0f, 1.0f, 0.0f}}, - {{0.5f, 0.5f, -0.5f}, {0.0f, 1.0f, 0.0f}}, - {{-0.5f, 0.5f, 0.5f}, {0.0f, 1.0f, 0.0f}}, - {{0.5f, 0.5f, -0.5f}, {0.0f, 1.0f, 0.0f}}, - {{-0.5f, 0.5f, -0.5f}, {0.0f, 1.0f, 0.0f}}, - - {{-0.5f, -0.5f, -0.5f}, {0.0f, -1.0f, 0.0f}}, - {{0.5f, -0.5f, -0.5f}, {0.0f, -1.0f, 0.0f}}, - {{0.5f, -0.5f, 0.5f}, {0.0f, -1.0f, 0.0f}}, - {{-0.5f, -0.5f, -0.5f}, {0.0f, -1.0f, 0.0f}}, - {{0.5f, -0.5f, 0.5f}, {0.0f, -1.0f, 0.0f}}, - {{-0.5f, -0.5f, 0.5f}, {0.0f, -1.0f, 0.0f}}, - }}; - - m_MeshVertexCount = (uint32_t)vertices.size(); - m_MeshVertexBuffer = VertexBuffer::Create( - m_GraphicsContext, - sizeof(MeshVertex) * vertices.size(), - vertices.data() - ); - - const auto* coreV1Runtime = FindParticleStateLayoutRuntime(EParticleStateLayout::CoreV1); - EE_CORE_ASSERT( - coreV1Runtime, - "Aether CoreV1 particle state runtime is missing." - ) - if (!coreV1Runtime) return; - - m_MeshVertexBuffer->SetLayout(coreV1Runtime->MeshPipeline->GetBufferLayout()); - } - - void Renderer::InitPerFrameData() - { - m_FrameConstantBuffer = UniformBuffer::Create( - m_GraphicsContext, - sizeof(SFrameData), - &m_FrameData - ); - } - - void Renderer::BindShaderParameters() - { - constexpr SSchedulePushConstants schedulePushConstants{}; - - m_SchedulerBeginShader->SetPushConstant( - "pc", - (void*)&schedulePushConstants, - sizeof(schedulePushConstants) - ); - - m_SchedulerBeginShader->BindStorageBuffer("instances", m_SystemInstanceBuffer); - m_SchedulerBeginShader->BindStorageBuffer("schedulerStates", m_SystemSchedulerStateBuffer); - - m_SchedulerInitEmittersShader->SetPushConstant( - "pc", - (void*)&schedulePushConstants, - sizeof(schedulePushConstants) - ); - - m_SchedulerInitEmittersShader->BindStorageBuffer( - "instances", - m_SystemInstanceBuffer - ); - m_SchedulerInitEmittersShader->BindStorageBuffer( - "emitterStates", - m_EmitterStateBuffer - ); - m_SchedulerInitEmittersShader->BindStorageBuffer( - "triggerQueueStates", - m_TriggerQueueStateBuffer - ); - m_SchedulerInitEmittersShader->BindStorageBuffer( - "schedulerStates", - m_SystemSchedulerStateBuffer - ); - - m_SchedulerScheduleEmittersShader->SetPushConstant( - "pc", - (void*)&schedulePushConstants, - sizeof(schedulePushConstants) - ); - - m_SchedulerScheduleEmittersShader->BindStorageBuffer( - "instances", - m_SystemInstanceBuffer - ); - m_SchedulerScheduleEmittersShader->BindStorageBuffer( - "emitters", - m_EmitterBuffer - ); - m_SchedulerScheduleEmittersShader->BindStorageBuffer( - "emitterStates", - m_EmitterStateBuffer - ); - m_SchedulerScheduleEmittersShader->BindStorageBuffer( - "spawnRequests", - m_SpawnRequestBuffer - ); - m_SchedulerScheduleEmittersShader->BindStorageBuffer( - "triggerTargets", - m_TriggerTargetBuffer - ); - m_SchedulerScheduleEmittersShader->BindStorageBuffer( - "triggerEventsA", - m_TriggerEventBuffers[0] - ); - m_SchedulerScheduleEmittersShader->BindStorageBuffer( - "triggerEventsB", - m_TriggerEventBuffers[1] - ); - m_SchedulerScheduleEmittersShader->BindStorageBuffer( - "triggerQueueStates", - m_TriggerQueueStateBuffer - ); - m_SchedulerScheduleEmittersShader->BindStorageBuffer( - "schedulerStates", - m_SystemSchedulerStateBuffer - ); - m_SchedulerScheduleEmittersShader->BindConstantBuffer( - "cbParams", - m_ParamsBuffer - ); - - m_SchedulerFinalizeShader->SetPushConstant( - "pc", - (void*)&schedulePushConstants, - sizeof(schedulePushConstants) - ); - - m_SchedulerFinalizeShader->BindStorageBuffer("instances", m_SystemInstanceBuffer); - m_SchedulerFinalizeShader->BindStorageBuffer("schedulerStates", m_SystemSchedulerStateBuffer); - - const auto whiteTex = Texture2D::Create( - m_GraphicsContext, - EImageFormat::R8G8B8A8_SRGB, - 1, 1, - &Color::WhiteAlpha - ); - - m_WhiteTextureHandle = m_Sprites->AddTexture(whiteTex); - - for (auto& runtime : m_ParticleStateLayoutRuntimes) - BindParticleStateLayoutShaderParameters(runtime); - } - - void Renderer::BindParticleStateLayoutShaderParameters( - const SParticleStateLayoutRuntime& runtime - ) const - { - EE_CORE_ASSERT( - runtime.ParticleStateBuffer, - "Aether cannot bind and uninitialized particle state layout runtime." - ) - if (!runtime.ParticleStateBuffer) return; - - constexpr SSpawnPushConstants spawnPushConstants{}; - - runtime.SpawnShader->SetPushConstant( - "pc", - (void*)&spawnPushConstants, - sizeof(spawnPushConstants) - ); - - runtime.SpawnShader->BindStorageBuffer("particles", runtime.ParticleStateBuffer); - runtime.SpawnShader->BindStorageBuffer("instances", m_SystemInstanceBuffer); - runtime.SpawnShader->BindStorageBuffer("emitters", m_EmitterBuffer); - runtime.SpawnShader->BindStorageBuffer("spawnRequests", m_SpawnRequestBuffer); - runtime.SpawnShader->BindStorageBuffer("ops", m_OpBuffer); - runtime.SpawnShader->BindStorageBuffer("parameters", m_ParameterBuffer); - runtime.SpawnShader->BindConstantBuffer("cbParams", m_ParamsBuffer); - - constexpr SUpdatePushConstants updatePushConstants{}; - runtime.UpdateShader->SetPushConstant( - "pc", - (void*)&updatePushConstants, - sizeof(updatePushConstants) - ); - - runtime.UpdateShader->BindStorageBuffer("particles", runtime.ParticleStateBuffer); - runtime.UpdateShader->BindStorageBuffer("instances", m_SystemInstanceBuffer); - runtime.UpdateShader->BindStorageBuffer("emitters", m_EmitterBuffer); - runtime.UpdateShader->BindStorageBuffer("ops", m_OpBuffer); - runtime.UpdateShader->BindStorageBuffer("parameters", m_ParameterBuffer); - runtime.UpdateShader->BindConstantBuffer("cbParams", m_ParamsBuffer); - - const SSpritePushConstants spritePushConstants{ - .SpriteIndex = m_WhiteTextureHandle.Index - }; - runtime.SpriteShader->SetPushConstant( - "pc", - (void*)&spritePushConstants, - sizeof(spritePushConstants) - ); - - runtime.SpriteShader->BindConstantBuffer("cbFrame", m_FrameConstantBuffer); - runtime.SpriteShader->BindTextureSet("sprites", m_Sprites); - runtime.SpriteShader->BindSampler("spriteSampler", m_SpriteSampler); - - constexpr SRibbonPushConstants ribbonPushConstants{}; - runtime.RibbonShader->SetPushConstant( - "pc", - (void*)&ribbonPushConstants, - sizeof(ribbonPushConstants) - ); - - runtime.RibbonShader->BindStorageBuffer("particles", runtime.ParticleStateBuffer); - runtime.RibbonShader->BindStorageBuffer("emitters", m_EmitterBuffer); - runtime.RibbonShader->BindConstantBuffer("cbFrame", m_FrameConstantBuffer); - - constexpr SMeshPushConstants meshPushConstants{}; - runtime.MeshShader->SetPushConstant( - "pc", - (void*)&meshPushConstants, - sizeof(meshPushConstants) - ); - - runtime.MeshShader->BindConstantBuffer("cbFrame", m_FrameConstantBuffer); - } - - uint32_t Renderer::ResolveSpriteIndex(const Ref& texture) - { - if (!texture) - return m_WhiteTextureHandle.Index; - - if (const auto it = m_SpriteTextures.find(texture); it != m_SpriteTextures.end()) - return it->second.Index; - - const auto handle = m_Sprites->AddTexture(texture); - m_SpriteTextures[texture] = handle; - - return handle.Index; - } - - void Renderer::BeginRendering(const Ref& cmd) const - { - const auto renderingInfo = SRenderingInfo - { - .ColorAttachment = m_GraphicsContext->GetRenderTarget(), - .DepthStencilAttachment = m_GraphicsContext->GetDepthStencilRenderTarget(), - .RenderArea = m_RenderExtent - }; - - Viewport viewport = {}; - viewport.X = 0; - viewport.Y = 0; - viewport.Width = (float)m_RenderExtent.Width; - viewport.Height = (float)m_RenderExtent.Height; - viewport.MinDepth = 0.0f; - viewport.MaxDepth = 1.0f; - - Rect2D scissor = {}; - scissor.Offset = { 0, 0 }; - scissor.Extent = m_RenderExtent; - - cmd->BeginRendering(renderingInfo); - cmd->SetViewports({ viewport }); - cmd->SetScissors({ scissor }); - } - - void Renderer::EndRendering(const Ref& cmd) const - { - cmd->EndRendering(); - m_GraphicsContext->EnqueueSecondaryCommandBuffer(cmd); - } - - Renderer::SInstanceRecord* Renderer::ResolveInstanceRecord(const SystemInstance& instance) - { - const auto instanceRevision = instance.GetRevision(); - const auto found = m_InstanceRecords.find(instance.GetId()); - - if (found != m_InstanceRecords.end() && - found->second.SystemInstanceRevision == instanceRevision) - { - return &found->second; - } - - const auto& system = instance.GetCompiledSystem(); - - const auto replacementAllocation = m_ParticleResourcePool.Allocate(system); - if (!replacementAllocation) - { - if (m_AllocationFailures.insert(instance.GetId()).second) - { - EE_CORE_ERROR( - "Aether GPU resource pool exhausted while creating system instance '{}'.", - system.Name - ) - } - - return nullptr; - } - - ClearParticleAllocation(*replacementAllocation); - UploadCompiledSystem(instance, *replacementAllocation); - - const SInstanceRecord replacement{ - .SystemInstanceId = instance.GetId(), - .SystemInstanceRevision = instanceRevision, - .CompiledSystemId = system.SourceId, - .CompilationRevision = system.CompilationRevision, - .ParameterRevision = instance.GetParameterRevision(), - .Allocation = *replacementAllocation, - }; - - if (found == m_InstanceRecords.end()) - { - const auto [it, inserted] = m_InstanceRecords.emplace(instance.GetId(), replacement); - - EE_CORE_ASSERT(inserted, "Aether system instance registry insertion failed.") - m_AllocationFailures.erase(instance.GetId()); - return &it->second; - } - - // The replacement is fully allocated and uploaded before retiring the - // previous record. If allocation fails, the old record remains intact. - QueueRetirement(found->second.Allocation); - found->second = replacement; - m_AllocationFailures.erase(instance.GetId()); - return &found->second; - } - - void Renderer::UploadCompiledSystem( - const SystemInstance& instance, - const SSystemInstanceAllocation& allocation - ) const - { - const auto& system = instance.GetCompiledSystem(); - - auto* emitters = (SEmitterData*)m_EmitterBuffer->Map(); - for (uint32_t i = 0; i < allocation.Emitters.Count; ++i) - { - emitters[allocation.Emitters.Offset + i] = ToEmitterDescription( - system.Emitters[i], - allocation.Ops.Offset, - allocation.TriggerTargets.Offset - ); - } - - auto* ops = (SParticleOpData*)m_OpBuffer->Map(); - for (uint32_t i = 0; i < allocation.Ops.Count; ++i) - { - ops[allocation.Ops.Offset + i] = ToOpDescription( - system.Ops[i], - allocation.Parameters.Offset - ); - } - - UploadInstanceParameters(instance, allocation); - - auto* targets = (STriggerTargetData*)m_TriggerTargetBuffer->Map(); - for (uint32_t i = 0; i < allocation.TriggerTargets.Count; ++i) - targets[allocation.TriggerTargets.Offset + i] = - ToTriggerTargetDescription(system.TriggerTargets[i]); - } - - void Renderer::UploadInstanceParameters( - const SystemInstance& instance, - const SSystemInstanceAllocation& allocation - ) const - { - auto* parameters = (SParameterData*)m_ParameterBuffer->Map(); - for (uint32_t i = 0; i < allocation.Parameters.Count; ++i) - parameters[allocation.Parameters.Offset + i].Value = - instance.ResolveParameterValue(i); - } - - void Renderer::QueueRetirement(SSystemInstanceAllocation allocation) - { - const auto frameIndex = m_GraphicsContext->GetFrameIndex(); - m_DeferredRetirements[frameIndex].push_back(std::move(allocation)); - } - - void Renderer::ProcessCompletedRetirements() - { - // Update() runs after GraphicsContext::Prepare() waited for this frame slot's fence. - // All GPU work that used these allocations has therefore completed. - const auto frameIndex = m_GraphicsContext->GetFrameIndex(); - auto& retirements = m_DeferredRetirements[frameIndex]; - - for (const auto& allocation : retirements) - m_ParticleResourcePool.Release(allocation); - - retirements.clear(); - } - - void Renderer::UpdateBuffers(SystemInstance const& instance, SInstanceRecord& record) - { - if (record.ParameterRevision != instance.GetParameterRevision()) - { - UploadInstanceParameters(instance, record.Allocation); - record.ParameterRevision = instance.GetParameterRevision(); - } - - const SParamsData params{ - .Time = { m_LastDeltaTimeSeconds, m_ElapsedTimeSeconds, 0.0f, 0.0f }, - .Viewport = { - (float)m_RenderExtent.Width, - (float)m_RenderExtent.Height, - 0.0f, - 0.0f - } - }; - m_ParamsBuffer->UpdateData(¶ms, sizeof(SParamsData)); - - const auto& system = instance.GetCompiledSystem(); - - const SSystemInstanceData instanceData - { - .ParticleBaseOffset = record.Allocation.Particles.Offset, - .EmitterBaseOffset = record.Allocation.Emitters.Offset, - .OpBaseOffset = record.Allocation.Ops.Offset, - .ParameterBaseOffset = record.Allocation.Parameters.Offset, - .EmitterStateBaseOffset = record.Allocation.EmitterStates.Offset, - .SpawnRequestBaseOffset = record.Allocation.SpawnRequests.Offset, - .TriggerEventBaseOffset = record.Allocation.TriggerEvents.Offset, - .TriggerQueueStateBaseOffset = record.Allocation.TriggerQueueStates.Offset, - .ParticleCount = record.Allocation.Particles.Count, - .EmitterCount = record.Allocation.Emitters.Count, - .TriggerEventCapacityPerEmitter = m_ParticlePoolLimits.TriggerEventCapacityPerEmitter, - .Generation = record.Allocation.Generation, - .ParticleStateLayoutIndex = (uint32_t)system.ParticleStateLayout, - }; - - m_SystemInstanceBuffer->UpdateData( - &instanceData, - sizeof(SSystemInstanceData), - record.Allocation.InstanceIndex * sizeof(SSystemInstanceData) - ); - } - - Renderer::SParticleStateLayoutRuntime* Renderer::FindParticleStateLayoutRuntime( - const EParticleStateLayout layout - ) - { - for (auto& runtime : m_ParticleStateLayoutRuntimes) - { - if (runtime.Key == layout) - return &runtime; - } - - return nullptr; - } - - const Renderer::SParticleStateLayoutRuntime* Renderer::FindParticleStateLayoutRuntime( - const EParticleStateLayout layout - ) const - { - for (const auto& runtime : m_ParticleStateLayoutRuntimes) - { - if (runtime.Key == layout) - return &runtime; - } - - return nullptr; - } - - bool Renderer::IsParticleStateLayoutSupported(const EParticleStateLayout layout) const - { - const auto* runtime = FindParticleStateLayoutRuntime(layout); - return runtime && runtime->IsReady(); - } - - std::vector Renderer::BuildSimulationBatches( - const std::vector& instances - ) const - { - std::vector batches; - - for (const auto& instance : instances) - { - SSimulationBatch* batch = nullptr; - - for (auto& candidate : batches) - { - if (candidate.ParticleStateLayout != instance.ParticleStateLayout) - continue; - - batch = &candidate; - break; - } - - if (!batch) - { - batches.push_back({ - .ParticleStateLayout = instance.ParticleStateLayout, - }); - batch = &batches.back(); - } - - batch->Instances.push_back(&instance); - } - - return batches; - } - - std::vector Renderer::BuildRenderBatches( - const std::vector& instances - ) const - { - std::vector batches; - - for (const auto& instance : instances) - { - const auto& system = instance.Instance->GetCompiledSystem(); - - for (uint32_t emitterIndex = 0; emitterIndex < system.Emitters.size(); ++emitterIndex) - { - const auto& emitter = system.Emitters[emitterIndex]; - - if (emitter.MaxParticles == 0) - continue; - - const SRenderBatchKey key{ - .ParticleStateLayout = instance.ParticleStateLayout, - .RenderMode = emitter.RenderMode, - }; - - SRenderBatch* batch = nullptr; - - for (auto& candidate : batches) - { - if (candidate.Key != key) - continue; - - batch = &candidate; - break; - } - - if (!batch) - { - batches.push_back({ - .Key = key, - }); - batch = &batches.back(); - } - - batch->Items.push_back({ - .Instance = &instance, - .Emitter = &emitter, - .LocalEmitterIndex = emitterIndex, - }); - } - } - - std::ranges::stable_sort(batches, [](const SRenderBatch& left, const SRenderBatch& right) - { - if (left.Key.ParticleStateLayout != right.Key.ParticleStateLayout) - { - return uint32_t(left.Key.ParticleStateLayout) < - uint32_t(right.Key.ParticleStateLayout); - } - - return GetRenderModeOrder(left.Key.RenderMode) < - GetRenderModeOrder(right.Key.RenderMode); - } - ); - - return batches; - } - - void Renderer::SimulateBatch(const Ref& cmd, const SSimulationBatch& batch) - { - const auto* runtime = FindParticleStateLayoutRuntime(batch.ParticleStateLayout); - EE_CORE_ASSERT(runtime, "Aether particle state layout runtime is missing.") - if (!runtime) return; - - const auto& particleBuffer = runtime->ParticleStateBuffer; - - // Scheduling: begin - - m_SchedulerBeginPipeline->Bind(cmd); - - for (const auto* instance : batch.Instances) - { - const SSchedulePushConstants pc{ - .InstanceIndex = instance->Allocation.InstanceIndex, - }; - - m_SchedulerBeginShader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - cmd->Dispatch(1); - } - - BarrierSchedulingBuffers(cmd); - - // Scheduling: init emitters - - m_SchedulerInitEmittersPipeline->Bind(cmd); - - for (const auto* instance : batch.Instances) - { - const SSchedulePushConstants pc{ - .InstanceIndex = instance->Allocation.InstanceIndex, - }; - - m_SchedulerInitEmittersShader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - cmd->Dispatch((instance->Allocation.Emitters.Count + COMPUTE_GROUP_SIZE - 1) / COMPUTE_GROUP_SIZE); - } - - BarrierSchedulingBuffers(cmd); - - // Scheduling: generate spawn requests - - m_SchedulerScheduleEmittersPipeline->Bind(cmd); - - for (const auto* instance : batch.Instances) - { - const SSchedulePushConstants pc{ - .InstanceIndex = instance->Allocation.InstanceIndex, - }; - - m_SchedulerScheduleEmittersShader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - cmd->Dispatch((instance->Allocation.Emitters.Count + COMPUTE_GROUP_SIZE - 1) / COMPUTE_GROUP_SIZE); - } - - BarrierSchedulingBuffers(cmd); - - // Scheduling: release trigger events - - m_SchedulerFinalizePipeline->Bind(cmd); - - for (const auto* instance : batch.Instances) - { - const SSchedulePushConstants pc{ - .InstanceIndex = instance->Allocation.InstanceIndex, - }; - - m_SchedulerFinalizeShader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - cmd->Dispatch(1); - } - - BarrierSchedulingBuffers(cmd); - - // Spawning - - particleBuffer->Barrier( - cmd, - EPipelineStage::ComputeShader, - EPipelineAccess::ShaderRead | EPipelineAccess::ShaderWrite - ); - - runtime->SpawnPipeline->Bind(cmd); - - for (const auto* instance : batch.Instances) - { - const auto& system = instance->Instance->GetCompiledSystem(); - const auto emitterCount = instance->Allocation.Emitters.Count; - - m_LastSubmissionMetrics.ScheduledEmitterCount += emitterCount; - - for (uint32_t i = 0; i < emitterCount; ++i) - { - const auto maxParticles = system.Emitters[i].MaxParticles; - if (maxParticles == 0) continue; - - ++m_LastSubmissionMetrics.SpawnDispatchCount; - - const SSpawnPushConstants pc - { - .InstanceIndex = instance->Allocation.InstanceIndex, - .EmitterIndex = i, - }; - - runtime->SpawnShader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - cmd->Dispatch((maxParticles + COMPUTE_GROUP_SIZE - 1) / COMPUTE_GROUP_SIZE); - } - } - - // Updating - - particleBuffer->Barrier( - cmd, - EPipelineStage::ComputeShader, - EPipelineAccess::ShaderRead | EPipelineAccess::ShaderWrite - ); - - runtime->UpdatePipeline->Bind(cmd); - - for (const auto* instance : batch.Instances) - { - const SUpdatePushConstants pc - { - .InstanceIndex = instance->Allocation.InstanceIndex, - }; - - runtime->UpdateShader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - cmd->Dispatch((instance->Allocation.Particles.Count + COMPUTE_GROUP_SIZE - 1) / COMPUTE_GROUP_SIZE); - } - } - - void Renderer::RenderBatch(const Ref& cmd, const SRenderBatch& batch) - { - const auto* runtime = FindParticleStateLayoutRuntime(batch.Key.ParticleStateLayout); - EE_CORE_ASSERT(runtime, "Aether particle state layout runtime is missing.") - if (!runtime) return; - - const auto& particleBuffer = runtime->ParticleStateBuffer; - - switch (batch.Key.RenderMode) - { - case EParticleRenderMode::Mesh: - { - runtime->MeshPipeline->Bind(cmd); - m_MeshVertexBuffer->Bind(cmd); - // TODO: Enhance this api - particleBuffer->BindAs(cmd, std::span{}, 1, 1); - - for (const auto& item : batch.Items) - { - const SMeshPushConstants pc{ - .WorldTransform = GetParticleRenderTransform(*item.Emitter, *item.Instance->Instance) - }; - - runtime->MeshShader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - cmd->Draw( - m_MeshVertexCount, - item.Emitter->MaxParticles, - 0, - item.Instance->Allocation.Particles.Offset + item.Emitter->LocalParticleOffset - ); - } - - return; - } - - case EParticleRenderMode::Ribbon: - { - runtime->RibbonPipeline->Bind(cmd); - - for (const auto& item : batch.Items) - { - const SRibbonPushConstants pc{ - .WorldTransform = GetParticleRenderTransform(*item.Emitter, *item.Instance->Instance), - .EmitterIndex = item.Instance->Allocation.Emitters.Offset + item.LocalEmitterIndex, - .ParticleBaseOffset = item.Instance->Allocation.Particles.Offset, - }; - - runtime->RibbonShader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - cmd->Draw(item.Emitter->MaxParticles * 6); - } - - return; - } - - case EParticleRenderMode::Sprite: - { - runtime->SpritePipeline->Bind(cmd); - particleBuffer->BindAs(cmd); - - for (const auto& item : batch.Items) - { - const SSpritePushConstants pc{ - .WorldTransform = GetParticleRenderTransform(*item.Emitter, *item.Instance->Instance), - .SpriteIndex = ResolveSpriteIndex(item.Emitter->SpriteTexture) - }; - - runtime->SpriteShader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - cmd->Draw( - 6, - item.Emitter->MaxParticles, - 0, - item.Instance->Allocation.Particles.Offset + item.Emitter->LocalParticleOffset - ); - } - - return; - } - } - - EE_CORE_ERROR( - "Aether cannot render unknown particle render mode '{}'.", - (uint32_t)batch.Key.RenderMode - ) - } - - void Renderer::BarrierSchedulingBuffers(const Ref& cmd) const - { - constexpr auto stage = EPipelineStage::ComputeShader; - constexpr auto access = EPipelineAccess::ShaderRead | EPipelineAccess::ShaderWrite; - - m_EmitterStateBuffer->Barrier(cmd, stage, access); - m_SpawnRequestBuffer->Barrier(cmd, stage, access); - m_TriggerEventBuffers[0]->Barrier(cmd, stage, access); - m_TriggerEventBuffers[1]->Barrier(cmd, stage, access); - m_TriggerQueueStateBuffer->Barrier(cmd, stage, access); - m_SystemSchedulerStateBuffer->Barrier(cmd, stage, access); - } - - void Renderer::ClearParticleAllocation(const SSystemInstanceAllocation& allocation) - { - const auto* layout = m_ParticleStateLayouts.Find(allocation.ParticleStateLayout); - const auto* runtime = FindParticleStateLayoutRuntime(allocation.ParticleStateLayout); - - runtime->ParticleStateBuffer->Fill( - 0, - int32_t(allocation.Particles.Offset * layout->ParticleStateStride), - allocation.Particles.Count * layout->ParticleStateStride - ); - } -} diff --git a/Elixir/Source/Engine/Aether/Renderer.h b/Elixir/Source/Engine/Aether/Renderer.h deleted file mode 100644 index 439d4f49..00000000 --- a/Elixir/Source/Engine/Aether/Renderer.h +++ /dev/null @@ -1,366 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace Elixir::Aether -{ - struct alignas(16) SFrameData - { - glm::mat4 View; - glm::mat4 Proj; - glm::mat4 ViewProj; - glm::vec3 CameraPos; - }; - - struct alignas(16) SParamsData - { - glm::vec4 Time{}; - glm::vec4 Viewport{}; - }; - - struct SEmitterData - { - glm::vec4 MetaA{}; - glm::vec4 MetaB{}; - glm::vec4 MetaC{}; - glm::vec4 MetaD{}; - }; - - struct SParticleOpData - { - glm::vec4 Header{}; - glm::vec4 Data0{}; - glm::vec4 Data1{}; - glm::vec4 Data2{}; - }; - - struct SParameterData - { - glm::vec4 Value{}; - }; - - struct SSystemInstanceData - { - uint32_t ParticleBaseOffset = 0; - uint32_t EmitterBaseOffset = 0; - uint32_t OpBaseOffset = 0; - uint32_t ParameterBaseOffset = 0; - - uint32_t EmitterStateBaseOffset = 0; - uint32_t SpawnRequestBaseOffset = 0; - uint32_t TriggerEventBaseOffset = 0; - uint32_t TriggerQueueStateBaseOffset = 0; - - uint32_t ParticleCount = 0; - uint32_t EmitterCount = 0; - uint32_t TriggerEventCapacityPerEmitter = 0; - uint32_t Generation = 0; - - uint32_t ParticleStateLayoutIndex = 0; - }; - - struct SEmitterInstanceStateData - { - float SpawnAccumulator = 0.0f; - float BurstAccumulator = 0.0f; - uint32_t BufferCursor = 0; - uint32_t EmissionIndex = 0; - - uint32_t Generation = 0; - }; - - struct SSpawnRequestData - { - uint32_t SpawnCursor = 0; - uint32_t SpawnCount = 0; - uint32_t EmissionIndex = 0; - uint32_t Generation = 0; - }; - - struct STriggerTargetData - { - uint32_t TargetEmitterIndex = 0; - uint32_t BurstCount = 0; - float DelaySeconds = 0.0f; - }; - - struct STriggerEventData - { - float RemainingDelaySeconds = 0.0f; - uint32_t SpawnCount = 0; - uint32_t Generation = 0; - }; - - struct STriggerQueueStateData - { - uint32_t Count = 0; - uint32_t OverflowCount = 0; - }; - - struct SSystemSchedulerStateData - { - uint32_t Generation = 0; - uint32_t ActiveTriggerBufferIndex = 0; - uint32_t ResetPending = 0; - }; - - // CPU-side observability only. These values describe the complete particle - // frame submitted for one Render() call; they do not read back GPU state. - struct SParticleSubmissionMetrics - { - uint64_t SubmissionSerial = 0u; - float DeltaTimeSeconds = 0.0f; - float ElapsedTimeSeconds = 0.0f; - - size_t RequestedSystemInstanceCount = 0; - size_t SubmittedSystemInstanceCount = 0; - - size_t RequestedEmitterCount = 0u; - size_t SubmittedEmitterCount = 0u; - - uint32_t RequestedParticleCapacity = 0u; - uint32_t SubmittedParticleCapacity = 0u; - - uint32_t ScheduledEmitterCount = 0; - uint32_t SpawnDispatchCount = 0; - uint32_t TriggerEventCapacityPerEmitter = 0; - - size_t SimulationBatchCount = 0; - size_t RenderBatchCount = 0; - size_t SubmittedRenderItemCount = 0; - }; - - class ELIXIR_API Renderer final - { - public: - static constexpr uint32_t COMPUTE_GROUP_SIZE = 256; - - Renderer( - const GraphicsContext* context, - const ShaderLoader* shaderLoader, - const SParticlePoolLimits& limits = {} - ); - - void Update(const Timestep& timestep); - void Render(const FrameSubmission& submission, const Camera& camera); - - // Must be called from the render-frame callback. The allocation remains - // resident until the current frame slot is recycled after its GPU fence. - void Retire(const SystemInstance& instance); - - // Read only at the frame boundary after Render() returns. - const SParticleSubmissionMetrics& GetLastSubmissionMetrics() const; - - private: - struct SParticleStateLayoutRuntime - { - EParticleStateLayout Key = EParticleStateLayout::CoreV1; - Ref ParticleStateBuffer; - - Ref SpawnShader; - Ref SpawnPipeline; - Ref UpdateShader; - Ref UpdatePipeline; - - Ref SpriteShader; - Ref SpritePipeline; - Ref RibbonShader; - Ref RibbonPipeline; - Ref MeshShader; - Ref MeshPipeline; - - bool IsReady() const - { - return ParticleStateBuffer && - SpawnShader && SpawnPipeline && - UpdateShader && UpdatePipeline && - SpriteShader && SpritePipeline && - RibbonShader && RibbonPipeline && - MeshShader && MeshPipeline; - } - }; - - void Init(const ShaderLoader* shaderLoader); - void CreateCoreV1ParticleStateLayoutRuntime(const ShaderLoader* shaderLoader); - void CreateBuffers(); - void CreateMeshVertexBuffer(); - void InitPerFrameData(); - void BindShaderParameters(); - void BindParticleStateLayoutShaderParameters(const SParticleStateLayoutRuntime& runtime) const; - - uint32_t ResolveSpriteIndex(const Ref& texture); - - void BeginRendering(const Ref& cmd) const; - void EndRendering(const Ref& cmd) const; - - struct SInstanceRecord - { - UUID SystemInstanceId; - uint32_t SystemInstanceRevision = 0; - UUID CompiledSystemId; - uint32_t CompilationRevision = 0; - uint32_t ParameterRevision = 0; - SSystemInstanceAllocation Allocation; - }; - - // Frame-local, renderer-owned snapshot. It decouples batch execution - // from m_InstanceRecords and remains valid for the complete Render(). - struct SSubmittedSystemInstance - { - const SystemInstance* Instance = nullptr; - SSystemInstanceAllocation Allocation; - EParticleStateLayout ParticleStateLayout = EParticleStateLayout::CoreV1; - }; - - struct SSimulationBatch - { - EParticleStateLayout ParticleStateLayout = EParticleStateLayout::CoreV1; - std::vector Instances; - }; - - struct SRenderBatchKey - { - EParticleStateLayout ParticleStateLayout = EParticleStateLayout::CoreV1; - EParticleRenderMode RenderMode = EParticleRenderMode::Sprite; - - bool operator==(const SRenderBatchKey&) const = default; - }; - - struct SRenderItem - { - const SSubmittedSystemInstance* Instance = nullptr; - const SCompiledEmitter* Emitter = nullptr; - uint32_t LocalEmitterIndex = 0; - }; - - struct SRenderBatch - { - SRenderBatchKey Key; - std::vector Items; - }; - - SInstanceRecord* ResolveInstanceRecord(const SystemInstance& instance); - void UploadCompiledSystem( - const SystemInstance& instance, - const SSystemInstanceAllocation& allocation - ) const; - void UploadInstanceParameters( - const SystemInstance& instance, - const SSystemInstanceAllocation& allocation - ) const; - - void QueueRetirement(SSystemInstanceAllocation allocation); - void ProcessCompletedRetirements(); - - void UpdateBuffers(SystemInstance const& instance, SInstanceRecord& record); - - SParticleStateLayoutRuntime* FindParticleStateLayoutRuntime(EParticleStateLayout layout); - const SParticleStateLayoutRuntime* FindParticleStateLayoutRuntime(EParticleStateLayout layout) const; - - bool IsParticleStateLayoutSupported(EParticleStateLayout layout) const; - - std::vector - BuildSimulationBatches(const std::vector& instances) const; - - std::vector - BuildRenderBatches(const std::vector& instances) const; - - void SimulateBatch( - const Ref& cmd, - const SSimulationBatch& batch - ); - - void RenderBatch( - const Ref& cmd, - const SRenderBatch& batch - ); - - void BarrierSchedulingBuffers(const Ref& cmd) const; - void ClearParticleAllocation(const SSystemInstanceAllocation& allocation); - - SFrameData m_FrameData{}; - Ref m_FrameConstantBuffer; - - struct alignas(16) SGPUParticleState - { - glm::vec4 PositionSize{}; - glm::vec4 VelocityAge{}; - glm::vec4 Transform{}; - glm::vec4 TangentRibbonId{}; - glm::vec4 Color{}; - glm::vec4 Metadata{}; - }; - - struct SSchedulePushConstants - { - uint32_t InstanceIndex = 0; - }; - - struct SSpawnPushConstants - { - uint32_t InstanceIndex = 0; - uint32_t EmitterIndex = 0; - }; - - using SUpdatePushConstants = SSchedulePushConstants; - - Ref m_SchedulerBeginShader; - Ref m_SchedulerBeginPipeline; - Ref m_SchedulerInitEmittersShader; - Ref m_SchedulerInitEmittersPipeline; - Ref m_SchedulerScheduleEmittersShader; - Ref m_SchedulerScheduleEmittersPipeline; - Ref m_SchedulerFinalizeShader; - Ref m_SchedulerFinalizePipeline; - - SParticlePoolLimits m_ParticlePoolLimits; - ParticleStateLayoutRegistry m_ParticleStateLayouts; - std::vector m_ParticleStateLayoutRuntimes; - ParticleResourcePool m_ParticleResourcePool; - std::unordered_map m_InstanceRecords; - std::unordered_set m_AllocationFailures; - std::unordered_set m_UnsupportedParticleStateLayoutInstances; - std::array< - std::vector, - GraphicsContext::FRAMES - > m_DeferredRetirements; - - Ref m_EmitterStateBuffer; - Ref m_SpawnRequestBuffer; - Ref m_TriggerTargetBuffer; - std::array, 2> m_TriggerEventBuffers; - Ref m_TriggerQueueStateBuffer; - Ref m_SystemSchedulerStateBuffer; - - Ref m_SystemInstanceBuffer; - Ref m_EmitterBuffer; - Ref m_OpBuffer; - Ref m_ParameterBuffer; - Ref m_ParamsBuffer; - - Ref m_Sprites; - Ref m_SpriteSampler; - std::unordered_map, SResourceHandle> m_SpriteTextures; - - SResourceHandle m_WhiteTextureHandle{}; - - uint32_t m_MeshVertexCount = 0; - Ref m_MeshVertexBuffer; - - float m_LastDeltaTimeSeconds = 0.0f; - float m_ElapsedTimeSeconds = 0.0f; - - uint64_t m_SubmissionSerial = 0; - SParticleSubmissionMetrics m_LastSubmissionMetrics{}; - - Extent2D m_RenderExtent{}; - const GraphicsContext* m_GraphicsContext; - }; -} diff --git a/Elixir/Source/Engine/Aether/Rendering/FrameSubmission.h b/Elixir/Source/Engine/Aether/Rendering/FrameSubmission.h new file mode 100644 index 00000000..c80881c5 --- /dev/null +++ b/Elixir/Source/Engine/Aether/Rendering/FrameSubmission.h @@ -0,0 +1,255 @@ +#pragma once + +#include +#include + +namespace Elixir::Aether::Rendering +{ + /** + * @brief Collects immutable system-instance render proxies for one frame. + * + * A single producer builds a submission by adding managed SystemInstance + * objects. Submit() captures each instance once and stores only its immutable + * SystemInstanceRenderProxy. + * + * Publish a completed submission through FrameSubmissionPublisher. A sealed + * submission cannot accept or remove instances. + * + * @thread_safety Not synchronized. One producer must build or reset a + * submission at a time. + */ + class ELIXIR_API FrameSubmission final + { + friend class FrameSubmissionPublisher; + + public: + /** + * @brief Captures an instance for this frame. + * + * The method captures the instance's current immutable render proxy. The + * same instance can appear only once in a submission. + * + * @param instance Runtime instance to capture. + * @return True when the instance was added. + * @return False when the instance is unregistered, duplicated, or the + * submission is sealed. + */ + bool Submit(const SystemInstance& instance) + { + if (m_IsSealed) return false; + + const auto [_, inserted] = m_InstanceKeys.insert(instance.GetKey()); + if (!inserted) return false; + + const auto snapshot = instance.CaptureSnapshot(); + if (!snapshot) + { + m_InstanceKeys.erase(instance.GetKey()); + return false; + } + + m_RenderProxies.push_back(snapshot->GetRenderProxy()); + return true; + } + + /** + * @brief Removes a captured instance before publication. + * + * @param instance Runtime instance to remove. + * @return True when the instance was removed. + * @return False when the submission is sealed or does not contain instance. + */ + bool Remove(const SystemInstance& instance) + { + if (m_IsSealed) return false; + + const auto found = m_InstanceKeys.find(instance.GetKey()); + if (found == m_InstanceKeys.end()) return false; + + std::erase_if(m_RenderProxies, [&instance](const auto& proxy) + { + return proxy->GetKey() == instance.GetKey(); + }); + + m_InstanceKeys.erase(found); + + return true; + } + + /** + * @brief Clears all captured instances. + * + * Call this method only before publishing the submission. + */ + void Reset() + { + m_RenderProxies.clear(); + m_InstanceKeys.clear(); + } + + /** + * @brief Creates a sealed copy that excludes one internal instance key. + * + * @param key Internal identity of the instance to exclude. + * @return A sealed submission that does not contain key. + */ + Ref Without(const SSystemInstanceKey& key) const + { + auto copy = CreateRef(); + copy->m_RenderProxies = m_RenderProxies; + copy->m_InstanceKeys = m_InstanceKeys; + + std::erase_if(copy->m_RenderProxies, [&key](const auto& proxy) + { + return proxy->GetKey() == key; + }); + + copy->m_InstanceKeys.erase(key); + copy->m_IsSealed = true; + return copy; + } + + /** + * @brief Reports whether this submission is immutable. + * @return True after a publisher seals the submission. + */ + bool IsSealed() const { return m_IsSealed; } + + /** + * @brief Reports whether this submission contains no instances. + * @return True when no render proxies were captured. + */ + bool IsEmpty() const { return m_RenderProxies.empty(); } + + /** + * @brief Returns the number of captured instances. + * @return Number of immutable render proxies in this submission. + */ + size_t GetInstanceCount() const { return m_RenderProxies.size(); } + + /** + * @brief Returns the renderer-facing proxies captured for this frame. + * @return Immutable render proxies captured for this frame. + */ + const std::vector>& GetRenderProxies() const + { + return m_RenderProxies; + } + + private: + // Creates a sealed copy that retains only keys accepted by the predicate. + template + Ref WithAllowedInstances(T&& isAllowed) const + { + auto copy = CreateRef(); + copy->m_RenderProxies = m_RenderProxies; + copy->m_InstanceKeys = m_InstanceKeys; + + std::erase_if(copy->m_RenderProxies, [&isAllowed](const auto& proxy) + { + return !isAllowed(proxy->GetKey()); + }); + + std::erase_if(copy->m_InstanceKeys, [&isAllowed](const auto& key) + { + return !isAllowed(key); + }); + + copy->m_IsSealed = true; + return copy; + } + + // Prevents further instance additions and removals. + void Seal() { m_IsSealed = true; } + + bool m_IsSealed = false; + std::vector> m_RenderProxies; + std::unordered_set m_InstanceKeys; + }; + + /** + * @brief Publishes the latest immutable Aether frame submission. + * + * A producer publishes a completed FrameSubmission, and the render path + * acquires the latest sealed copy. Publishing replaces the previous + * submission; this class does not queue multiple frames. + * + * @thread_safety All public methods synchronize access to the published + * submission. + */ + class ELIXIR_API FrameSubmissionPublisher final + { + public: + /** + * @brief Publishes a submission without filtering instances. + * + * @param submission Submission to seal and publish. + * + * @pre submission is not null. + */ + void Publish(Ref submission) + { + Publish(std::move(submission), [](const SSystemInstanceKey&) + { + return true; + }); + } + + /** + * @brief Publishes a filtered copy of a submission. + * + * The method seals the result and retains only proxies whose internal keys + * are accepted by isAllowed. + * + * @tparam T Predicate type that accepts SSystemInstanceKey. + * @param submission Submission to filter, seal, and publish. + * @param isAllowed Predicate that selects managed instances. + * + * @pre submission is not null. + */ + template + void Publish(Ref submission, T&& isAllowed) + { + EE_CORE_ASSERT(submission, "Aether frame submission cannot be null.") + submission->Seal(); + + const auto filtered = submission->WithAllowedInstances( + std::forward(isAllowed) + ); + + const std::scoped_lock lock(m_Mutex); + m_Published = std::move(filtered); + } + + /** + * @brief Acquires the latest published submission. + * + * @return The latest sealed submission, or null when none was published. + */ + Ref Acquire() + { + const std::scoped_lock lock(m_Mutex); + return m_Published; + } + + /** + * @brief Removes an instance from the currently published submission. + * + * Manager calls this method while detaching an instance. The replacement + * submission remains sealed and does not contain the removed instance. + * + * @param instance Runtime instance to remove. + */ + void Remove(const SystemInstance& instance) + { + const std::scoped_lock lock(m_Mutex); + + if (m_Published) + m_Published = m_Published->Without(instance.GetKey()); + } + + private: + mutable std::mutex m_Mutex; + Ref m_Published; + }; +} diff --git a/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp b/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp new file mode 100644 index 00000000..44a3c3ed --- /dev/null +++ b/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp @@ -0,0 +1,368 @@ +#include "epch.h" +#include "Renderer.h" + +namespace Elixir::Aether::Rendering +{ + using namespace Core; + using namespace Materials::Rendering; + + namespace + { + struct SMeshVertex + { + glm::vec3 Position; + glm::vec3 Normal; + }; + + struct SSpritePushConstants + { + glm::mat4 WorldTransform{ 1.0f }; + uint32_t MaterialIndex = UINT32_MAX; + }; + + struct SMeshPushConstants + { + glm::mat4 WorldTransform{ 1.0f }; + uint32_t MaterialIndex = UINT32_MAX; + }; + + struct SRibbonPushConstants + { + glm::mat4 WorldTransform{ 1.0f }; + uint32_t EmitterIndex = 0; + uint32_t ParticleBaseOffset = 0; + uint32_t MaterialIndex = UINT32_MAX; + }; + } + + Renderer::Renderer(const GraphicsContext* context) + : m_GraphicsContext(context) + { + EE_CORE_ASSERT(context, "Aether Renderer requires a graphics context.") + EE_CORE_INFO("Initializing Aether Renderer.") + + CreateCoreV1GraphicsLayout(); + CreateMeshVertexBuffer(); + InitPerFrameData(); + } + + MaterialRenderScene Renderer::BuildRenderScene( + const RenderFrame& frame, + const Camera& camera + ) + { + m_LastMetrics = { .SubmissionSerial = frame.GetSubmissionSerial() }; + + m_FrameData.View = camera.GetViewMatrix(); + m_FrameData.Proj = camera.GetProjectionMatrix(); + m_FrameData.ViewProj = camera.GetViewProjectionMatrix(); + m_FrameData.CameraPos = camera.GetPosition(); + m_FrameData.Time = frame.GetElapsedTimeSeconds(); + m_FrameConstantBuffer->UpdateData(&m_FrameData, sizeof(m_FrameData)); + + auto scene = BuildScene(frame); + m_LastMetrics.SubmittedMaterialCount = scene.GetItems().size(); + + return scene; + } + + void Renderer::CreateCoreV1GraphicsLayout() + { + SParticleGraphicsLayout layout{ + .Key = EParticleStateLayout::CoreV1, + }; + + layout.SpriteVertexLayout = {{ + { + { + { EDataType::Vec4, "PositionSize" }, + { EDataType::Vec4, "VelocityAge" }, + { EDataType::Vec4, "Transform" }, + { EDataType::Vec4, "TangentRibbonId" }, + { EDataType::Vec4, "Color" }, + { EDataType::Vec4, "Metadata" } + }, + EInputRate::Instance + } + }}; + + layout.MeshVertexLayout = {{ + { + { + { EDataType::Vec3, "Position" }, + { EDataType::Vec3, "Normal" }, + }, + EInputRate::Vertex + }, + { + { + { EDataType::Vec4, "PositionSize" }, + { EDataType::Vec4, "VelocityAge" }, + { EDataType::Vec4, "Transform" }, + { EDataType::Vec4, "TangentRibbonId" }, + { EDataType::Vec4, "Color" }, + { EDataType::Vec4, "Metadata" } + }, + EInputRate::Instance + } + }}; + + m_GraphicsLayouts.push_back(std::move(layout)); + } + + void Renderer::CreateMeshVertexBuffer() + { + static constexpr std::array vertices = {{ + {{-0.5f, -0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}}, + {{0.5f, -0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}}, + {{0.5f, 0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}}, + {{-0.5f, -0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}}, + {{0.5f, 0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}}, + {{-0.5f, 0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}}, + + {{0.5f, -0.5f, -0.5f}, {0.0f, 0.0f, -1.0f}}, + {{-0.5f, -0.5f, -0.5f}, {0.0f, 0.0f, -1.0f}}, + {{-0.5f, 0.5f, -0.5f}, {0.0f, 0.0f, -1.0f}}, + {{0.5f, -0.5f, -0.5f}, {0.0f, 0.0f, -1.0f}}, + {{-0.5f, 0.5f, -0.5f}, {0.0f, 0.0f, -1.0f}}, + {{0.5f, 0.5f, -0.5f}, {0.0f, 0.0f, -1.0f}}, + + {{-0.5f, -0.5f, -0.5f}, {-1.0f, 0.0f, 0.0f}}, + {{-0.5f, -0.5f, 0.5f}, {-1.0f, 0.0f, 0.0f}}, + {{-0.5f, 0.5f, 0.5f}, {-1.0f, 0.0f, 0.0f}}, + {{-0.5f, -0.5f, -0.5f}, {-1.0f, 0.0f, 0.0f}}, + {{-0.5f, 0.5f, 0.5f}, {-1.0f, 0.0f, 0.0f}}, + {{-0.5f, 0.5f, -0.5f}, {-1.0f, 0.0f, 0.0f}}, + + {{0.5f, -0.5f, 0.5f}, {1.0f, 0.0f, 0.0f}}, + {{0.5f, -0.5f, -0.5f}, {1.0f, 0.0f, 0.0f}}, + {{0.5f, 0.5f, -0.5f}, {1.0f, 0.0f, 0.0f}}, + {{0.5f, -0.5f, 0.5f}, {1.0f, 0.0f, 0.0f}}, + {{0.5f, 0.5f, -0.5f}, {1.0f, 0.0f, 0.0f}}, + {{0.5f, 0.5f, 0.5f}, {1.0f, 0.0f, 0.0f}}, + + {{-0.5f, 0.5f, 0.5f}, {0.0f, 1.0f, 0.0f}}, + {{0.5f, 0.5f, 0.5f}, {0.0f, 1.0f, 0.0f}}, + {{0.5f, 0.5f, -0.5f}, {0.0f, 1.0f, 0.0f}}, + {{-0.5f, 0.5f, 0.5f}, {0.0f, 1.0f, 0.0f}}, + {{0.5f, 0.5f, -0.5f}, {0.0f, 1.0f, 0.0f}}, + {{-0.5f, 0.5f, -0.5f}, {0.0f, 1.0f, 0.0f}}, + + {{-0.5f, -0.5f, -0.5f}, {0.0f, -1.0f, 0.0f}}, + {{0.5f, -0.5f, -0.5f}, {0.0f, -1.0f, 0.0f}}, + {{0.5f, -0.5f, 0.5f}, {0.0f, -1.0f, 0.0f}}, + {{-0.5f, -0.5f, -0.5f}, {0.0f, -1.0f, 0.0f}}, + {{0.5f, -0.5f, 0.5f}, {0.0f, -1.0f, 0.0f}}, + {{-0.5f, -0.5f, 0.5f}, {0.0f, -1.0f, 0.0f}}, + }}; + + m_MeshVertexCount = (uint32_t)vertices.size(); + m_MeshVertexBuffer = VertexBuffer::Create( + m_GraphicsContext, + sizeof(vertices), + vertices.data() + ); + + const auto* layout = FindGraphicsLayout(EParticleStateLayout::CoreV1); + EE_CORE_ASSERT(layout, "Aether CoreV1 graphics layout is missing.") + if (!layout) return; + + m_MeshVertexBuffer->SetLayout(layout->MeshVertexLayout); + } + + void Renderer::InitPerFrameData() + { + m_FrameConstantBuffer = UniformBuffer::Create( + m_GraphicsContext, + sizeof(m_FrameData), + &m_FrameData + ); + } + + const Renderer::SParticleGraphicsLayout* Renderer::FindGraphicsLayout( + const EParticleStateLayout key + ) const + { + for (const auto& layout : m_GraphicsLayouts) + if (layout.Key == key) return &layout; + return nullptr; + } + + const SParticleStateRenderResource* Renderer::FindRenderResource( + const RenderFrame& frame, + const EParticleStateLayout key + ) + { + for (const auto& resource : frame.GetResources()) + if (resource.Layout == key) return &resource; + + return nullptr; + } + + MaterialRenderScene Renderer::BuildScene(const RenderFrame& frame) const + { + MaterialRenderScene scene; + + static const BufferLayout ribbonVertexLayout; + + struct SGeometryIndices + { + uint32_t Sprite = UINT32_MAX; + uint32_t Ribbon = UINT32_MAX; + uint32_t Mesh = UINT32_MAX; + }; + + std::unordered_map geometries; + + const auto getGeometry = [this, &frame, &scene, &geometries]( + const EParticleStateLayout key + ) -> std::optional + { + const auto cacheKey = (uint32_t)key; + if (const auto found = geometries.find(cacheKey); found != geometries.end()) + return found->second; + + const auto* layout = FindGraphicsLayout(key); + EE_CORE_ASSERT(layout, "Aether graphics layout is missing.") + + const auto* resource = FindRenderResource(frame, key); + EE_CORE_ASSERT(resource, "Aether render resource is missing."); + + if (!layout || !resource || !resource->ParticleStateBuffer) + return std::nullopt; + + const std::array constantBuffers{ + SConstantBufferBinding{ + .Name = "cbFrame", + .Buffer = m_FrameConstantBuffer, + }, + }; + + const std::array ribbonStorageBuffers{ + SStorageBufferBinding{ + .Name = "particles", + .Buffer = MaterialStorageBuffer{ resource->ParticleStateBuffer }, + }, + SStorageBufferBinding{ + .Name = "emitters", + .Buffer = MaterialStorageBuffer{ frame.GetEmitterBuffer() }, + }, + }; + + const SGeometryIndices indices{ + .Sprite = scene.AddGeometry({ + .Pipeline = { + .VertexLayoutKey = cacheKey, + .VertexLayout = &layout->SpriteVertexLayout, + }, + .ConstantBuffers = { constantBuffers.begin(), constantBuffers.end() }, + .VertexBuffers = { + { .Buffer = resource->ParticleStateBuffer.get(), .Binding = 0 } + }, + }), + .Ribbon = scene.AddGeometry({ + .Pipeline = { + .VertexLayoutKey = cacheKey, + .VertexLayout = &ribbonVertexLayout, + }, + .ConstantBuffers = { constantBuffers.begin(), constantBuffers.end() }, + .StorageBuffers = { ribbonStorageBuffers.begin(), ribbonStorageBuffers.end() }, + }), + .Mesh = scene.AddGeometry({ + .Pipeline = { + .VertexLayoutKey = cacheKey, + .VertexLayout = &layout->MeshVertexLayout, + }, + .ConstantBuffers = { constantBuffers.begin(), constantBuffers.end() }, + .VertexBuffers = { + { .Buffer = m_MeshVertexBuffer.get(), .Binding = 0 }, + { .Buffer = resource->ParticleStateBuffer.get(), .Binding = 1 }, + }, + }), + }; + + geometries.emplace(cacheKey, indices); + return indices; + }; + + for (const auto& item : frame.GetItems()) + { + const auto geometry = getGeometry(item.ParticleStateLayout); + if (!geometry) continue; + + switch (item.RenderMode) + { + case EParticleRenderMode::Sprite: + { + const SSpritePushConstants constants{ + .WorldTransform = item.WorldTransform, + }; + + scene.Add({ + .Pass = EMaterialPass::ParticleSprite, + .Material = item.Material, + .GeometryIndex = geometry->Sprite, + .PushConstants = SMaterialPushConstants::Create( + constants, + offsetof(SSpritePushConstants, MaterialIndex) + ), + .Draw = { + .VertexCount = 6, + .InstanceCount = item.ParticleCount, + .FirstInstance = item.Allocation.Particles.Offset + + item.LocalParticleOffset, + }, + }); + break; + } + + case EParticleRenderMode::Ribbon: + { + const SRibbonPushConstants constants{ + .WorldTransform = item.WorldTransform, + .EmitterIndex = item.Allocation.Emitters.Offset + item.EmitterIndex, + .ParticleBaseOffset = item.Allocation.Particles.Offset, + }; + + scene.Add({ + .Pass = EMaterialPass::ParticleRibbon, + .Material = item.Material, + .GeometryIndex = geometry->Ribbon, + .PushConstants = SMaterialPushConstants::Create( + constants, + offsetof(SRibbonPushConstants, MaterialIndex) + ), + .Draw = { .VertexCount = item.ParticleCount * 6 }, + }); + break; + } + + case EParticleRenderMode::Mesh: + { + const SMeshPushConstants constants{ + .WorldTransform = item.WorldTransform, + }; + + scene.Add({ + .Pass = EMaterialPass::ParticleMesh, + .Material = item.Material, + .GeometryIndex = geometry->Mesh, + .PushConstants = SMaterialPushConstants::Create( + constants, + offsetof(SMeshPushConstants, MaterialIndex) + ), + .Draw = { + .VertexCount = m_MeshVertexCount, + .InstanceCount = item.ParticleCount, + .FirstInstance = item.Allocation.Particles.Offset + + item.LocalParticleOffset, + }, + }); + break; + } + } + } + + return scene; + } +} diff --git a/Elixir/Source/Engine/Aether/Rendering/Renderer.h b/Elixir/Source/Engine/Aether/Rendering/Renderer.h new file mode 100644 index 00000000..2f29e35c --- /dev/null +++ b/Elixir/Source/Engine/Aether/Rendering/Renderer.h @@ -0,0 +1,129 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace Elixir::Aether::Rendering +{ + using namespace Core; + using namespace Simulation; + using namespace Materials; + using namespace Materials::Rendering; + + struct alignas(16) SFrameData + { + glm::mat4 View; + glm::mat4 Proj; + glm::mat4 ViewProj; + glm::vec3 CameraPos; + float Time = 0.0f; + }; + + /** + * @brief Stores statistics from the most recent rendering operation. + * + * These values describe the commands recorded by the most recent call to + * Renderer::Render(). Renderer resets them before it renders another frame. + */ + struct SRenderingMetrics + { + uint64_t SubmissionSerial = 0u; + size_t RenderBatchCount = 0; + size_t SubmittedRenderItemCount = 0; + size_t SubmittedMaterialCount = 0; + }; + + /** + * @brief Records particle draw commands. + * + * Renderer reads an immutable RenderFrame produced by Simulator. It converts + * the frame's particle items into a material render scene and records the + * graphics commands. + * + * Renderer does not simulate particles, allocate simulation resources, or + * submit command buffers. + * + * @thread_safety Use this class only from the render-frame thread. + */ + class ELIXIR_API Renderer final + { + public: + /** + * @brief Creates the resources required to render particles. + * @param context Graphics context that owns the rendering resources. + * @pre context is not null and outlives the renderer. + */ + explicit Renderer(const GraphicsContext* context); + + /** + * @brief Builds material draw data for a simulated particle frame. + * + * The method updates the frame constants, creates a material render scene, + * and returns it to the application-owned material system. + * + * @param frame Particle data produced by Simulator. + * @param camera Camera used to transform and project the particles. + * @return Material render scene. + */ + MaterialRenderScene BuildRenderScene( + const RenderFrame& frame, + const Camera& camera + ); + + /** + * @brief Returns statistics from the most recent rendering operation. + * @return Statistics produced by the most recent call to Render(). + * @note A later call to Render() replaces these values. + */ + const SRenderingMetrics& GetLastMetrics() const + { + return m_LastMetrics; + } + + private: + // Stores the vertex layouts used to render one particle-state layout. + struct SParticleGraphicsLayout + { + EParticleStateLayout Key = EParticleStateLayout::CoreV1; + BufferLayout SpriteVertexLayout; + BufferLayout MeshVertexLayout; + }; + + // Creates the sprite and mesh vertex layouts for CoreV1 particles. + void CreateCoreV1GraphicsLayout(); + + // Creates the unit mesh geometry used by mesh particle rendering. + void CreateMeshVertexBuffer(); + + // Initializes per-frame constant-buffer data. + void InitPerFrameData(); + + // Finds the graphics layout for a particle-state layout. + const SParticleGraphicsLayout* FindGraphicsLayout(EParticleStateLayout key) const; + + // Finds the particle-state resource for a layout in the render frame. + static const SParticleStateRenderResource* FindRenderResource( + const RenderFrame& frame, + EParticleStateLayout key + ); + + // Converts particle render items into material geometry and draw commands. + MaterialRenderScene BuildScene(const RenderFrame& frame) const; + + SFrameData m_FrameData{}; + Ref m_FrameConstantBuffer; + + std::vector m_GraphicsLayouts; + + uint32_t m_MeshVertexCount = 0; + Ref m_MeshVertexBuffer; + + SRenderingMetrics m_LastMetrics{}; + + Extent2D m_RenderExtent{}; + const GraphicsContext* m_GraphicsContext = nullptr; + }; +} diff --git a/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.cpp b/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.cpp new file mode 100644 index 00000000..a8da84a7 --- /dev/null +++ b/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.cpp @@ -0,0 +1,29 @@ +#include "epch.h" +#include "SystemInstanceRenderProxy.h" + +namespace Elixir::Aether::Rendering +{ + SystemInstanceRenderProxy::SystemInstanceRenderProxy( + const SSystemInstanceKey& key, + const uint32_t revision, + const uint32_t parameterRevision, + Ref system, + const glm::mat4& worldTransform, + Ref parameters + ) : m_Key(key), + m_Revision(revision), + m_ParameterRevision(parameterRevision), + m_CompiledSystem(std::move(system)), + m_WorldTransform(worldTransform), + m_Parameters(std::move(parameters)) + { + EE_CORE_ASSERT( + m_Parameters, + "SystemInstanceRenderProxy requires resolved parameter values." + ) + EE_CORE_ASSERT( + m_Parameters->size() == m_CompiledSystem->Parameters.size(), + "Aether resolved parameter table does not match the compiled system." + ) + } +} diff --git a/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.h b/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.h new file mode 100644 index 00000000..8711cc45 --- /dev/null +++ b/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.h @@ -0,0 +1,98 @@ +#pragma once + +#include + +namespace Elixir::Aether::Simulation { class Simulator; } + +namespace Elixir::Aether::Rendering +{ + using Aether::SystemInstanceSnapshot; + using Simulation::Simulator; + + /** + * @brief Provides immutable SystemInstance data to the particle renderer. + * + * SystemInstanceSnapshot creates this proxy from one compiled system, world + * transform, and resolved parameter table. FrameSubmission stores the proxy, + * so Renderer never reads mutable SystemInstance state. + * + * The proxy shares the immutable parameter table created by SystemInstance. + * Its values have the same order as SCompiledSystem::Parameters. + * + * @thread_safety Immutable after construction. + */ + class SystemInstanceRenderProxy + { + friend class SystemInstanceSnapshot; + friend class FrameSubmission; + friend class Simulator; + + public: + /** + * @brief Returns a resolved parameter value by compiled-table index. + * + * @param parameterIndex Index in SCompiledSystem::Parameters. + * @return The instance override when present; otherwise, the compiled + * default value. + * + * @pre parameterIndex is less than SCompiledSystem::Parameters::size(). + * @warning An invalid index triggers an assertion and returns a zero vector + * when assertions do not stop execution. + */ + glm::vec4 GetParameterValue(const uint32_t parameterIndex) const + { + EE_CORE_ASSERT( + parameterIndex < m_Parameters->size(), + "Aether parameter index is outside the render proxy table." + ) + return parameterIndex < m_Parameters->size() + ? (*m_Parameters)[parameterIndex] + : glm::vec4{}; + } + + /** + * @brief Returns the compiled-system selection revision. + * @return Revision incremented when the selected compiled system changes. + */ + uint32_t GetRevision() const { return m_Revision; } + + /** + * @brief Returns the resolved parameter-value revision. + * @return Revision incremented when effective parameter values change. + */ + uint32_t GetParameterRevision() const { return m_ParameterRevision; } + + /** + * @brief Returns the immutable compiled system used by this proxy. + * @return Compiled system selected by the source instance. + */ + const SCompiledSystem& GetCompiledSystem() const { return *m_CompiledSystem; } + + /** + * @brief Returns the world transform selected by the source instance. + * @return Immutable world transform for this frame. + */ + const glm::mat4& GetWorldTransform() const { return m_WorldTransform; } + + private: + // Stores immutable state already resolved by SystemInstance. + SystemInstanceRenderProxy( + const SSystemInstanceKey& key, + uint32_t revision, + uint32_t parameterRevision, + Ref system, + const glm::mat4& worldTransform, + Ref parameters + ); + + // Returns the internal identity used by Renderer instance records. + const SSystemInstanceKey& GetKey() const { return m_Key; } + + SSystemInstanceKey m_Key; + uint32_t m_Revision = 1; + uint32_t m_ParameterRevision = 1; + Ref m_CompiledSystem; + glm::mat4 m_WorldTransform{ 1.0f }; + Ref m_Parameters; + }; +} diff --git a/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRetirementQueue.h b/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRetirementQueue.h new file mode 100644 index 00000000..224641ea --- /dev/null +++ b/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRetirementQueue.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +namespace Elixir::Aether::Rendering +{ + /** + * @brief Transfers detached system instances to the render-frame retirement path. + * + * Manager enqueues an instance after removing it from the active registry and + * published submissions. During BeginFrame(), Manager drains this queue and + * asks Renderer to retire the instance's GPU allocation. + * + * The queue retains a reference to each pending instance until the render path + * accepts its retirement. Renderer performs the later fence-safe GPU release. + * + * @thread_safety All public methods synchronize access to pending instances. + */ + class ELIXIR_API SystemInstanceRetirementQueue final + { + public: + /** + * @brief Adds a detached instance to the retirement handoff. + * + * @param instance Instance whose GPU allocation must be retired. + * + * @pre instance is not null. + */ + void Enqueue(Ref instance) + { + EE_CORE_ASSERT(instance, "Aether instance retirement cannot be null.") + + const std::scoped_lock lock(m_Mutex); + m_Pending.push_back(std::move(instance)); + } + + /** + * @brief Removes and returns all pending instances. + * + * @return Instances waiting to be forwarded to Renderer. + * + * @note The returned instances are no longer retained by this queue. + */ + std::vector> Drain() + { + const std::scoped_lock lock(m_Mutex); + return std::exchange(m_Pending, {}); + } + + private: + std::mutex m_Mutex; + std::vector> m_Pending; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp new file mode 100644 index 00000000..e1c8b92c --- /dev/null +++ b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp @@ -0,0 +1,144 @@ +#include "epch.h" +#include "InstanceRegistry.h" + +#include + +namespace Elixir::Aether::Runtime +{ + InstanceRegistry::InstanceRegistry(MaterialRegistry& materialRegistry) + : m_EffectMaterials(materialRegistry) {} + + bool InstanceRegistry::Recompile(const Ref& system) + { + EE_CORE_ASSERT(system, "Aether requires a System asset.") + if (!system) return false; + + const std::scoped_lock lock(m_Mutex); + + const auto compiled = CompileSystem(*system); + if (!compiled) return false; + + m_CompiledSystems.insert_or_assign(system->GetId(), compiled); + + for (const auto& instance : m_Instances | std::views::values) + { + if (instance->GetSourceSystemId() == system->GetId()) + instance->ApplyCompilation(compiled); + } + + return true; + } + + bool InstanceRegistry::Register(const Ref& instance) + { + if (!instance || !instance->TryBeginSubmission()) + return false; + + const auto system = instance->GetSourceSystem(); + if (!system) + { + instance->CancelSubmission(); + return false; + } + + { + const std::scoped_lock lock(m_Mutex); + + if (m_Instances.contains(instance->GetKey())) + { + instance->CancelSubmission(); + EE_CORE_ASSERT(false, "Aether system instance UUID must be unique.") + return false; + } + + auto compiled = m_CompiledSystems.find(system->GetId()); + if (compiled == m_CompiledSystems.end()) + { + const auto result = CompileSystem(*system); + if (!result) + { + instance->CancelSubmission(); + return false; + } + + compiled = m_CompiledSystems.emplace(system->GetId(), result).first; + } + + if (!instance->Initialize(compiled->second)) + { + instance->CancelSubmission(); + return false; + } + + m_Instances.emplace(instance->GetKey(), instance); + } + + return true; + } + + Ref InstanceRegistry::Unregister( + const Ref& instance + ) + { + const std::scoped_lock lock(m_Mutex); + + if (!IsManagedInstance(instance)) + return nullptr; + + const auto found = m_Instances.find(instance->GetKey()); + if (found == m_Instances.end()) + return nullptr; + + m_Publisher.Remove(*instance); + + auto detached = found->second; + m_Instances.erase(found); + + return detached; + } + + void InstanceRegistry::PublishActiveInstances() + { + const std::scoped_lock lock(m_Mutex); + + auto submission = CreateRef(); + + for (const auto& instance : m_Instances | std::views::values) + { + const bool submitted = submission->Submit(*instance); + EE_CORE_ASSERT( + submitted, + "Aether could not capture an active system instance." + ) + } + + m_Publisher.Publish(std::move(submission)); + } + + Ref InstanceRegistry::AcquireSubmission() + { + return m_Publisher.Acquire(); + } + + Ref InstanceRegistry::CompileSystem(const System& system) const + { + if (!m_EffectMaterials.Resolve(system)) + { + EE_CORE_ERROR( + "Could not resolve materials for Aether system '{}'.", + system.GetName() + ) + return nullptr; + } + + return CreateRef(system.Compile()); + } + + bool InstanceRegistry::IsManagedInstance(const Ref& instance) const + { + if (!instance) return false; + + const auto found = m_Instances.find(instance->GetKey()); + return found != m_Instances.end() && found->second.get() == instance.get(); + } +} diff --git a/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.h b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.h new file mode 100644 index 00000000..ef685f66 --- /dev/null +++ b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.h @@ -0,0 +1,109 @@ +#pragma once + +#include +#include +#include + +namespace Elixir::Materials +{ + class MaterialRegistry; +} + +namespace Elixir::Aether::Runtime +{ + using Rendering::FrameSubmission; + using Rendering::FrameSubmissionPublisher; + + /** + * @brief Manages compiled Aether systems and their runtime instances. + * + * InstanceRegistry accepts unregistered SystemInstance objects, resolves their + * effect materials, caches compiled systems, and maintains the persistent set + * of active instances. + * + * The registry does not retain authored System assets. A caller may release a + * System after creating its instances. The compiled representation remains + * available until this registry is destroyed. + * + * @note MaterialRegistry must outlive this registry. + * + * @thread_safety Public methods synchronize compilation, instance ownership, + * and frame publication. + */ + class ELIXIR_API InstanceRegistry final + { + public: + /** + * @brief Creates an instance registry. + * @param materialRegistry Stores effect-authored and default materials. + */ + explicit InstanceRegistry(MaterialRegistry& materialRegistry); + + /** + * @brief Recompiles a System and updates all of its registered instances. + * + * Compatible parameter overrides remain active. The method replaces the + * cached compilation and publishes a new snapshot from each affected + * instance. + * + * @param system System asset to recompile. + * @return True when the compilation succeeds. + */ + bool Recompile(const Ref& system); + + /** + * @brief Compiles, registers, and activates an instance. + * + * The instance remains active in every subsequent frame until it is + * detached. The first successful submission releases its authored System. + * + * @param instance Instance to capture. + * @return True when the instance was registered. + * @return False when the instance is null or was previously submitted. + * + * @thread_safety Concurrent calls are serialized. Exactly one concurrent + * submission of the same instance can succeed. + */ + bool Register(const Ref& instance); + + /** + * @brief Detaches a registered instance from the runtime. + * + * The method removes the instance from future frame submissions. It does + * not release GPU resources. + * + * @param instance Instance to detach. + * @return Detached instance, or null when it is not registered. + */ + Ref Unregister(const Ref& instance); + + /** + * @brief Publishes the current state of every active instance. + * + * A submission racing this method is included in either this frame or the + * next frame, according to the registry lock acquisition order. + */ + void PublishActiveInstances(); + + /** + * @brief Returns the latest immutable frame submission. + * @return Published submission, or null when none is available. + */ + Ref AcquireSubmission(); + + private: + // Compiles one authored System into immutable runtime data. + Ref CompileSystem(const System& system) const; + + // Checks ownership while m_Mutex is already held. + bool IsManagedInstance(const Ref& instance) const; + + Effect::MaterialResolver m_EffectMaterials; + + std::unordered_map> m_CompiledSystems; + std::unordered_map> m_Instances; + + mutable std::mutex m_Mutex; + FrameSubmissionPublisher m_Publisher; + }; +} diff --git a/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h b/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h new file mode 100644 index 00000000..b27d3ae9 --- /dev/null +++ b/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h @@ -0,0 +1,126 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace Elixir::Aether::Simulation +{ + using namespace Materials; + + /** + * @brief Retains the particle-state buffer for one simulation layout. + * + * Renderer selects the resource whose layout matches a render item. The + * strong buffer reference keeps persistent simulation data alive while the + * frame command buffer is recorded. + */ + struct SParticleStateRenderResource + { + Core::EParticleStateLayout Layout = Core::EParticleStateLayout::CoreV1; + Ref ParticleStateBuffer; + }; + + /** + * @brief Describes one immutable particle draw produced by Simulator. + * + * The item contains resolved allocation offsets, material state, transform, + * and emitter-local draw information. Renderer converts it into a + * MaterialRenderScene item without reading mutable system-instance state. + */ + struct SRenderItem + { + Core::SSystemInstanceAllocation Allocation; + Core::EParticleStateLayout ParticleStateLayout = Core::EParticleStateLayout::CoreV1; + Core::EParticleRenderMode RenderMode = Core::EParticleRenderMode::Sprite; + Ref Material; + glm::mat4 WorldTransform{ 1.0f }; + uint32_t EmitterIndex = 0; + uint32_t LocalParticleOffset = 0; + uint32_t ParticleCount = 0; + }; + + /** + * @brief Provides immutable particle render data for one simulated frame. + * + * Simulator creates a RenderFrame after recording particle simulation work and + * the required compute-to-graphics barriers. Renderer consumes the frame while + * recording the corresponding graphics commands. + * + * The frame owns its render items and metadata. It also retains strong + * references to the GPU buffers required by those items. It does not retain a + * command buffer, frame submission, or mutable system-instance state. + * + * @thread_safety Immutable after construction. Concurrent reads are safe when + * the referenced GPU resources are used according to their synchronization + * requirements. + */ + class ELIXIR_API RenderFrame final + { + public: + /** + * @brief Creates an immutable frame from resolved simulation output. + * + * @param resources Particle-state buffers paired with their layouts. + * @param emitterBuffer Buffer containing the resolved emitter data. + * @param items Particle draw items generated for the frame. + * @param submissionSerial Serial number of the source frame submission. + * @param elapsedTimeSeconds Total simulation time at frame publication. + */ + RenderFrame( + std::vector resources, + Ref emitterBuffer, + std::vector items, + const uint64_t submissionSerial, + const float elapsedTimeSeconds + ) : m_Resources(std::move(resources)), + m_EmitterBuffer(std::move(emitterBuffer)), + m_Items(std::move(items)), + m_SubmissionSerial(submissionSerial), + m_ElapsedTimeSeconds(elapsedTimeSeconds) {} + + RenderFrame(const RenderFrame&) = delete; + RenderFrame& operator=(const RenderFrame&) = delete; + RenderFrame(RenderFrame&&) = delete; + RenderFrame& operator=(RenderFrame&&) = delete; + + /** + * @brief Returns the particle-state resources required for rendering. + * @return Particle-state buffers paired with their layouts. + */ + const std::vector& GetResources() const { return m_Resources; } + + /** + * @brief Returns the emitter data buffer used by the frame. + * @return Strong reference to the emitter buffer. + */ + const Ref& GetEmitterBuffer() const { return m_EmitterBuffer; } + + /** + * @brief Returns the resolved particle draw items. + * @return Immutable collection of render items. + */ + const std::vector& GetItems() const { return m_Items; } + + /** + * @brief Returns the serial number of the source frame submission. + * @return The serial number of the submission that produced this frame. + */ + uint64_t GetSubmissionSerial() const { return m_SubmissionSerial; } + + /** + * @brief Returns the accumulated simulation time for this frame. + * @return Elapsed simulation time, in seconds. + */ + float GetElapsedTimeSeconds() const { return m_ElapsedTimeSeconds; } + + private: + std::vector m_Resources; + Ref m_EmitterBuffer; + std::vector m_Items; + uint64_t m_SubmissionSerial = 0; + float m_ElapsedTimeSeconds = 0.0f; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Aether/ParticleResourcePool.cpp b/Elixir/Source/Engine/Aether/Simulation/ResourcePool.cpp similarity index 92% rename from Elixir/Source/Engine/Aether/ParticleResourcePool.cpp rename to Elixir/Source/Engine/Aether/Simulation/ResourcePool.cpp index 02b218e6..a08495fa 100644 --- a/Elixir/Source/Engine/Aether/ParticleResourcePool.cpp +++ b/Elixir/Source/Engine/Aether/Simulation/ResourcePool.cpp @@ -1,10 +1,10 @@ #include "epch.h" -#include "ParticleResourcePool.h" +#include "ResourcePool.h" -namespace Elixir::Aether +namespace Elixir::Aether::Core { - ParticleResourcePool::ParticleResourcePool( - const SParticlePoolLimits& limits, + ResourcePool::ResourcePool( + const SResourcePoolLimits& limits, const ParticleStateLayoutRegistry& layouts ) : m_Limits(limits), m_FreeInstanceSlots(MakeFreeRanges(limits.MaxSystemInstances)), @@ -29,7 +29,7 @@ namespace Elixir::Aether } } - std::optional ParticleResourcePool::Allocate( + std::optional ResourcePool::Allocate( const SCompiledSystem& system ) { @@ -95,7 +95,7 @@ namespace Elixir::Aether return allocation; } - void ParticleResourcePool::Release(const SSystemInstanceAllocation& allocation) + void ResourcePool::Release(const SSystemInstanceAllocation& allocation) { auto* particleFreeRanges = FindParticleFreeRanges(allocation.ParticleStateLayout); EE_CORE_ASSERT( @@ -118,7 +118,7 @@ namespace Elixir::Aether ReleaseRange(m_FreeInstanceSlots, { allocation.InstanceIndex, 1 }); } - SBufferRange ParticleResourcePool::AllocateRange( + SBufferRange ResourcePool::AllocateRange( std::vector& freeRanges, const uint32_t count ) @@ -144,7 +144,7 @@ namespace Elixir::Aether return {}; } - void ParticleResourcePool::ReleaseRange( + void ResourcePool::ReleaseRange( std::vector& freeRanges, const SBufferRange range ) @@ -173,7 +173,7 @@ namespace Elixir::Aether freeRanges = std::move(merged); } - std::vector ParticleResourcePool::MakeFreeRanges(const uint32_t capacity) + std::vector ResourcePool::MakeFreeRanges(const uint32_t capacity) { if (capacity == 0) return {}; @@ -181,7 +181,7 @@ namespace Elixir::Aether return {{ 0, capacity }}; } - std::vector* ParticleResourcePool::FindParticleFreeRanges( + std::vector* ResourcePool::FindParticleFreeRanges( const EParticleStateLayout layout ) { diff --git a/Elixir/Source/Engine/Aether/Simulation/ResourcePool.h b/Elixir/Source/Engine/Aether/Simulation/ResourcePool.h new file mode 100644 index 00000000..f782294c --- /dev/null +++ b/Elixir/Source/Engine/Aether/Simulation/ResourcePool.h @@ -0,0 +1,99 @@ +#pragma once + +#include +#include +#include + +namespace Elixir::Aether::Core +{ + /** + * @brief Allocates shared logical resource ranges for Aether system instances. + * + * The pool reserves contiguous ranges for all tables required by a compiled + * system. Allocation is atomic from the caller's perspective: when any required + * range is unavailable, all ranges reserved by that request are released. + * + * The pool owns allocation policy only. The particle renderer owns the GPU buffers + * that correspond to these ranges and retires their contents after GPU + * synchronization. + * + * @thread_safety Not synchronized. Access it only from the renderer's + * allocation and retirement path. + */ + class ELIXIR_API ResourcePool final + { + public: + /** + * @brief Creates a pool with fixed shared-resource capacities. + * + * @param limits Maximum capacity of each logical resource table. + * @param layouts Registered particle-state layouts supported by the renderer. + * + * @pre Each registered layout has a non-zero particle capacity. + */ + explicit ResourcePool( + const SResourcePoolLimits& limits, + const ParticleStateLayoutRegistry& layouts + ); + + /** + * @brief Reserves all ranges required by a compiled system. + * + * @param system Compiled system whose tables require allocation. + * @return A complete allocation when all required ranges are available. + * @return std::nullopt when any required capacity is unavailable or the + * system requests an unsupported particle-state layout. + */ + std::optional Allocate(const SCompiledSystem& system); + + /** + * @brief Releases all ranges owned by an allocation. + * + * @param allocation Allocation previously returned by Allocate(). + * + * @pre allocation has not already been released. + */ + void Release(const SSystemInstanceAllocation& allocation); + + /** + * @brief Returns the fixed capacity limits of this pool. + * @return Resource limits selected during construction. + */ + const SResourcePoolLimits& GetLimits() const { return m_Limits; } + + private: + // Reserves one contiguous range from a free-list. + static SBufferRange AllocateRange(std::vector& freeRanges, uint32_t count); + + // Returns a range to a free-list and merges adjacent ranges. + static void ReleaseRange(std::vector& freeRanges, SBufferRange range); + + // Creates one free-range spanning an entire table. + static std::vector MakeFreeRanges(uint32_t capacity); + + // Finds the free-list associated with a particle-state layout. + std::vector* FindParticleFreeRanges(EParticleStateLayout layout); + + SResourcePoolLimits m_Limits; + + // Tracks free particle-state ranges for one registered layout. + struct SParticleStateLayoutAllocator + { + EParticleStateLayout Key = EParticleStateLayout::CoreV1; + std::vector FreeParticleRanges; + }; + std::vector m_ParticleStateLayoutAllocators; + + std::vector m_FreeInstanceSlots; + std::vector m_InstanceGenerations; + + std::vector m_FreeEmitters; + std::vector m_FreeOps; + std::vector m_FreeParameters; + std::vector m_FreeTriggerTargets; + std::vector m_FreeEmitterStates; + std::vector m_FreeSpawnRequests; + std::vector m_FreeTriggerEvents; + std::vector m_FreeTriggerQueueStates; + }; +} diff --git a/Elixir/Source/Engine/Aether/Simulation/Simulator.cpp b/Elixir/Source/Engine/Aether/Simulation/Simulator.cpp new file mode 100644 index 00000000..5a02ee78 --- /dev/null +++ b/Elixir/Source/Engine/Aether/Simulation/Simulator.cpp @@ -0,0 +1,1112 @@ +#include "epch.h" +#include "Simulator.h" + +#include + +namespace Elixir::Aether::Simulation +{ + using namespace Core; + using namespace Modules; + using Rendering::SystemInstanceRenderProxy; + using Rendering::FrameSubmission; + + namespace + { + + struct alignas(16) SParamsData + { + glm::vec4 Time{}; + glm::vec4 Viewport{}; + }; + + struct SEmitterData + { + glm::vec4 MetaA{}; + glm::vec4 MetaB{}; + glm::vec4 MetaC{}; + glm::vec4 MetaD{}; + }; + + struct SParticleOpData + { + glm::vec4 Header{}; + glm::vec4 Data0{}; + glm::vec4 Data1{}; + glm::vec4 Data2{}; + }; + + struct SParameterData + { + glm::vec4 Value{}; + }; + + struct SSystemInstanceData + { + uint32_t ParticleBaseOffset = 0; + uint32_t EmitterBaseOffset = 0; + uint32_t OpBaseOffset = 0; + uint32_t ParameterBaseOffset = 0; + + uint32_t EmitterStateBaseOffset = 0; + uint32_t SpawnRequestBaseOffset = 0; + uint32_t TriggerEventBaseOffset = 0; + uint32_t TriggerQueueStateBaseOffset = 0; + + uint32_t ParticleCount = 0; + uint32_t EmitterCount = 0; + uint32_t TriggerEventCapacityPerEmitter = 0; + uint32_t Generation = 0; + + uint32_t ParticleStateLayoutIndex = 0; + }; + + struct SEmitterInstanceStateData + { + float SpawnAccumulator = 0.0f; + float BurstAccumulator = 0.0f; + uint32_t BufferCursor = 0; + uint32_t EmissionIndex = 0; + + uint32_t Generation = 0; + }; + + struct SSpawnRequestData + { + uint32_t SpawnCursor = 0; + uint32_t SpawnCount = 0; + uint32_t EmissionIndex = 0; + uint32_t Generation = 0; + }; + + struct STriggerTargetData + { + uint32_t TargetEmitterIndex = 0; + uint32_t BurstCount = 0; + float DelaySeconds = 0.0f; + }; + + struct STriggerEventData + { + float RemainingDelaySeconds = 0.0f; + uint32_t SpawnCount = 0; + uint32_t Generation = 0; + }; + + struct STriggerQueueStateData + { + uint32_t Count = 0; + uint32_t OverflowCount = 0; + }; + + struct SSystemSchedulerStateData + { + uint32_t Generation = 0; + uint32_t ActiveTriggerBufferIndex = 0; + uint32_t ResetPending = 0; + }; + + // Mirrors one CoreV1 particle state in GPU storage. + struct alignas(16) SGPUParticleState + { + glm::vec4 PositionSize{}; + glm::vec4 VelocityAge{}; + glm::vec4 Transform{}; + glm::vec4 TangentRibbonId{}; + glm::vec4 Color{}; + glm::vec4 Metadata{}; + }; + + // Identifies the system instance processed by scheduler compute dispatches. + struct SSchedulePushConstants + { + uint32_t InstanceIndex = 0; + }; + + // Identifies the system instance and emitter processed by spawn dispatches. + struct SSpawnPushConstants + { + uint32_t InstanceIndex = 0; + uint32_t EmitterIndex = 0; + }; + + // Update dispatches use the same ABI as scheduler dispatches. + using SUpdatePushConstants = SSchedulePushConstants; + + SEmitterData ToEmitterData( + const SCompiledEmitter& emitter, + uint32_t opBaseOffset, + uint32_t triggerTargetBaseOffset + ) + { + SEmitterData desc{}; + desc.MetaA = { + emitter.LocalParticleOffset, + emitter.MaxParticles, + opBaseOffset + emitter.SpawnOpOffset, + emitter.SpawnOpCount + }; + + desc.MetaB = { + opBaseOffset + emitter.UpdateOpOffset, + emitter.UpdateOpCount, + triggerTargetBaseOffset + emitter.TriggerTargetOffset, + emitter.TriggerTargetCount + }; + + desc.MetaC = { + (float)emitter.RenderMode, + emitter.SpawnRatePerSecond, + emitter.GravityScale, + emitter.BurstIntervalSeconds + }; + + desc.MetaD = { + (float)emitter.BurstCount, + emitter.IsTriggerDriven ? 1.0f : 0.0f, + 0.0f, + 0.0f + }; + + return desc; + } + + STriggerTargetData ToTriggerTargetData(const SCompiledTriggerTarget& target) + { + return { + .TargetEmitterIndex = target.TargetEmitterIndex, + .BurstCount = target.BurstCount, + .DelaySeconds = target.DelaySeconds, + }; + } + + SParticleOpData ToOpData(const SGPUParticleOp& op, uint32_t parameterBaseOffset) + { + const auto ResolveParameterIndex = [parameterBaseOffset](const uint32_t parameterIndex) + { + return parameterIndex == UINT32_MAX + ? -1.0f + : (float)(parameterBaseOffset + parameterIndex); + }; + + SParticleOpData desc{}; + + desc.Header = { + (float)(uint32_t)op.Type, + (float)op.Target, + ResolveParameterIndex(op.Parameter0Index), + ResolveParameterIndex(op.Parameter1Index) + }; + + desc.Data0 = op.Data0; + desc.Data1 = op.Data1; + desc.Data2 = op.Data2; + + if (op.Type == EParticleOp::ApplyVortex) + { + // ApplyVortex keeps its optional tangential and radial parameter + // indices in Data2.z and Data2.w. Unlike Header.zw, these values + // were left relative to the compiled system, causing instances + // beyond parameter buffer offset zero to read another system's + // forces. + const auto ResolveEmbeddedParameterIndex = [parameterBaseOffset]( + const float parameterIndex + ) + { + const auto index = (int32_t)parameterIndex; + return index < 0 + ? -1.0f + : (float)(parameterBaseOffset + (uint32_t)index); + }; + + desc.Data2.z = ResolveEmbeddedParameterIndex(op.Data2.z); + desc.Data2.w = ResolveEmbeddedParameterIndex(op.Data2.w); + } + + return desc; + } + + SParameterData ToParameterData(const SGPUParameter& parameter) + { + SParameterData desc{}; + desc.Value = parameter.Value; + + return desc; + } + + glm::mat4 GetParticleRenderTransform( + const SCompiledEmitter& emitter, + const SystemInstanceRenderProxy& proxy + ) + { + return emitter.SimulationSpace == EParticleSimulationSpace::Local + ? proxy.GetWorldTransform() + : glm::mat4{ 1.0f }; + } + } + + Simulator::Simulator( + const GraphicsContext* context, + const ShaderLoader* shaderLoader, + const SResourcePoolLimits& limits + ) : m_Limits(limits), + m_ParticleStateLayouts(m_Limits.ParticleCapacity), + m_ResourcePool(m_Limits, m_ParticleStateLayouts), + m_GraphicsContext(context) + { + static_assert(sizeof(SGPUParticleState) == PARTICLE_STATE_CORE_V1_STRIDE); + EE_CORE_ASSERT(context, "Aether Simulator requires a graphics context.") + EE_CORE_ASSERT(shaderLoader, "Aether Simulator requires a shader loader.") + EE_CORE_INFO("Initializing Aether Simulator.") + + Init(shaderLoader); + CreateBuffers(); + BindShaderParameters(); + } + + void Simulator::BeginFrame(const Timestep& timestep) + { + ProcessCompletedRetirements(); + m_LastDeltaTimeSeconds = timestep.GetSeconds(); + m_ElapsedTimeSeconds += timestep.GetSeconds(); + } + + Ref Simulator::Simulate( + const FrameSubmission& submission, + const Ref& cmd + ) + { + EE_CORE_ASSERT(cmd, "Aether simulation requires a command buffer.") + + const auto serial = ++m_SubmissionSerial; + + m_LastMetrics = { + .SubmissionSerial = serial, + .DeltaTimeSeconds = m_LastDeltaTimeSeconds, + .ElapsedTimeSeconds = m_ElapsedTimeSeconds, + .RequestedSystemInstanceCount = submission.GetRenderProxies().size(), + .TriggerEventCapacityPerEmitter = m_Limits.TriggerEventCapacityPerEmitter, + }; + + const auto extent = m_GraphicsContext->GetRenderTarget()->GetExtent(); + const SParamsData params{ + .Time = { m_LastDeltaTimeSeconds, m_ElapsedTimeSeconds, 0.0f, 0.0f }, + .Viewport = { (float)extent.Width, (float)extent.Height, 0.0f, 0.0f }, + }; + m_ParamsBuffer->UpdateData(¶ms, sizeof(params)); + + auto instances = ResolveSubmittedInstances(submission); + + const auto batches = BuildSimulationBatches(instances); + m_LastMetrics.SimulationBatchCount = batches.size(); + + for (const auto& batch : batches) + SimulateBatch(cmd, batch); + + for (const auto& batch : batches) + PublishRenderBarrier(cmd, batch.ParticleStateLayout); + + return CreateRef( + BuildRenderResources(), + m_EmitterBuffer, + BuildRenderItems(instances), + serial, + m_ElapsedTimeSeconds + ); + } + + void Simulator::Retire(const SSystemInstanceKey& key) + { + const auto found = m_InstanceRecords.find(key); + if (found == m_InstanceRecords.end()) + return; + + QueueRetirement(found->second.Allocation); + m_InstanceRecords.erase(found); + m_AllocationFailures.erase(key); + m_UnsupportedParticleStateLayoutInstances.erase(key); + } + + void Simulator::Init(const ShaderLoader* shaderLoader) + { + m_SchedulerBeginShader = shaderLoader->LoadShader( + "./Shaders/Aether/", + std::array{ "ParticlesSchedulerBegin" }, + "ParticlesSchedulerBegin", + EShaderStage::Compute + ); + + m_SchedulerInitEmittersShader = shaderLoader->LoadShader( + "./Shaders/Aether/", + std::array{ "ParticlesSchedulerInitEmitters" }, + "ParticlesSchedulerInitEmitters", + EShaderStage::Compute + ); + + m_SchedulerScheduleEmittersShader = shaderLoader->LoadShader( + "./Shaders/Aether/", + std::array{ "ParticlesSchedulerScheduleEmitters" }, + "ParticlesSchedulerScheduleEmitters", + EShaderStage::Compute + ); + + m_SchedulerFinalizeShader = shaderLoader->LoadShader( + "./Shaders/Aether/", + std::array{ "ParticlesSchedulerFinalize" }, + "ParticlesSchedulerFinalize", + EShaderStage::Compute + ); + + SPipelineCreateInfo pipelineInfo{}; + pipelineInfo.Shader = m_SchedulerBeginShader; + m_SchedulerBeginPipeline = ComputePipeline::Create(m_GraphicsContext, pipelineInfo); + + pipelineInfo.Shader = m_SchedulerInitEmittersShader; + m_SchedulerInitEmittersPipeline = ComputePipeline::Create(m_GraphicsContext, pipelineInfo); + + pipelineInfo.Shader = m_SchedulerScheduleEmittersShader; + m_SchedulerScheduleEmittersPipeline = ComputePipeline::Create( + m_GraphicsContext, + pipelineInfo + ); + + pipelineInfo.Shader = m_SchedulerFinalizeShader; + m_SchedulerFinalizePipeline = ComputePipeline::Create(m_GraphicsContext, pipelineInfo); + + CreateCoreV1ParticleStateLayoutRuntime(shaderLoader); + } + + void Simulator::CreateCoreV1ParticleStateLayoutRuntime(const ShaderLoader* shaderLoader) + { + EE_CORE_ASSERT( + m_ParticleStateLayouts.Find(EParticleStateLayout::CoreV1), + "Aether requires a CoreV1 particle state layout descriptor." + ) + + EE_CORE_ASSERT( + !FindParticleStateLayoutRuntime(EParticleStateLayout::CoreV1), + "Aether cannot create the CoreV1 particle state runtime twice." + ) + + m_ParticleStateLayoutRuntimes.push_back({ + .Key = EParticleStateLayout::CoreV1, + }); + + auto& runtime = m_ParticleStateLayoutRuntimes.back(); + + runtime.SpawnShader = shaderLoader->LoadShader( + "./Shaders/Aether/", + std::array{ "ParticlesSpawn" }, + "ParticlesSpawn", + EShaderStage::Compute + ); + + runtime.UpdateShader = shaderLoader->LoadShader( + "./Shaders/Aether/", + std::array{ "ParticlesUpdate" }, + "ParticlesUpdate", + EShaderStage::Compute + ); + + SPipelineCreateInfo pipelineInfo{}; + pipelineInfo.Shader = runtime.SpawnShader; + runtime.SpawnPipeline = ComputePipeline::Create(m_GraphicsContext, pipelineInfo); + + pipelineInfo.Shader = runtime.UpdateShader; + runtime.UpdatePipeline = ComputePipeline::Create(m_GraphicsContext, pipelineInfo); + } + + void Simulator::CreateBuffers() + { + for (const auto& descriptor : m_ParticleStateLayouts.GetDescriptors()) + { + auto* runtime = FindParticleStateLayoutRuntime(descriptor.Key); + EE_CORE_ASSERT(runtime, "Particle state layout requires a simulation runtime.") + if (!runtime) continue; + + runtime->ParticleStateBuffer = StorageBuffer::Create( + m_GraphicsContext, + descriptor.ParticleStateStride * descriptor.ParticleCapacity + ); + } + + m_EmitterStateBuffer = StorageBuffer::Create( + m_GraphicsContext, + sizeof(SEmitterInstanceStateData) * m_Limits.EmitterCapacity + ); + + m_SpawnRequestBuffer = StorageBuffer::Create( + m_GraphicsContext, + sizeof(SSpawnRequestData) * m_Limits.EmitterCapacity + ); + + m_TriggerTargetBuffer = DynamicStorageBuffer::Create( + m_GraphicsContext, + sizeof(STriggerTargetData) * m_Limits.TriggerTargetCapacity + ); + + for (auto& buffer : m_TriggerEventBuffers) + { + buffer = StorageBuffer::Create( + m_GraphicsContext, + sizeof(STriggerEventData) * + m_Limits.EmitterCapacity * + m_Limits.TriggerEventCapacityPerEmitter + ); + buffer->Clear(); + } + + m_TriggerQueueStateBuffer = StorageBuffer::Create( + m_GraphicsContext, + sizeof(STriggerQueueStateData) * m_Limits.EmitterCapacity * 2 + ); + + m_SystemInstanceBuffer = DynamicStorageBuffer::Create( + m_GraphicsContext, + sizeof(SSystemInstanceData) * m_Limits.MaxSystemInstances + ); + + m_SystemSchedulerStateBuffer = StorageBuffer::Create( + m_GraphicsContext, + sizeof(SSystemSchedulerStateData) * m_Limits.MaxSystemInstances + ); + + m_EmitterBuffer = DynamicStorageBuffer::Create( + m_GraphicsContext, + sizeof(SEmitterData) * m_Limits.EmitterCapacity + ); + + m_OpBuffer = DynamicStorageBuffer::Create( + m_GraphicsContext, + sizeof(SParticleOpData) * m_Limits.OpCapacity + ); + + m_ParameterBuffer = DynamicStorageBuffer::Create( + m_GraphicsContext, + sizeof(SParameterData) * m_Limits.ParameterCapacity + ); + + m_ParamsBuffer = UniformBuffer::Create( + m_GraphicsContext, + sizeof(SParamsData) + ); + + m_EmitterStateBuffer->Clear(); + m_SpawnRequestBuffer->Clear(); + m_TriggerQueueStateBuffer->Clear(); + m_SystemSchedulerStateBuffer->Clear(); + } + + void Simulator::BindShaderParameters() + { + constexpr SSchedulePushConstants schedulePushConstants{}; + + m_SchedulerBeginShader->SetPushConstant( + "pc", + (void*)&schedulePushConstants, + sizeof(schedulePushConstants) + ); + + m_SchedulerBeginShader->BindStorageBuffer("instances", m_SystemInstanceBuffer); + m_SchedulerBeginShader->BindStorageBuffer("schedulerStates", m_SystemSchedulerStateBuffer); + + m_SchedulerInitEmittersShader->SetPushConstant( + "pc", + (void*)&schedulePushConstants, + sizeof(schedulePushConstants) + ); + + m_SchedulerInitEmittersShader->BindStorageBuffer( + "instances", + m_SystemInstanceBuffer + ); + m_SchedulerInitEmittersShader->BindStorageBuffer( + "emitterStates", + m_EmitterStateBuffer + ); + m_SchedulerInitEmittersShader->BindStorageBuffer( + "triggerQueueStates", + m_TriggerQueueStateBuffer + ); + m_SchedulerInitEmittersShader->BindStorageBuffer( + "schedulerStates", + m_SystemSchedulerStateBuffer + ); + + m_SchedulerScheduleEmittersShader->SetPushConstant( + "pc", + (void*)&schedulePushConstants, + sizeof(schedulePushConstants) + ); + + m_SchedulerScheduleEmittersShader->BindStorageBuffer( + "instances", + m_SystemInstanceBuffer + ); + m_SchedulerScheduleEmittersShader->BindStorageBuffer( + "emitters", + m_EmitterBuffer + ); + m_SchedulerScheduleEmittersShader->BindStorageBuffer( + "emitterStates", + m_EmitterStateBuffer + ); + m_SchedulerScheduleEmittersShader->BindStorageBuffer( + "spawnRequests", + m_SpawnRequestBuffer + ); + m_SchedulerScheduleEmittersShader->BindStorageBuffer( + "triggerTargets", + m_TriggerTargetBuffer + ); + m_SchedulerScheduleEmittersShader->BindStorageBuffer( + "triggerEventsA", + m_TriggerEventBuffers[0] + ); + m_SchedulerScheduleEmittersShader->BindStorageBuffer( + "triggerEventsB", + m_TriggerEventBuffers[1] + ); + m_SchedulerScheduleEmittersShader->BindStorageBuffer( + "triggerQueueStates", + m_TriggerQueueStateBuffer + ); + m_SchedulerScheduleEmittersShader->BindStorageBuffer( + "schedulerStates", + m_SystemSchedulerStateBuffer + ); + m_SchedulerScheduleEmittersShader->BindConstantBuffer( + "cbParams", + m_ParamsBuffer + ); + + m_SchedulerFinalizeShader->SetPushConstant( + "pc", + (void*)&schedulePushConstants, + sizeof(schedulePushConstants) + ); + + m_SchedulerFinalizeShader->BindStorageBuffer("instances", m_SystemInstanceBuffer); + m_SchedulerFinalizeShader->BindStorageBuffer("schedulerStates", m_SystemSchedulerStateBuffer); + + for (const auto& runtime : m_ParticleStateLayoutRuntimes) + BindParticleStateLayoutShaderParameters(runtime); + } + + void Simulator::BindParticleStateLayoutShaderParameters( + const SParticleStateLayoutRuntime& runtime + ) const + { + EE_CORE_ASSERT( + runtime.ParticleStateBuffer, + "Aether cannot bind and uninitialized particle state layout runtime." + ) + if (!runtime.ParticleStateBuffer) return; + + constexpr SSpawnPushConstants spawnPushConstants{}; + + runtime.SpawnShader->SetPushConstant( + "pc", + (void*)&spawnPushConstants, + sizeof(spawnPushConstants) + ); + + runtime.SpawnShader->BindStorageBuffer("particles", runtime.ParticleStateBuffer); + runtime.SpawnShader->BindStorageBuffer("instances", m_SystemInstanceBuffer); + runtime.SpawnShader->BindStorageBuffer("emitters", m_EmitterBuffer); + runtime.SpawnShader->BindStorageBuffer("spawnRequests", m_SpawnRequestBuffer); + runtime.SpawnShader->BindStorageBuffer("ops", m_OpBuffer); + runtime.SpawnShader->BindStorageBuffer("parameters", m_ParameterBuffer); + runtime.SpawnShader->BindConstantBuffer("cbParams", m_ParamsBuffer); + + constexpr SUpdatePushConstants updatePushConstants{}; + runtime.UpdateShader->SetPushConstant( + "pc", + (void*)&updatePushConstants, + sizeof(updatePushConstants) + ); + + runtime.UpdateShader->BindStorageBuffer("particles", runtime.ParticleStateBuffer); + runtime.UpdateShader->BindStorageBuffer("instances", m_SystemInstanceBuffer); + runtime.UpdateShader->BindStorageBuffer("emitters", m_EmitterBuffer); + runtime.UpdateShader->BindStorageBuffer("ops", m_OpBuffer); + runtime.UpdateShader->BindStorageBuffer("parameters", m_ParameterBuffer); + runtime.UpdateShader->BindConstantBuffer("cbParams", m_ParamsBuffer); + } + + Simulator::SInstanceRecord* Simulator::ResolveInstanceRecord( + const SystemInstanceRenderProxy& proxy + ) + { + const auto found = m_InstanceRecords.find(proxy.GetKey()); + + if (found != m_InstanceRecords.end() && + found->second.SystemInstanceRevision == proxy.GetRevision()) + { + return &found->second; + } + + const auto& system = proxy.GetCompiledSystem(); + + const auto allocation = m_ResourcePool.Allocate(system); + if (!allocation) + { + if (m_AllocationFailures.insert(proxy.GetKey()).second) + { + EE_CORE_ERROR( + "Aether GPU resource pool exhausted for system '{}'.", + system.SourceId + ) + } + return nullptr; + } + + ClearParticleAllocation(*allocation); + UploadCompiledSystem(proxy, *allocation); + + const SInstanceRecord replacement{ + .SystemInstanceKey = proxy.GetKey(), + .SystemInstanceRevision = proxy.GetRevision(), + .CompiledSystemId = system.SourceId, + .CompilationRevision = system.CompilationRevision, + .ParameterRevision = proxy.GetParameterRevision(), + .Allocation = *allocation, + }; + + if (found == m_InstanceRecords.end()) + { + const auto [it, inserted] = m_InstanceRecords.emplace(proxy.GetKey(), replacement); + + EE_CORE_ASSERT(inserted, "Aether system instance registry insertion failed.") + m_AllocationFailures.erase(proxy.GetKey()); + return &it->second; + } + + // The replacement is fully allocated and uploaded before retiring the + // previous record. If allocation fails, the old record remains intact. + QueueRetirement(found->second.Allocation); + found->second = replacement; + m_AllocationFailures.erase(proxy.GetKey()); + return &found->second; + } + + void Simulator::UpdateBuffers( + const SystemInstanceRenderProxy& proxy, + SInstanceRecord& record + ) + { + if (record.ParameterRevision != proxy.GetParameterRevision()) + { + UploadInstanceParameters(proxy, record.Allocation); + record.ParameterRevision = proxy.GetParameterRevision(); + } + + const auto& system = proxy.GetCompiledSystem(); + const auto& allocation = record.Allocation; + + const SSystemInstanceData data + { + .ParticleBaseOffset = allocation.Particles.Offset, + .EmitterBaseOffset = allocation.Emitters.Offset, + .OpBaseOffset = allocation.Ops.Offset, + .ParameterBaseOffset = allocation.Parameters.Offset, + .EmitterStateBaseOffset = allocation.EmitterStates.Offset, + .SpawnRequestBaseOffset = allocation.SpawnRequests.Offset, + .TriggerEventBaseOffset = allocation.TriggerEvents.Offset, + .TriggerQueueStateBaseOffset = allocation.TriggerQueueStates.Offset, + .ParticleCount = allocation.Particles.Count, + .EmitterCount = allocation.Emitters.Count, + .TriggerEventCapacityPerEmitter = m_Limits.TriggerEventCapacityPerEmitter, + .Generation = allocation.Generation, + .ParticleStateLayoutIndex = (uint32_t)system.ParticleStateLayout, + }; + + m_SystemInstanceBuffer->UpdateData( + &data, + sizeof(data), + allocation.InstanceIndex * sizeof(data) + ); + } + + void Simulator::UploadCompiledSystem( + const SystemInstanceRenderProxy& proxy, + const SSystemInstanceAllocation& allocation + ) const + { + const auto& system = proxy.GetCompiledSystem(); + + auto* emitters = (SEmitterData*)m_EmitterBuffer->Map(); + for (uint32_t i = 0; i < allocation.Emitters.Count; ++i) + { + emitters[allocation.Emitters.Offset + i] = ToEmitterData( + system.Emitters[i], + allocation.Ops.Offset, + allocation.TriggerTargets.Offset + ); + } + + auto* ops = (SParticleOpData*)m_OpBuffer->Map(); + for (uint32_t i = 0; i < allocation.Ops.Count; ++i) + { + ops[allocation.Ops.Offset + i] = ToOpData( + system.Ops[i], + allocation.Parameters.Offset + ); + } + + UploadInstanceParameters(proxy, allocation); + + auto* targets = (STriggerTargetData*)m_TriggerTargetBuffer->Map(); + for (uint32_t i = 0; i < allocation.TriggerTargets.Count; ++i) + targets[allocation.TriggerTargets.Offset + i] = + ToTriggerTargetData(system.TriggerTargets[i]); + } + + void Simulator::UploadInstanceParameters( + const SystemInstanceRenderProxy& proxy, + const SSystemInstanceAllocation& allocation + ) const + { + auto* parameters = (SParameterData*)m_ParameterBuffer->Map(); + for (uint32_t i = 0; i < allocation.Parameters.Count; ++i) + parameters[allocation.Parameters.Offset + i].Value = + proxy.GetParameterValue(i); + } + + Simulator::SParticleStateLayoutRuntime* Simulator::FindParticleStateLayoutRuntime( + const EParticleStateLayout layout + ) + { + for (auto& runtime : m_ParticleStateLayoutRuntimes) + if (runtime.Key == layout) return &runtime; + return nullptr; + } + + const Simulator::SParticleStateLayoutRuntime* Simulator::FindParticleStateLayoutRuntime( + const EParticleStateLayout layout + ) const + { + for (const auto& runtime : m_ParticleStateLayoutRuntimes) + if (runtime.Key == layout) return &runtime; + return nullptr; + } + + bool Simulator::IsParticleStateLayoutSupported(const EParticleStateLayout layout) const + { + const auto* runtime = FindParticleStateLayoutRuntime(layout); + return runtime && runtime->IsReady(); + } + + std::vector Simulator::ResolveSubmittedInstances( + const FrameSubmission& submission + ) + { + std::vector instances; + instances.reserve(submission.GetRenderProxies().size()); + + for (const auto& proxy : submission.GetRenderProxies()) + { + const auto& system = proxy->GetCompiledSystem(); + m_LastMetrics.RequestedEmitterCount += system.Emitters.size(); + m_LastMetrics.RequestedParticleCapacity += system.TotalMaxParticles; + + if (!IsParticleStateLayoutSupported(system.ParticleStateLayout)) + { + if (m_UnsupportedParticleStateLayoutInstances.insert(proxy->GetKey()).second) + { + EE_CORE_ERROR( + "Aether particle state layout '{}' is unsupported for system '{}'.", + (uint32_t)system.ParticleStateLayout, + system.SourceId + ) + } + continue; + } + + m_UnsupportedParticleStateLayoutInstances.erase(proxy->GetKey()); + + auto* record = ResolveInstanceRecord(*proxy); + if (!record) continue; + + UpdateBuffers(*proxy, *record); + + ++m_LastMetrics.SubmittedSystemInstanceCount; + m_LastMetrics.SubmittedEmitterCount += record->Allocation.Emitters.Count; + m_LastMetrics.SubmittedParticleCapacity += record->Allocation.Particles.Count; + + instances.push_back({ + .Proxy = proxy, + .Allocation = record->Allocation, + .ParticleStateLayout = system.ParticleStateLayout, + }); + } + + return instances; + } + + std::vector Simulator::BuildSimulationBatches( + const std::vector& instances + ) + { + std::vector batches; + + for (const auto& instance : instances) + { + auto found = std::ranges::find_if(batches, [&instance](const auto& batch) + { + return batch.ParticleStateLayout == instance.ParticleStateLayout; + }); + + if (found == batches.end()) + { + batches.push_back({ + .ParticleStateLayout = instance.ParticleStateLayout, + }); + found = std::prev(batches.end()); + } + + found->Instances.push_back(&instance); + } + + return batches; + } + + std::vector Simulator::BuildRenderResources() const + { + std::vector resources; + resources.reserve(m_ParticleStateLayoutRuntimes.size()); + + for (const auto& runtime : m_ParticleStateLayoutRuntimes) + { + resources.push_back({ + .Layout = runtime.Key, + .ParticleStateBuffer = runtime.ParticleStateBuffer, + }); + } + + return resources; + } + + std::vector Simulator::BuildRenderItems( + const std::vector& instances + ) + { + std::vector items; + + for (const auto& instance : instances) + { + const auto& emitters = instance.Proxy->GetCompiledSystem().Emitters; + + for (uint32_t emitterIndex = 0; emitterIndex < emitters.size(); ++emitterIndex) + { + const auto& emitter = emitters[emitterIndex]; + if (emitter.MaxParticles == 0) continue; + + items.push_back({ + .Allocation = instance.Allocation, + .ParticleStateLayout = instance.ParticleStateLayout, + .RenderMode = emitter.RenderMode, + .Material = emitter.Material, + .WorldTransform = GetParticleRenderTransform(emitter, *instance.Proxy), + .EmitterIndex = emitterIndex, + .LocalParticleOffset = emitter.LocalParticleOffset, + .ParticleCount = emitter.MaxParticles, + }); + } + } + + return items; + } + + void Simulator::SimulateBatch( + const Ref& cmd, + const SSimulationBatch& batch + ) + { + const auto* runtime = FindParticleStateLayoutRuntime(batch.ParticleStateLayout); + EE_CORE_ASSERT(runtime, "Aether particle state layout runtime is missing.") + if (!runtime) return; + + const auto& particleBuffer = runtime->ParticleStateBuffer; + + // Scheduling: begin + + m_SchedulerBeginPipeline->Bind(cmd); + + for (const auto* instance : batch.Instances) + { + const SSchedulePushConstants pc{ + .InstanceIndex = instance->Allocation.InstanceIndex, + }; + + m_SchedulerBeginShader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); + cmd->Dispatch(1); + } + + BarrierSchedulingBuffers(cmd); + + // Scheduling: init emitters + + m_SchedulerInitEmittersPipeline->Bind(cmd); + + for (const auto* instance : batch.Instances) + { + const SSchedulePushConstants pc{ + .InstanceIndex = instance->Allocation.InstanceIndex, + }; + + m_SchedulerInitEmittersShader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); + cmd->Dispatch((instance->Allocation.Emitters.Count + COMPUTE_GROUP_SIZE - 1) / COMPUTE_GROUP_SIZE); + } + + BarrierSchedulingBuffers(cmd); + + // Scheduling: generate spawn requests + + m_SchedulerScheduleEmittersPipeline->Bind(cmd); + + for (const auto* instance : batch.Instances) + { + const SSchedulePushConstants pc{ + .InstanceIndex = instance->Allocation.InstanceIndex, + }; + + m_SchedulerScheduleEmittersShader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); + cmd->Dispatch((instance->Allocation.Emitters.Count + COMPUTE_GROUP_SIZE - 1) / COMPUTE_GROUP_SIZE); + } + + BarrierSchedulingBuffers(cmd); + + // Scheduling: release trigger events + + m_SchedulerFinalizePipeline->Bind(cmd); + + for (const auto* instance : batch.Instances) + { + const SSchedulePushConstants pc{ + .InstanceIndex = instance->Allocation.InstanceIndex, + }; + + m_SchedulerFinalizeShader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); + cmd->Dispatch(1); + } + + BarrierSchedulingBuffers(cmd); + + // Spawning + + particleBuffer->Barrier( + cmd, + EPipelineStage::ComputeShader, + EPipelineAccess::ShaderRead | EPipelineAccess::ShaderWrite + ); + + runtime->SpawnPipeline->Bind(cmd); + + for (const auto* instance : batch.Instances) + { + const auto& system = instance->Proxy->GetCompiledSystem(); + const auto emitterCount = instance->Allocation.Emitters.Count; + + m_LastMetrics.ScheduledEmitterCount += emitterCount; + + for (uint32_t i = 0; i < emitterCount; ++i) + { + const auto maxParticles = system.Emitters[i].MaxParticles; + if (maxParticles == 0) continue; + + ++m_LastMetrics.SpawnDispatchCount; + + const SSpawnPushConstants pc + { + .InstanceIndex = instance->Allocation.InstanceIndex, + .EmitterIndex = i, + }; + + runtime->SpawnShader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); + cmd->Dispatch((maxParticles + COMPUTE_GROUP_SIZE - 1) / COMPUTE_GROUP_SIZE); + } + } + + // Updating + + particleBuffer->Barrier( + cmd, + EPipelineStage::ComputeShader, + EPipelineAccess::ShaderRead | EPipelineAccess::ShaderWrite + ); + + runtime->UpdatePipeline->Bind(cmd); + + for (const auto* instance : batch.Instances) + { + const SUpdatePushConstants pc + { + .InstanceIndex = instance->Allocation.InstanceIndex, + }; + + runtime->UpdateShader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); + cmd->Dispatch((instance->Allocation.Particles.Count + COMPUTE_GROUP_SIZE - 1) / COMPUTE_GROUP_SIZE); + } + } + + void Simulator::PublishRenderBarrier( + const Ref& cmd, + const EParticleStateLayout layout + ) const + { + const auto* runtime = FindParticleStateLayoutRuntime(layout); + EE_CORE_ASSERT(runtime, "Aether particle state layout runtime is missing.") + if (!runtime) return; + + runtime->ParticleStateBuffer->Barrier( + cmd, + EPipelineStage::VertexShader | EPipelineStage::VertexInput, + EPipelineAccess::ShaderRead | EPipelineAccess::VertexAttributeRead + ); + } + + void Simulator::QueueRetirement(SSystemInstanceAllocation allocation) + { + const auto frameIndex = m_GraphicsContext->GetFrameIndex(); + m_DeferredRetirements[frameIndex].push_back(std::move(allocation)); + } + + void Simulator::ProcessCompletedRetirements() + { + // Update() runs after GraphicsContext::Prepare() waited for this frame slot's fence. + // All GPU work that used these allocations has therefore completed. + const auto frameIndex = m_GraphicsContext->GetFrameIndex(); + auto& retirements = m_DeferredRetirements[frameIndex]; + + for (const auto& allocation : retirements) + m_ResourcePool.Release(allocation); + + retirements.clear(); + } + + void Simulator::BarrierSchedulingBuffers(const Ref& cmd) const + { + constexpr auto stage = EPipelineStage::ComputeShader; + constexpr auto access = EPipelineAccess::ShaderRead | EPipelineAccess::ShaderWrite; + + m_EmitterStateBuffer->Barrier(cmd, stage, access); + m_SpawnRequestBuffer->Barrier(cmd, stage, access); + m_TriggerEventBuffers[0]->Barrier(cmd, stage, access); + m_TriggerEventBuffers[1]->Barrier(cmd, stage, access); + m_TriggerQueueStateBuffer->Barrier(cmd, stage, access); + m_SystemSchedulerStateBuffer->Barrier(cmd, stage, access); + } + + void Simulator::ClearParticleAllocation(const SSystemInstanceAllocation& allocation) + { + const auto* layout = m_ParticleStateLayouts.Find(allocation.ParticleStateLayout); + const auto* runtime = FindParticleStateLayoutRuntime(allocation.ParticleStateLayout); + if (!layout || !runtime) return; + + runtime->ParticleStateBuffer->Fill( + 0, + int32_t(allocation.Particles.Offset * layout->ParticleStateStride), + allocation.Particles.Count * layout->ParticleStateStride + ); + } +} diff --git a/Elixir/Source/Engine/Aether/Simulation/Simulator.h b/Elixir/Source/Engine/Aether/Simulation/Simulator.h new file mode 100644 index 00000000..33e881d1 --- /dev/null +++ b/Elixir/Source/Engine/Aether/Simulation/Simulator.h @@ -0,0 +1,304 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace Elixir +{ + class CommandBuffer; + class GraphicsContext; + class ShaderLoader; + class Timestep; +} + +namespace Elixir::Aether::Simulation +{ + using namespace Core; + + /** + * @brief Reports the simulation work for the latest frame submission. + * + * Requested values include all instances in the frame submission. Submitted + * values include only instances that the simulator accepted. An instance may + * be rejected when its particle layout is unsupported or its resources cannot + * be allocated. + * + * Simulator replaces these values each time Simulate() runs. + */ + struct SSimulationMetrics + { + uint64_t SubmissionSerial = 0u; + float DeltaTimeSeconds = 0.0f; + float ElapsedTimeSeconds = 0.0f; + + size_t RequestedSystemInstanceCount = 0; + size_t SubmittedSystemInstanceCount = 0; + + size_t RequestedEmitterCount = 0u; + size_t SubmittedEmitterCount = 0u; + + uint32_t RequestedParticleCapacity = 0u; + uint32_t SubmittedParticleCapacity = 0u; + + uint32_t ScheduledEmitterCount = 0; + uint32_t SpawnDispatchCount = 0; + size_t SimulationBatchCount = 0; + + uint32_t TriggerEventCapacityPerEmitter = 0; + }; + + /** + * @brief Simulates Aether particle systems on the GPU. + * + * Simulator owns particle allocations, simulation buffers, compute pipelines, + * and deferred resource retirement. It consumes immutable frame submissions and + * produces immutable render frames. + * + * Simulator records simulation commands but does not submit command buffers or + * render particle geometry. + * + * @thread_safety Use this class only from the render-frame thread. + */ + class ELIXIR_API Simulator final + { + public: + /** Number of threads used by Aether compute shader dispatches. */ + static constexpr uint32_t COMPUTE_GROUP_SIZE = 256; + + /** + * @brief Creates the GPU resources required for particle simulation. + * + * @param context Graphics context that owns the GPU resources. + * @param shaderLoader Loader used to create the simulation shaders. + * @param limits Maximum capacity of each shared simulation resource. + * + * @pre context is not null and outlives the simulator. + * @pre shaderLoader is not null. + */ + Simulator( + const GraphicsContext* context, + const ShaderLoader* shaderLoader, + const SResourcePoolLimits& limits = {} + ); + + /** + * @brief Prepares the simulator for a new frame. + * + * This method releases allocations whose GPU work has completed. It also + * updates the current and total simulation time. + * + * @param timestep Time elapsed since the previous frame. + * + * @note Call this method once per frame, after the graphics context prepares + * the current frame slot and before calling Simulate(). + */ + void BeginFrame(const Timestep& timestep); + + /** + * @brief Records particle simulation work for a frame submission. + * + * The method resolves instance allocations, uploads changed system data, + * records compute work, and adds the barriers required for rendering. + * + * @param submission Immutable system instances requested for the frame. + * @param cmd Command buffer that receives the simulation commands. + * @return Immutable render data produced by the simulation. + * + * @pre cmd is not null and is recording commands. + * @pre BeginFrame() was called for the current frame. + */ + Ref Simulate( + const Rendering::FrameSubmission& submission, + const Ref& cmd + ); + + /** + * @brief Stops tracking a system instance and schedules its allocation for release. + * + * The allocation remains valid until the GPU finishes the frame that last + * used it. An unknown key has no effect. + * + * @param key Internal key of the system instance to retire. + */ + void Retire(const SSystemInstanceKey& key); + + /** + * @brief Returns metrics from the latest simulation. + * @return Metrics produced by the latest call to Simulate(). + * @note A later call to Simulate() replaces the reported values. + */ + const SSimulationMetrics& GetLastMetrics() const + { + return m_LastMetrics; + } + + private: + // Owns GPU resources and compute pipelines for one particle-state layout. + struct SParticleStateLayoutRuntime + { + EParticleStateLayout Key = EParticleStateLayout::CoreV1; + Ref ParticleStateBuffer; + + Ref SpawnShader; + Ref SpawnPipeline; + Ref UpdateShader; + Ref UpdatePipeline; + + bool IsReady() const + { + return ParticleStateBuffer && + SpawnShader && SpawnPipeline && + UpdateShader && UpdatePipeline; + } + }; + + // Tracks the GPU allocation and uploaded revisions for one live system instance. + struct SInstanceRecord + { + SSystemInstanceKey SystemInstanceKey; + uint32_t SystemInstanceRevision = 0; + UUID CompiledSystemId; + uint32_t CompilationRevision = 0; + uint32_t ParameterRevision = 0; + SSystemInstanceAllocation Allocation; + }; + + // Pairs one immutable render proxy with its simulator-owned GPU allocation. + struct SSubmittedSystemInstance + { + Ref Proxy; + SSystemInstanceAllocation Allocation; + EParticleStateLayout ParticleStateLayout = EParticleStateLayout::CoreV1; + }; + + // Groups submitted instances that use the same particle-state layout. + struct SSimulationBatch + { + EParticleStateLayout ParticleStateLayout = EParticleStateLayout::CoreV1; + std::vector Instances; + }; + + // Initializes Aether shaders, pipelines, layouts, and shared GPU buffers. + void Init(const ShaderLoader* shaderLoader); + + // Create the GPU runtime for the built-in CoreV1 particle-state layout. + void CreateCoreV1ParticleStateLayoutRuntime(const ShaderLoader* shaderLoader); + + // Creates shared GPU buffers sized from the configured resource-pool limits. + void CreateBuffers(); + + // Binds shared Aether buffers to scheduler and simulation shaders. + void BindShaderParameters(); + + // Binds buffers and constants specific to one particle-state layout. + void BindParticleStateLayoutShaderParameters(const SParticleStateLayoutRuntime& runtime) const; + + // Finds or creates the simulation record required by an immutable instance proxy. + SInstanceRecord* ResolveInstanceRecord(const Rendering::SystemInstanceRenderProxy& proxy); + + // Updates GPU tables when the proxy revisions differ from the instance record. + void UpdateBuffers(const Rendering::SystemInstanceRenderProxy& proxy, SInstanceRecord& record); + + // Uploads compiled emitter, operation, trigger, and system data for an allocation. + void UploadCompiledSystem( + const Rendering::SystemInstanceRenderProxy& proxy, + const SSystemInstanceAllocation& allocation + ) const; + + // Uploads resolved instance parameter values for an allocation. + void UploadInstanceParameters( + const Rendering::SystemInstanceRenderProxy& proxy, + const SSystemInstanceAllocation& allocation + ) const; + + // Finds the mutable GPU runtime for a registered particle-state layout. + SParticleStateLayoutRuntime* FindParticleStateLayoutRuntime(EParticleStateLayout layout); + + // Finds the read-only GPU runtime for a registered particle-state layout. + const SParticleStateLayoutRuntime* FindParticleStateLayoutRuntime(EParticleStateLayout layout) const; + + // Returns whether the simulator has a ready GPU runtime for the layout. + bool IsParticleStateLayoutSupported(EParticleStateLayout layout) const; + + // Resolves allocations and removes instances that cannot be simulated. + std::vector ResolveSubmittedInstances( + const Rendering::FrameSubmission& submission + ); + + // Groups submitted instances into simulation batches by particle-state layout. + static std::vector BuildSimulationBatches( + const std::vector& instances + ); + + // Collects the particle-state buffers required for rendering. + std::vector BuildRenderResources() const; + + // Creates one render item for each renderable emitter. + static std::vector BuildRenderItems( + const std::vector& instances + ); + + // Dispatch GPU simulation passes for all instances in one layout batch. + void SimulateBatch(const Ref& cmd, const SSimulationBatch& batch); + + // Makes particle writes visible to the graphics pipeline. + void PublishRenderBarrier(const Ref& cmd, EParticleStateLayout layout) const; + + // Defers an allocation release until its frame slot is safe to recycle. + void QueueRetirement(SSystemInstanceAllocation allocation); + + // Returns allocations whose associated GPU work has completed to ResourcePool. + void ProcessCompletedRetirements(); + + // Makes scheduler writes visible to later compute passes. + void BarrierSchedulingBuffers(const Ref& cmd) const; + + // Clears persistent particle state before a released allocation is reused. + void ClearParticleAllocation(const SSystemInstanceAllocation& allocation); + + SResourcePoolLimits m_Limits; + ParticleStateLayoutRegistry m_ParticleStateLayouts; + std::vector m_ParticleStateLayoutRuntimes; + std::unordered_set m_AllocationFailures; + std::unordered_set m_UnsupportedParticleStateLayoutInstances; + ResourcePool m_ResourcePool; + std::unordered_map m_InstanceRecords; + std::array< + std::vector, + GraphicsContext::FRAMES + > m_DeferredRetirements; + + Ref m_EmitterStateBuffer; + Ref m_SpawnRequestBuffer; + Ref m_TriggerTargetBuffer; + std::array, 2> m_TriggerEventBuffers; + Ref m_TriggerQueueStateBuffer; + Ref m_SystemSchedulerStateBuffer; + + Ref m_SystemInstanceBuffer; + Ref m_EmitterBuffer; + Ref m_OpBuffer; + Ref m_ParameterBuffer; + Ref m_ParamsBuffer; + + Ref m_SchedulerBeginShader; + Ref m_SchedulerBeginPipeline; + Ref m_SchedulerInitEmittersShader; + Ref m_SchedulerInitEmittersPipeline; + Ref m_SchedulerScheduleEmittersShader; + Ref m_SchedulerScheduleEmittersPipeline; + Ref m_SchedulerFinalizeShader; + Ref m_SchedulerFinalizePipeline; + + float m_LastDeltaTimeSeconds = 0.0f; + float m_ElapsedTimeSeconds = 0.0f; + + uint64_t m_SubmissionSerial = 0; + SSimulationMetrics m_LastMetrics{}; + + const GraphicsContext* m_GraphicsContext = nullptr; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Aether/System.cpp b/Elixir/Source/Engine/Aether/System.cpp index 4d94972e..46337f30 100644 --- a/Elixir/Source/Engine/Aether/System.cpp +++ b/Elixir/Source/Engine/Aether/System.cpp @@ -1,10 +1,17 @@ #include "epch.h" #include "System.h" +#include + namespace Elixir::Aether { System::System(const std::string& name) : m_Name(name) {} + Ref System::CreateInstance() + { + return Ref(new SystemInstance(shared_from_this())); + } + Emitter& System::AddEmitter( const std::string& name, uint32_t maxParticles, @@ -15,14 +22,52 @@ namespace Elixir::Aether return *m_Emitters.back(); } + Emitter* System::FindEmitter(const std::string_view name) const + { + for (const auto& emitter : m_Emitters) + { + if (emitter->GetName() == name) + return emitter.get(); + } + + return nullptr; + } + + std::vector System::BuildExposedParameters() const + { + auto parameters = m_Parameters.Compile(); + + for (const auto& emitter : m_Emitters) + { + const auto prefix = emitter->GetName() + "."; + const auto params = emitter->GetParameters().Compile(prefix); + parameters.insert(parameters.end(), params.begin(), params.end()); + } + + return parameters; + } + + std::optional System::GetParameterDefault(std::string_view name) const + { + const auto parameters = BuildExposedParameters(); + const auto found = std::ranges::find_if(parameters, [&name](const auto& param) + { + return param.Name == name; + }); + + if (found == parameters.end()) + return std::nullopt; + + return found->Value; + } + SCompiledSystem System::Compile() const { SCompiledSystem system; system.SourceId = m_UUID; system.CompilationRevision = ++m_CompilationRevision; - system.Name = m_Name; - system.Parameters = m_Parameters.Compile(); + system.Parameters = BuildExposedParameters(); const auto systemCurves = m_Curves.Compile(); system.Curves.insert(system.Curves.end(), systemCurves.begin(), systemCurves.end()); @@ -34,9 +79,6 @@ namespace Elixir::Aether { const auto prefix = emitter->GetName() + "."; - const auto emitterParams = emitter->GetParameters().Compile(prefix); - system.Parameters.insert(system.Parameters.end(), emitterParams.begin(), emitterParams.end()); - const auto emitterCurves = emitter->GetCurves().Compile(prefix); system.Curves.insert(system.Curves.end(), emitterCurves.begin(), emitterCurves.end()); @@ -80,7 +122,11 @@ namespace Elixir::Aether for (const auto& emitter : m_Emitters) { - auto compiled = emitter->Compile(m_Parameters, system.Parameters, system.Ops); + auto compiled = emitter->Compile( + m_Parameters, + system.Parameters, + system.Ops + ); compiled.LocalParticleOffset = localParticleOffset; localParticleOffset += compiled.MaxParticles; @@ -98,14 +144,14 @@ namespace Elixir::Aether const auto& name = emitter->GetTriggerEmitterName(); if (name.empty()) continue; - auto found = std::ranges::find_if(system.Emitters, [&name](const SCompiledEmitter& e) + const auto found = std::ranges::find_if(m_Emitters, [&name](const auto& e) { - return e.Name == name; + return e->GetName() == name; }); - if (found != system.Emitters.end()) + if (found != m_Emitters.end()) { - const auto sourceIndex = (uint32_t)std::distance(system.Emitters.begin(), found); + const auto sourceIndex = (uint32_t)std::distance(m_Emitters.begin(), found); auto& target = system.Emitters[targetIndex]; target.TriggerSourceEmitterIndex = (int32_t)sourceIndex; @@ -119,7 +165,11 @@ namespace Elixir::Aether } else { - EE_CORE_ERROR("Trigger source emitter '{}' not found for emitter '{}'.", name, emitter->GetName()); + EE_CORE_ERROR( + "Trigger source emitter '{}' not found for emitter '{}'.", + name, + emitter->GetName() + ) } } diff --git a/Elixir/Source/Engine/Aether/System.h b/Elixir/Source/Engine/Aether/System.h index 19da8e8e..d74af16e 100644 --- a/Elixir/Source/Engine/Aether/System.h +++ b/Elixir/Source/Engine/Aether/System.h @@ -1,31 +1,64 @@ #pragma once #include -#include -#include -#include +#include +#include +#include +#include + +namespace Elixir +{ + namespace Materials::Rendering { class Resolver; } + namespace Aether::Runtime { class InstanceRegistry; } +} namespace Elixir::Aether { - struct SCompiledTriggerTarget - { - uint32_t TargetEmitterIndex = 0; - uint32_t BurstCount = 0; - float DelaySeconds = 0.0f; - }; + using namespace Core; + using namespace Modules; + + class SystemInstance; + /** + * @brief Maps an exposed runtime parameter to the compiled parameter table. + * + * SystemInstance uses this mapping to validate named parameter overrides before + * publishing an immutable render proxy. + */ struct SExposedParameter { std::string Name; uint32_t ParameterIndex = 0; }; + /** + * @brief Describes one emitter activated by a trigger event. + * + * SCompiledSystem stores trigger targets in a flat table. Each source emitter + * references a contiguous range of entries in that table. + */ + struct SCompiledTriggerTarget + { + uint32_t TargetEmitterIndex = 0; + uint32_t BurstCount = 0; + float DelaySeconds = 0.0f; + }; + + /** + * @brief Store immutable GPU-ready data for one compiled Aether system. + * + * System::Compile() creates this structure from mutable effect authoring data. + * A SystemInstance selects one compiled system, and the particle renderer uses + * its data to allocate GPU ranges, simulate particles, and build render items. + * + * All emitter and trigger ranges refer to tables owned by this structure. + * The structure is treated as immutable after compilation. + */ struct SCompiledSystem { UUID SourceId; uint32_t CompilationRevision = 0; - std::string Name; EParticleStateLayout ParticleStateLayout = EParticleStateLayout::CoreV1; std::vector Emitters; @@ -38,14 +71,33 @@ namespace Elixir::Aether std::vector Curves; std::vector ColorCurves; - std::vector SpriteTextures; - uint32_t TotalMaxParticles = 0; }; - class ELIXIR_API System final + /** + * @brief Defines an authored Aether particle effect. + * + * System owns the emitters, parameters, and curves that describe one effect. + * It is mutable authoring data. Compile() converts this data into an immutable + * SCompiledSystem for runtime use. + * + * A system does not simulate or render particles. Create a SystemInstance to + * use compiled system data at runtime. + * + * @note A system is movable but not copyable. Its UUID identifies the authored + * source across compiled revisions. + */ + class ELIXIR_API System final : public std::enable_shared_from_this { + friend class Runtime::InstanceRegistry; + friend class SystemInstance; + public: + /** + * @brief Creates an empty particle effect definition. + * + * @param name Display name for the effect. + */ explicit System(const std::string& name); System(System&&) = default; @@ -54,15 +106,91 @@ namespace Elixir::Aether System(const System&) = delete; System& operator=(const System&) = delete; + /** + * @brief Creates an unregistered runtime instance of this system. + * + * The instance retains this authored system until Manager accepts its + * first submission. Manager then compiles the system and releases the + * authored representation from the instance. + * + * @return A new unregistered system instance. + * + * @pre This system is owned by Ref. + */ + Ref CreateInstance(); + + /** + * @brief Adds an emitter to the effect. + * + * The system owns the returned emitter. + * + * @param name Display name for the emitter. + * @param maxParticles Maximum number of particles owned by the emitter. + * @param spawnRate Default particle spawn rate in particles per second. + * @return The newly created emitter. + */ Emitter& AddEmitter(const std::string& name, uint32_t maxParticles, float spawnRate); - SCompiledSystem Compile() const; - + /** + * @brief Finds an emitter by name. + * + * @param name Name of the emitter to find. + * @return The matching emitter, or null when no emitter has this name. + */ + Emitter* FindEmitter(std::string_view name) const; + + /** + * @brief Returns the UUID of this effect system. + * @return The system UUID. + */ + const UUID& GetId() const { return m_UUID; } + + /** + * @brief Returns the display name of this effect. + * @return The authored effect name. + */ + const std::string& GetName() const { return m_Name; } + + /** + * @brief Returns the system's emitters. + * @return Read-only list of emitters owned by this system. + */ + const std::vector>& GetEmitters() const { return m_Emitters; } + + /** + * @brief Returns the system-level parameter store. + * @return Mutable parameters shared by emitters in this system. + */ ParameterStore& GetParameters() { return m_Parameters; } + + /** + * @brief Returns the system-level parameter store. + * @return Read-only parameters shared by emitters in this system. + */ + const ParameterStore& GetParameters() const { return m_Parameters; } + + /** + * @brief Returns the system-level scalar curve store. + * @return Mutable scalar curves for this system. + */ CurveStore& GetCurves() { return m_Curves; } + + /** + * @brief Returns the system-level color curve store. + * @return Mutable color curves for this system. + */ ColorCurveStore& GetColorCurves() { return m_ColorCurves; } private: + // Builds the authored parameters that can be overridden by instances. + std::vector BuildExposedParameters() const; + + // Returns an authored parameter default before an instance is compiled. + std::optional GetParameterDefault(std::string_view name) const; + + // Compiles the authored system into immutable runtime data. + SCompiledSystem Compile() const; + UUID m_UUID; mutable uint32_t m_CompilationRevision = 0; diff --git a/Elixir/Source/Engine/Aether/SystemInstance.cpp b/Elixir/Source/Engine/Aether/SystemInstance.cpp index 3b21773f..7ef22c60 100644 --- a/Elixir/Source/Engine/Aether/SystemInstance.cpp +++ b/Elixir/Source/Engine/Aether/SystemInstance.cpp @@ -1,105 +1,299 @@ #include "epch.h" #include "SystemInstance.h" +#include + namespace Elixir::Aether { - SystemInstance::SystemInstance(Ref compiledSystem) - : m_CompiledSystem(std::move(compiledSystem)) + /* SystemInstanceSnapshot */ + + SystemInstanceSnapshot::SystemInstanceSnapshot( + SSystemInstanceKey key, + const uint32_t revision, + const uint32_t parameterRevision, + Ref system, + const glm::mat4& worldTransform, + Ref parameters + ) : m_Key(std::move(key)), + m_Revision(revision), + m_ParameterRevision(parameterRevision), + m_CompiledSystem(std::move(system)), + m_WorldTransform(worldTransform), + m_Parameters(std::move(parameters)), + m_RenderProxy(new Rendering::SystemInstanceRenderProxy( + m_Key, + m_Revision, + m_ParameterRevision, + m_CompiledSystem, + m_WorldTransform, + m_Parameters + )) {} + + /* SystemInstance */ + + SystemInstance::SystemInstance(Ref system) + : m_SourceSystemId(system->GetId()), + m_SourceSystem(std::move(system)) { - EE_CORE_ASSERT(m_CompiledSystem, "SystemInstance requires a compiled system.") + EE_CORE_ASSERT(m_SourceSystem, "SystemInstance requires an authored system.") } - void SystemInstance::SetCompiledSystem(Ref compiledSystem) + bool SystemInstance::SetParameterOverride( + const std::string& name, + const glm::vec4& value + ) { - EE_CORE_ASSERT(compiledSystem, "SystemInstance requires a compiled system.") + const std::scoped_lock lock(m_SnapshotMutex); + + const bool isExposed = m_CompiledSystem + ? FindExposedParameter(*m_CompiledSystem, name) != nullptr + : m_SourceSystem + && m_SourceSystem->GetParameterDefault(name).has_value(); + + if (!isExposed) return false; + + m_ParameterOverrides.insert_or_assign(name, value); + ++m_ParameterRevision; + + if (m_CompiledSystem) + PublishSnapshot(); - if (m_CompiledSystem == compiledSystem) + return true; + } + + bool SystemInstance::ClearParameterOverride(const std::string& name) + { + const std::scoped_lock lock(m_SnapshotMutex); + + if (m_ParameterOverrides.erase(name) == 0) + return false; + + ++m_ParameterRevision; + + if (m_CompiledSystem) + PublishSnapshot(); + + return true; + } + + void SystemInstance::ClearParameterOverrides() + { + const std::scoped_lock lock(m_SnapshotMutex); + + if (m_ParameterOverrides.empty()) return; - m_CompiledSystem = std::move(compiledSystem); + m_ParameterOverrides.clear(); + ++m_ParameterRevision; - bool removedOverrides = false; + if (m_CompiledSystem) + PublishSnapshot(); + } - for (auto it = m_ParameterOverrides.begin(); it != m_ParameterOverrides.end();) + std::optional SystemInstance::GetParameterValue(const std::string& name) const + { + const std::scoped_lock lock(m_SnapshotMutex); + + if (!m_CompiledSystem) { - if (IsExposedParameter(it->first)) - { - ++it; - continue; - } - it = m_ParameterOverrides.erase(it); - removedOverrides = true; + if (!m_SourceSystem) + return std::nullopt; + + const auto defaultValue = m_SourceSystem->GetParameterDefault(name); + if (!defaultValue) + return std::nullopt; + + const auto override = m_ParameterOverrides.find(name); + return override != m_ParameterOverrides.end() + ? override->second + : *defaultValue; } - ++m_Revision; + const auto* exposedParameter = FindExposedParameter(*m_CompiledSystem, name); + if (!exposedParameter) return std::nullopt; - if (removedOverrides) - ++m_ParameterRevision; + EE_CORE_ASSERT( + exposedParameter->ParameterIndex < m_CompiledSystem->Parameters.size(), + "Aether exposed parameter index is outside the compiled parameter table." + ) + + if (exposedParameter->ParameterIndex >= m_CompiledSystem->Parameters.size()) + return std::nullopt; + + return ResolveParameterValue( + m_CompiledSystem->Parameters[exposedParameter->ParameterIndex], + m_ParameterOverrides + ); } void SystemInstance::SetWorldTransform(const glm::mat4& worldTransform) { + const std::scoped_lock lock(m_SnapshotMutex); m_WorldTransform = worldTransform; + + if (m_CompiledSystem) + PublishSnapshot(); } - bool SystemInstance::SetParameterOverride(std::string name, const glm::vec4& value) + Ref SystemInstance::GetSourceSystem() const { - if (!IsExposedParameter(name)) - return false; + const std::scoped_lock lock(m_SnapshotMutex); + return m_SourceSystem; + } - m_ParameterOverrides.insert_or_assign(std::move(name), value); - ++m_ParameterRevision; + bool SystemInstance::TryBeginSubmission() + { + const std::scoped_lock lock(m_SnapshotMutex); + if (m_SubmissionStarted) + return false; + + m_SubmissionStarted = true; return true; } - bool SystemInstance::ClearParameterOverride(const std::string& name) + void SystemInstance::CancelSubmission() { - const auto found = m_ParameterOverrides.find(name); - if (found == m_ParameterOverrides.end()) + const std::scoped_lock lock(m_SnapshotMutex); + + EE_CORE_ASSERT( + !m_CompiledSystem, + "A compiled Aether instance cannot cancel its submission." + ) + + if (!m_CompiledSystem) + m_SubmissionStarted = false; + } + + bool SystemInstance::Initialize(Ref system) + { + EE_CORE_ASSERT(system, "SystemInstance requires a compiled system.") + if (!system) return false; + + const std::scoped_lock lock(m_SnapshotMutex); + + if (!m_SubmissionStarted || m_CompiledSystem) return false; - m_ParameterOverrides.erase(found); - ++m_ParameterRevision; + EE_CORE_ASSERT( + system->SourceId == m_SourceSystemId, + "Aether compilation must belong to the instance source system." + ) + + if (system->SourceId != m_SourceSystemId) + return false; + const auto removed = std::erase_if( + m_ParameterOverrides, + [&system](const auto& entry) + { + return FindExposedParameter(*system, entry.first) == nullptr; + } + ); + + m_CompiledSystem = std::move(system); + m_SourceSystem.reset(); + + if (removed > 0) + ++m_ParameterRevision; + + PublishSnapshot(); return true; } - void SystemInstance::ClearParameterOverrides() + void SystemInstance::ApplyCompilation(Ref system) { - if (m_ParameterOverrides.empty()) + EE_CORE_ASSERT(system, "SystemInstance requires a compiled system.") + const std::scoped_lock lock(m_SnapshotMutex); + + EE_CORE_ASSERT( + system->SourceId == m_SourceSystemId, + "Aether compilation must belong to the instance source system." + ) + + if (system->SourceId != m_SourceSystemId) return; - m_ParameterOverrides.clear(); - ++m_ParameterRevision; + if (m_CompiledSystem == system) + return; + + const auto removed = std::erase_if(m_ParameterOverrides, [&system](const auto& entry) + { + return FindExposedParameter(*system, entry.first) == nullptr; + }); + + m_CompiledSystem = std::move(system); + ++m_Revision; + + if (removed > 0) + ++m_ParameterRevision; + + PublishSnapshot(); } - glm::vec4 SystemInstance::ResolveParameterValue(uint32_t parameterIndex) const + Ref SystemInstance::CaptureSnapshot() const + { + const std::scoped_lock lock(m_SnapshotMutex); + return m_Snapshot; + } + + void SystemInstance::PublishSnapshot() { EE_CORE_ASSERT( - parameterIndex < m_CompiledSystem->Parameters.size(), - "Aether parameter index is outside the compiled system parameter table." + m_CompiledSystem, + "SystemInstance requires compiled data before publishing a snapshot." ) - if (parameterIndex >= m_CompiledSystem->Parameters.size()) - return {}; - - const auto& parameter = m_CompiledSystem->Parameters[parameterIndex]; - const auto found = m_ParameterOverrides.find(parameter.Name); - - return found != m_ParameterOverrides.end() - ? found->second - : parameter.Value; + m_Snapshot = CreateRef( + m_Key, + m_Revision, + m_ParameterRevision, + m_CompiledSystem, + m_WorldTransform, + ResolveParameterValues(*m_CompiledSystem, m_ParameterOverrides) + ); } - bool SystemInstance::IsExposedParameter(const std::string& name) const + const SExposedParameter* SystemInstance::FindExposedParameter( + const SCompiledSystem& system, + std::string_view name + ) { - return std::ranges::any_of( - m_CompiledSystem->ExposedParameters, + const auto found = std::ranges::find_if( + system.ExposedParameters, [&name](const SExposedParameter& parameter) { return parameter.Name == name; } ); + + return found != system.ExposedParameters.end() + ? &*found + : nullptr; + } + + glm::vec4 SystemInstance::ResolveParameterValue( + const SGPUParameter& parameter, + const ParameterOverridesMap& overrides + ) + { + const auto found = overrides.find(parameter.Name); + return found != overrides.end() + ? found->second + : parameter.Value; + } + + Ref SystemInstance::ResolveParameterValues( + const SCompiledSystem& system, + const ParameterOverridesMap& overrides + ) + { + auto values = CreateRef(); + values->reserve(system.Parameters.size()); + + for (const auto& parameter : system.Parameters) + values->push_back(ResolveParameterValue(parameter, overrides)); + + return values; } } diff --git a/Elixir/Source/Engine/Aether/SystemInstance.h b/Elixir/Source/Engine/Aether/SystemInstance.h index 2342a15e..1bb53d3c 100644 --- a/Elixir/Source/Engine/Aether/SystemInstance.h +++ b/Elixir/Source/Engine/Aether/SystemInstance.h @@ -4,43 +4,278 @@ namespace Elixir::Aether { - // Runtime identity and immutable compiled payload selection. - // GPU allocations belong to Renderer::ParticleResourcePool, never here. - class ELIXIR_API SystemInstance final + namespace Runtime { class InstanceRegistry; } + namespace Rendering { - public: - explicit SystemInstance(Ref compiledSystem); - SystemInstance(const SystemInstance&) = delete; - SystemInstance& operator=(const SystemInstance&) = delete; - SystemInstance(SystemInstance&&) = delete; - SystemInstance& operator=(SystemInstance&&) = delete; + class FrameSubmission; + class FrameSubmissionPublisher; + class Renderer; + class SystemInstanceRenderProxy; + } +} + +namespace Elixir::Aether +{ + class Manager; + + /** + * @brief Identifies one runtime SystemInstance inside Aether. + * + * The key is created and used only by Aether internals. It lets Manager, + * FrameSubmission, and Renderer refer to the same instance without exposing a + * public handle API. + * + * @note Equality and hashing use the instance UUID. + */ + struct SSystemInstanceKey + { + friend class SystemInstance; + friend class Manager; + + std::size_t GetHashParams() const + { + return m_Id.GetHashParams(); + } + + bool operator==(const SSystemInstanceKey&) const = default; + + private: + SSystemInstanceKey() = default; - const UUID& GetId() const { return m_Id; } + UUID m_Id; + }; + + using ParameterOverridesMap = std::unordered_map; + using ResolvedParameterValues = std::vector; + + /** + * @brief Stores one immutable view of a SystemInstance. + * + * A snapshot contains the compiled system, world transform, and resolved + * parameter values selected at one point in time. It also creates the + * corresponding SystemInstanceRenderProxy for the renderer. + * + * SystemInstance publishes a replacement snapshot after each accepted change. + * Existing snapshots remain valid for frame submissions that already captured + * them. + * + * @thread_safety Immutable after construction. + */ + class ELIXIR_API SystemInstanceSnapshot final + { + friend class Manager; + friend class SystemInstance; + friend class Rendering::Renderer; + friend class Rendering::FrameSubmission; + + public: + /** + * @brief Creates an immutable runtime-state snapshot. + * + * @param key Internal identity of the source instance. + * @param revision Revision of the compiled-system selection. + * @param parameterRevision Revision of the resolved parameter values. + * @param system Compiled system selected by the instance. + * @param worldTransform World transform selected by the instance. + * @param parameters Resolved values in compiled parameter order. + * + * @pre system is not null. + * @pre parameters is not null. + */ + SystemInstanceSnapshot( + SSystemInstanceKey key, + uint32_t revision, + uint32_t parameterRevision, + Ref system, + const glm::mat4& worldTransform, + Ref parameters + ); + /** + * @brief Returns the compiled-system selection revision. + * @return Revision incremented when the compiled system changes. + */ uint32_t GetRevision() const { return m_Revision; } + + /** + * @brief Returns the resolved parameter-value revision. + * @return Revision incremented when effective parameter values change. + */ uint32_t GetParameterRevision() const { return m_ParameterRevision; } + /** + * @brief Returns the selected immutable compiled system. + * @return Compiled system used by this snapshot. + */ const SCompiledSystem& GetCompiledSystem() const { return *m_CompiledSystem; } - void SetCompiledSystem(Ref compiledSystem); + /** + * @brief Returns the selected world transform. + * @return World transform stored in this snapshot. + */ const glm::mat4& GetWorldTransform() const { return m_WorldTransform; } - void SetWorldTransform(const glm::mat4& worldTransform); - bool SetParameterOverride(std::string name, const glm::vec4& value); + private: + // Returns the internal identity used by frame and renderer bookkeeping. + const SSystemInstanceKey& GetKey() const { return m_Key; } + + // Returns the immutable renderer-facing state derived from this snapshot. + const Ref& GetRenderProxy() const + { + return m_RenderProxy; + } + + SSystemInstanceKey m_Key; + uint32_t m_Revision = 1; + uint32_t m_ParameterRevision = 1; + Ref m_CompiledSystem; + glm::mat4 m_WorldTransform{ 1.0f }; + Ref m_Parameters; + Ref m_RenderProxy; + }; + + /** + * @brief Represents one runtime use of an Aether system. + * + * System::CreateInstance() creates an unregistered instance from authored + * system data. Manager::Submit() compiles and registers it on first use. + * + * The instance owns mutable runtime choices such as its world transform and + * exposed parameter overrides. After registration, it publishes each change + * as an immutable SystemInstanceSnapshot. + * + * The instance does not own particle buffers or other GPU resources. Manager + * registers the instance, and Renderer owns its GPU allocation and retirement. + * + * @thread_safety Public mutation methods synchronize snapshot publication. + * Frame submission captures an immutable snapshot and never reads mutable + * instance state from the render thread. + */ + class ELIXIR_API SystemInstance final + { + friend class Manager; + friend class System; + friend class Runtime::InstanceRegistry; + friend class Rendering::Renderer; + friend class Rendering::FrameSubmission; + friend class Rendering::FrameSubmissionPublisher; + + public: + /** + * @brief Creates an unregistered from an authored System. + * @param system Authored system to instantiate. + */ + explicit SystemInstance(Ref system); + + SystemInstance(const SystemInstance&) = delete; + SystemInstance& operator=(const SystemInstance&) = delete; + SystemInstance(SystemInstance&&) = delete; + SystemInstance& operator=(SystemInstance&&) = delete; + + /** + * @brief Sets an override for an exposed compiled-system parameter. + * + * @param name Name of the exposed parameter. + * @param value Replacement float4 value. + * @return True when the parameter is exposed and the override was stored. + * @return False when the parameter is not exposed. + */ + bool SetParameterOverride(const std::string& name, const glm::vec4& value); + + /** + * @brief Removes one parameter override. + * @param name Name of the overridden parameter. + * @return True when an override was removed. + * @return False when no override exists for this name. + */ bool ClearParameterOverride(const std::string& name); + + /** + * @brief Removes all parameter overrides. + * + * The method does nothing when no overrides are set. + */ void ClearParameterOverrides(); - glm::vec4 ResolveParameterValue(uint32_t parameterIndex) const; + /** + * @brief Returns the effective value of an exposed parameter. + * + * The method returns the instance override when one exists. Otherwise, it + * returns the default value from the selected compiled system. + * + * @param name Name of the exposed parameter. + * @return Effective value, or no value when the parameter is not exposed. + */ + std::optional GetParameterValue(const std::string& name) const; + + /** + * @brief Returns the identity of the source System. + * @return UUID of the System used to create this instance. + */ + const UUID& GetSourceSystemId() const { return m_SourceSystemId; } + + /** + * @brief Replaces the world transform for future frame submissions. + * @param worldTransform Transform applied to this system instance. + */ + void SetWorldTransform(const glm::mat4& worldTransform); private: - bool IsExposedParameter(const std::string& name) const; + // Returns the authored System retained before the first submission. + Ref GetSourceSystem() const; - UUID m_Id; + // Reserves the instance for its first runtime submission. + bool TryBeginSubmission(); + + // Cancels a failed first submission so that it can be retried. + void CancelSubmission(); + + // Applies the first compilation and releases the authored System. + bool Initialize(Ref system); + + // Replaces the internal compilation after rebuilding the same source asset. + void ApplyCompilation(Ref system); + + // Captures the current immutable state without retaining the mutex. + Ref CaptureSnapshot() const; + + // Publishes the current mutable state as an immutable snapshot. + void PublishSnapshot(); + + // Finds the compiled mapping for an exposed runtime parameter. + static const SExposedParameter* FindExposedParameter( + const SCompiledSystem& system, + std::string_view name + ); + + // Resolves one compiled parameter against the instance overrides. + static glm::vec4 ResolveParameterValue( + const SGPUParameter& parameter, + const ParameterOverridesMap& overrides + ); + + // Resolves all compiled parameters into immutable renderer table order. + static Ref ResolveParameterValues( + const SCompiledSystem& system, + const ParameterOverridesMap& overrides + ); + + // Returns the internal identity used by Manager and Renderer. + const SSystemInstanceKey& GetKey() const { return m_Key; } + + SSystemInstanceKey m_Key; + UUID m_SourceSystemId; uint32_t m_Revision = 1; uint32_t m_ParameterRevision = 1; + Ref m_SourceSystem; Ref m_CompiledSystem; - glm::mat4 m_WorldTransform{ 1.0f }; - std::unordered_map m_ParameterOverrides; + ParameterOverridesMap m_ParameterOverrides; + + bool m_SubmissionStarted = false; + Ref m_Snapshot; + mutable std::mutex m_SnapshotMutex; }; } + +GENERATE_HASH_FUNCTION(Elixir::Aether::SSystemInstanceKey) diff --git a/Elixir/Source/Engine/Core/Application.cpp b/Elixir/Source/Engine/Core/Application.cpp index 1f4116e0..4c37f1e0 100644 --- a/Elixir/Source/Engine/Core/Application.cpp +++ b/Elixir/Source/Engine/Core/Application.cpp @@ -1,4 +1,3 @@ -#include "epch.h" #include "Application.h" #include "Engine/GUI/Button.h" @@ -14,6 +13,9 @@ #include #include #include +#include +#include +#include namespace Elixir { @@ -40,6 +42,13 @@ namespace Elixir FontManager::Initialize(m_GraphicsContext.get()); IconManager::Initialize(m_GraphicsContext.get()); + m_MaterialRegistry = CreateScope(); + m_MaterialSystem = CreateScope( + m_GraphicsContext.get(), + m_ShaderLoader.get(), + SMaterialSystemConfig{ .InitialFrameCapacity = 256 } + ); + m_GUIManager = CreateScope(); m_GUIManager->Initialize( m_GraphicsContext.get(), @@ -47,6 +56,13 @@ namespace Elixir m_Window->GetFramebufferExtent() // TODO: Get from Ctx->GetRenderTargetExtent().. ); + m_AetherManager = CreateScope( + m_GraphicsContext.get(), + m_ShaderLoader.get(), + *m_MaterialRegistry, + *m_MaterialSystem + ); + const auto buttonBg = TextureLoader::Load("./Assets/Button_Background.png"); const auto panel = CreateRef(); @@ -124,7 +140,7 @@ namespace Elixir .SetPosition({ 10, 10 }) .SetSize({ 280, 24 }); - m_GUIManager->SetRoot(panel); + //m_GUIManager->SetRoot(panel); } Application::~Application() @@ -179,9 +195,13 @@ namespace Elixir m_GUIManager->ArrangeLayout(m_Window->GetWindowExtent()); // TODO: Remove from here and handle only when resizing m_GUIManager->Update(frameTime); + Prepare(frameTime); + m_GraphicsContext->RenderFrame([this, frameTime]() { - OnRender(frameTime); + m_MaterialSystem->BeginFrame(); + Render(frameTime); + m_MaterialSystem->RenderFrame(); m_GUIManager->Render(); }); @@ -203,6 +223,40 @@ namespace Elixir m_GUIManager->ProcessEvent(event); } + MaterialSystem& Application::GetMaterialSystem() + { + EE_CORE_ASSERT(m_MaterialSystem, "Application material system is unavailable.") + return *m_MaterialSystem; + } + + const MaterialSystem& Application::GetMaterialSystem() const + { + EE_CORE_ASSERT(m_MaterialSystem, "Application material system is unavailable.") + return *m_MaterialSystem; + } + + MaterialRegistry& Application::GetMaterialRegistry() + { + return *m_MaterialRegistry; + } + + const MaterialRegistry& Application::GetMaterialRegistry() const + { + return *m_MaterialRegistry; + } + + Aether::Manager& Application::GetAetherManager() + { + EE_CORE_ASSERT(m_AetherManager, "Application Aether manager is unavailable.") + return *m_AetherManager; + } + + const Aether::Manager& Application::GetAetherManager() const + { + EE_CORE_ASSERT(m_AetherManager, "Application Aether manager is unavailable.") + return *m_AetherManager; + } + bool Application::OnWindowClose(WindowCloseEvent& event) { m_Running = false; diff --git a/Elixir/Source/Engine/Core/Application.h b/Elixir/Source/Engine/Core/Application.h index 611717c8..fc7e1c49 100644 --- a/Elixir/Source/Engine/Core/Application.h +++ b/Elixir/Source/Engine/Core/Application.h @@ -13,6 +13,17 @@ namespace Elixir { namespace GUI { class TextBlock; } + namespace Aether { class Manager; } + namespace Materials + { + class MaterialSystem; + class MaterialRegistry; + } +} + +namespace Elixir +{ + using namespace Elixir::Materials; class ELIXIR_API Application { @@ -23,10 +34,24 @@ namespace Elixir void Run(); virtual void OnGUI(Timestep frameTime) {} - virtual void OnRender(Timestep frameTime) {} + + // Runs on the application thread before its render task is queued. + // Implementations must not record GPU commands from this method. + virtual void Prepare(Timestep frameTime) {} + + virtual void Render(Timestep frameTime) {} virtual void OnEvent(Event& event); - [[nodiscard]] const Window* GetWindow() const { return m_Window.get(); } + const Window* GetWindow() const { return m_Window.get(); } + + MaterialSystem& GetMaterialSystem(); + const MaterialSystem& GetMaterialSystem() const; + + MaterialRegistry& GetMaterialRegistry(); + const MaterialRegistry& GetMaterialRegistry() const; + + Aether::Manager& GetAetherManager(); + const Aether::Manager& GetAetherManager() const; static Application& Get() { return *s_Application; } @@ -41,6 +66,11 @@ namespace Elixir Scope m_ShaderLoader; Scope m_GUIManager; + Scope m_MaterialSystem; + Scope m_MaterialRegistry; + + Scope m_AetherManager; + Timer m_Timer; FrameProfiler m_Profiler; @@ -57,4 +87,4 @@ namespace Elixir // NOTE: To be defined in the client extern Application* CreateApplication(); -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/Core/Core.h b/Elixir/Source/Engine/Core/Core.h index 4f19100f..df4a7541 100644 --- a/Elixir/Source/Engine/Core/Core.h +++ b/Elixir/Source/Engine/Core/Core.h @@ -79,11 +79,11 @@ struct HasGetHashParams().GetHashParams( namespace std \ { \ template <> \ - struct hash { \ + struct hash { \ size_t operator()(const T& obj) const noexcept \ { \ static_assert(HasGetHashParams::value, #T " must define GetHashParams()"); \ - return Elixir::Hash::HashValues(obj.GetHashParams()); \ + return Elixir::Hash::HashValues(obj.GetHashParams()); \ } \ }; \ } @@ -115,7 +115,7 @@ namespace Elixir namespace Hash { - inline void HashCombine(std::size_t& seed, std::size_t value) + inline void HashCombine(std::size_t& seed, const std::size_t value) { // Similar to boost::hash_combine seed ^= value + 0x9e3779b97f4a7c15ULL + (seed << 6) + (seed >> 2); diff --git a/Elixir/Source/Engine/Core/UUID.h b/Elixir/Source/Engine/Core/UUID.h index 3b2f38fa..04d13e46 100644 --- a/Elixir/Source/Engine/Core/UUID.h +++ b/Elixir/Source/Engine/Core/UUID.h @@ -48,6 +48,26 @@ namespace Elixir GENERATE_HASH_FUNCTION(Elixir::UUID) +inline std::string operator+(const char* lhs, const Elixir::UUID& rhs) +{ + return std::string(lhs) + rhs.ToString(); +} + +inline std::string operator+(const std::string& lhs, const Elixir::UUID& rhs) +{ + return lhs + rhs.ToString(); +} + +inline std::string operator+(const Elixir::UUID& lhs, const char* rhs) +{ + return lhs.ToString() + rhs; +} + +inline std::string operator+(const Elixir::UUID& lhs, const std::string& rhs) +{ + return lhs.ToString() + rhs; +} + /** * Formatter for seamless UUID output in the logging system. */ diff --git a/Elixir/Source/Engine/Graphics/FrameSlotPendingState.h b/Elixir/Source/Engine/Graphics/FrameSlotPendingState.h new file mode 100644 index 00000000..51ed8888 --- /dev/null +++ b/Elixir/Source/Engine/Graphics/FrameSlotPendingState.h @@ -0,0 +1,143 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace Elixir +{ + /** + * @brief Applies keyed state changes to the resource for each frame slot. + * + * Each key has a desired value and a revision. A revision is materialized + * once for each frame slot, when that slot becomes current. + * + * @tparam TResource Resource stored for each frame slot. + * @tparam TKey Key that identifies an independently tracked value. + * @tparam TValue Value associated with a key. + */ + template + class FrameSlotPendingState final + { + public: + /** + * @brief Describes one value pending materialization. + * + * References remain valid only during ApplyPendingState. + */ + struct SPendingState + { + const TKey& Key; + const TValue& Value; + }; + + /** + * @brief Creates resources associated with a graphics context. + * @param context Context that owns the frame slots. + */ + explicit FrameSlotPendingState(const GraphicsContext& context) + : m_Resources(context) {} + + /** + * @brief Stores a value when it differs from the current desired value. + * @param key Key that identifies the value. + * @param value Desired value. + * @return True when the desired value changed. + */ + bool Set(const TKey& key, TValue value) + { + const auto found = m_States.find(key); + + if (found == m_States.end()) + { + m_States.emplace(key, SState{ + .Value = std::move(value), + .Revision = 1 + }); + return true; + } + + auto& state = found->second; + if (state.Value == value) + return false; + + state.Value = std::move(value); + ++state.Revision; + return true; + } + + /** + * @brief Applies state not yet materialized in the current frame slot. + * + * The callback receives the resource for GraphicsContext::GetFrameIndex() + * and every key whose desired revision differs from that slot's revision. + * Revisions are recorded only after the callback returns. + * + * @tparam TApply The callback type. + * @param apply Receives TResource& and std::span. + */ + template + requires std::invocable> + void ApplyPendingState(TApply&& apply) + { + const auto frameIndex = m_Resources.GetCurrentFrameIndex(); + std::vector> pendingStates; + std::vector pendingValues; + + for (auto& [key, state] : m_States) + { + if (state.AppliedRevisions[frameIndex] == state.Revision) + continue; + + pendingStates.emplace_back(state); + pendingValues.emplace_back(key, state.Value); + } + + if (pendingValues.empty()) + return; + + std::invoke( + std::forward(apply), + m_Resources.GetCurrent(), + std::span(pendingValues) + ); + + for (auto& state : pendingStates) + state.get().AppliedRevisions[frameIndex] = state.get().Revision; + } + + /** + * @brief Invokes a function for every frame-slot resource. + * @tparam TFunction The function type. + * @param function Function invoked for each resource. + */ + template + void ForEach(TFunction&& function) + { + m_Resources.ForEach(std::forward(function)); + } + + /** @brief Returns the resource for the current frame slot. */ + TResource& GetCurrent() { return m_Resources.GetCurrent(); } + + /** @brief Returns the resource for the current frame slot. */ + const TResource& GetCurrent() const { return m_Resources.GetCurrent(); } + + private: + struct SState + { + TValue Value; + uint64_t Revision = 0; + std::array AppliedRevisions{}; + }; + + FrameSlotState m_Resources; + std::unordered_map m_States; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Graphics/FrameSlotState.h b/Elixir/Source/Engine/Graphics/FrameSlotState.h new file mode 100644 index 00000000..0608a463 --- /dev/null +++ b/Elixir/Source/Engine/Graphics/FrameSlotState.h @@ -0,0 +1,99 @@ +#pragma once + +#include +#include + +namespace Elixir +{ + /** + * @brief Stores one value for each frame slot of a graphics context. + * + * The current value is selected from GraphicsContext::GetFrameIndex(). + * + * @tparam T Value stored for each frame slot. + */ + template + class FrameSlotState final + { + public: + /** + * @brief Creates values associated with a graphics context. + * @param context Graphics context that owns frame slots. + */ + explicit FrameSlotState(const GraphicsContext& context) + : m_GraphicsContext(context) {} + + /** + * @brief Returns the index of the current frame slot. + * @return The index of the current frame slot. + */ + uint32_t GetCurrentFrameIndex() const + { + return m_GraphicsContext.GetFrameIndex(); + } + + /** + * @brief Returns the value for the current frame slot. + * @return The value stored for the currently active frame slot. + */ + T& GetCurrent() + { + return m_Values[GetCurrentFrameIndex()]; + } + + /** + * @brief Returns the value for the current frame slot. + * @return The value stored for the currently active frame slot. + */ + const T& GetCurrent() const + { + return m_Values[GetCurrentFrameIndex()]; + } + + /** + * @brief Returns the value for a frame slot. + * @param frameIndex Frame slot index. + * @return The value stored for @p frameIndex frame slot. + */ + T& Get(const uint32_t frameIndex) + { + ValidateFrameIndex(frameIndex); + return m_Values[frameIndex]; + } + + /** + * @brief Returns the value for a frame slot. + * @param frameIndex Frame slot index. + * @return The value stored for @p frameIndex frame slot. + */ + const T& Get(const uint32_t frameIndex) const + { + ValidateFrameIndex(frameIndex); + return m_Values[frameIndex]; + } + + /** + * @brief Invokes a function for every frame-slot value. + * @tparam TFunction The function type. + * @param function Function invoked for each value. + */ + template + void ForEach(TFunction&& function) + { + for (auto& value : m_Values) + std::invoke(std::forward(function), value); + } + + private: + static void ValidateFrameIndex(const uint32_t frameIndex) + { + EE_CORE_ASSERT( + frameIndex < GraphicsContext::FRAMES, + "Frame slot index is out of range." + ) + } + + std::array m_Values; + const GraphicsContext& m_GraphicsContext; + }; +} diff --git a/Elixir/Source/Engine/Graphics/GraphicsContext.h b/Elixir/Source/Engine/Graphics/GraphicsContext.h index af3f8cbb..b87a804e 100644 --- a/Elixir/Source/Engine/Graphics/GraphicsContext.h +++ b/Elixir/Source/Engine/Graphics/GraphicsContext.h @@ -56,6 +56,11 @@ namespace Elixir virtual Ref GetUploadCommandBuffer() const = 0; virtual void EnqueueSecondaryCommandBuffer(const Ref& cmd) const = 0; + /** + * Block until the GPU has finished all submitted work. + */ + virtual void WaitDeviceIdle() const {} + [[nodiscard]] EGraphicsAPI GetAPI() const { return m_API; } const Window* GetWindow() const { return m_Window; } diff --git a/Elixir/Source/Engine/Graphics/Shader/Shader.cpp b/Elixir/Source/Engine/Graphics/Shader/Shader.cpp index d5b87317..87e07643 100644 --- a/Elixir/Source/Engine/Graphics/Shader/Shader.cpp +++ b/Elixir/Source/Engine/Graphics/Shader/Shader.cpp @@ -21,6 +21,11 @@ namespace Elixir } } + bool Shader::HasBinding(const std::string& name) const + { + return GetShaderBinding(name) != nullptr; + } + Ref Shader::GetTexture(const std::string& name) const { if (const auto binding = GetShaderBinding(name)) diff --git a/Elixir/Source/Engine/Graphics/Shader/Shader.h b/Elixir/Source/Engine/Graphics/Shader/Shader.h index 6a9416d1..b4dc05f2 100644 --- a/Elixir/Source/Engine/Graphics/Shader/Shader.h +++ b/Elixir/Source/Engine/Graphics/Shader/Shader.h @@ -73,6 +73,17 @@ namespace Elixir virtual void BindStorageBuffer(const std::string& name, const Ref& buffer) = 0; virtual void BindConstantBuffer(const std::string& name, const Ref& buffer) = 0; + /** + * @brief Checks whether the shader declares a named binding. + * + * The shader compiler can remove declarations that are not used by an + * optimized permutation. + * + * @param name Binding name to find. + * @return True when the shader contains @p name binding. + */ + bool HasBinding(const std::string& name) const; + virtual Ref GetTexture(const std::string& name) const; virtual Ref GetTexture(SShaderBinding binding) const; diff --git a/Elixir/Source/Engine/Icon/IconManager.h b/Elixir/Source/Engine/Icon/IconManager.h index 60b2e3d2..3a5a801b 100644 --- a/Elixir/Source/Engine/Icon/IconManager.h +++ b/Elixir/Source/Engine/Icon/IconManager.h @@ -48,7 +48,7 @@ namespace Elixir * @param path Local icon file. * @return Icon, or nullptr when no loader accepts the file. */ - static Ref Load(const std::filesystem::path& path); + static Ref<::Icon> Load(const std::filesystem::path& path); private: static std::optional InferFormat(const std::filesystem::path& path); diff --git a/Elixir/Source/Engine/Materials/Compilation/CompilationCache.cpp b/Elixir/Source/Engine/Materials/Compilation/CompilationCache.cpp new file mode 100644 index 00000000..9f65c68b --- /dev/null +++ b/Elixir/Source/Engine/Materials/Compilation/CompilationCache.cpp @@ -0,0 +1,45 @@ +#include "epch.h" +#include "CompilationCache.h" + +#include + +namespace Elixir::Materials::Compilation +{ + CompilationCache::CompilationCache(const ShaderLoader* shaderLoader) + : m_ShaderLoader(shaderLoader) {} + + Ref CompilationCache::GetOrCompile( + const Ref& material + ) + { + if (!material) return nullptr; + + // ShaderLoader and cache mutation are serialized while compiling a miss. + const std::scoped_lock lock(m_Mutex); + + auto& entry = m_Entries[material.get()]; + + if (entry.Compiled && entry.Revision == material->GetRevision()) + return entry.Compiled; + + const auto result = m_ShaderLoader + ? Compiler::Compile(m_ShaderLoader, *material) + : Compiler::Build(*material); + + if (!result) + { + EE_CORE_ERROR( + "Material '{}' compilation failed: {}", + material->GetName(), + result.Diagnostics + ) + return nullptr; + } + + entry.Source = material; + entry.Revision = material->GetRevision(); + entry.Compiled = result.Material; + + return entry.Compiled; + } +} diff --git a/Elixir/Source/Engine/Materials/Compilation/CompilationCache.h b/Elixir/Source/Engine/Materials/Compilation/CompilationCache.h new file mode 100644 index 00000000..e18c5efb --- /dev/null +++ b/Elixir/Source/Engine/Materials/Compilation/CompilationCache.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include + +namespace Elixir { class ShaderLoader; } + +namespace Elixir::Materials::Compilation +{ + /** + * @brief Caches compiled material data for one shader loader. + * + * Cached entries are rebuilt when the source material revision changes. Access + * is serialized so one compilation updates the cache at a time. + */ + class ELIXIR_API CompilationCache final + { + public: + /** + * @brief Creates a material compilation cache. + * + * When shaderLoader is null, the cache validates graphs and builds layouts + * without producing shader programs. + * + * @param shaderLoader Shader loader used for full shader compilation. + */ + explicit CompilationCache(const ShaderLoader* shaderLoader); + + /** + * @brief Gets compiled data for a material. + * + * The cache returns the existing result when its revision matches the source + * material. Otherwise, it rebuilds the material before returning it. + * + * @param material Material to compile or retrieve. + * @return Compiled material data, or null when material is null or compilation fails. + */ + Ref GetOrCompile(const Ref& material); + + private: + /** @brief Stores the source material, compiled revision, and cached result. */ + struct SEntry + { + Ref Source; + uint32_t Revision = 0; + Ref Compiled; + }; + + const ShaderLoader* m_ShaderLoader = nullptr; + std::unordered_map m_Entries; + std::mutex m_Mutex; + }; +} diff --git a/Elixir/Source/Engine/Materials/Compilation/Compiler.cpp b/Elixir/Source/Engine/Materials/Compilation/Compiler.cpp new file mode 100644 index 00000000..77c4034a --- /dev/null +++ b/Elixir/Source/Engine/Materials/Compilation/Compiler.cpp @@ -0,0 +1,469 @@ +#include "epch.h" +#include "Compiler.h" + +#include +#include +#include + +namespace Elixir::Materials::Compilation +{ + namespace fs = std::filesystem; + + static const fs::path s_ShadersDir = "./Shaders"; + static const fs::path s_GeneratedDir = s_ShadersDir / "Generated"; + + namespace + { + fs::path FindDXC() + { + if (const char* sdk = std::getenv("VULKAN_SDK")) + { + fs::path mac = fs::path(sdk) / "macOS" / "bin" / "dxc"; + if (fs::exists(mac)) return mac; + + const fs::path bin = fs::path(sdk) / "bin" / "dxc"; + if (fs::exists(bin)) return bin; + } + + return "dxc"; // rely on PATH + } + + std::string ReadFile(const fs::path& path) + { + std::ifstream in(path, std::ios::binary); + if (!in) return {}; + + std::stringstream ss; + ss << in.rdbuf(); + + return ss.str(); + } + + std::string ValueExpression(const SCompiledParameter& parameter) + { + std::string value = "mat.Values[" + std::to_string(parameter.Slot) + "]"; + + switch (parameter.ValueType) + { + case EMaterialValueType::Float: return value + ".x"; + case EMaterialValueType::Float2: return value + ".xy"; + case EMaterialValueType::Float3: return value + ".xyz"; + case EMaterialValueType::Float4: return value; + } + + return value; + } + + std::string GenerateGraphHLSL( + const MaterialGraph& graph, + const SCompiledMaterial& material + ) + { + SMaterialGraphBindings bindings; + + for (const auto& parameter : material.Parameters) + { + const auto expression = parameter.Kind == EMaterialParameterKind::Texture + ? "mat.TextureIndices[" + std::to_string(parameter.Slot) + "]" + : ValueExpression(parameter); + + auto& destination = parameter.Kind == EMaterialParameterKind::Texture + ? bindings.Textures + : bindings.Values; + + destination[parameter.Name] = expression; + } + + return graph.GenerateHLSL(bindings); + } + } + + SCompileResult Compiler::Build(const Material& material) + { + std::string diagnostics; + if (!material.ValidateGraph(&diagnostics)) + return { .Diagnostics = std::move(diagnostics) }; + + std::vector> parameters( + material.GetParameters().begin(), + material.GetParameters().end() + ); + std::ranges::sort(parameters, {}, &decltype(parameters)::value_type::first); + + SMaterialGraphBindings bindings; + std::vector layout; + uint32_t valueSlot = 0; + uint32_t textureSlot = 0; + + for (const auto& [name, definition] : parameters) + { + auto& slot = definition.Kind == EMaterialParameterKind::Texture + ? textureSlot + : valueSlot; + + if (slot >= 32) + return { .Diagnostics = "Material parameter capacity exceeded." }; + + layout.push_back({ name, definition.Kind, definition.ValueType, slot }); + + if (definition.Kind == EMaterialParameterKind::Texture) + bindings.Textures[name] = "mat.TextureIndices[" + std::to_string(slot++) + "]"; + else + bindings.Values[name] = "mat.Values[" + std::to_string(slot++) + "]"; + } + + const auto compiled = CreateRef(); + compiled->UsageMask = material.GetUsageMask(); + compiled->MaterialRevision = material.GetRevision(); + compiled->Parameters = std::move(layout); + return { .Material = compiled }; + } + + SCompileResult Compiler::Compile(const ShaderLoader* loader, const Material& material) + { + auto result = Build(material); + if (!result) return result; + + result = CompileSurface(loader, material, std::move(result)); + if (!result) return result; + + if (material.SupportsUsage(EMaterialUsage::ParticleSprite)) + { + result = CompileParticleSprite(loader, material, std::move(result)); + if (!result) return result; + } + + if (material.SupportsUsage(EMaterialUsage::ParticleRibbon)) + { + result = CompileParticleRibbon(loader, material, std::move(result)); + if (!result) return result; + } + + if (material.SupportsUsage(EMaterialUsage::ParticleMesh)) + { + result = CompileParticleMesh(loader, material, std::move(result)); + if (!result) return result; + } + + return result; + } + + std::string Compiler::InjectBody(const std::string& hlsl, const std::string& graphBody) + { + std::string out = hlsl; + + constexpr std::string_view marker = "// __GRAPH_BODY__"; + if (const auto pos = out.find(marker); pos != std::string::npos) + out.replace(pos, marker.size(), graphBody); + + return out; + } + + SCompileResult Compiler::CompileSurface( + const ShaderLoader* loader, + const Material& material, + SCompileResult result + ) + { + const auto hlsl = ReadFile(s_ShadersDir / "Material" / "Material.ps.hlsl"); + + if (hlsl.empty()) + { + EE_CORE_ERROR("Material graph: template Material.ps.hlsl not found.") + result.Diagnostics = "Material template Material.ps.hlsl was not found."; + result.Material.reset(); + return result; + } + + // Unique name per compiled graph so instances don't clobber each other. + static std::atomic counter{ 0 }; + const std::string name = "GraphMat_" + std::to_string(counter.fetch_add(1)); + + // Each compile loads from its own subdir containing only its two SPIR-V + // modules, so stray files (like the generated.hlsl) never look like a + // shader module to the loader. The .hlsl source lives outside that dir. + const fs::path loadDir = s_GeneratedDir / name; + std::error_code error; + fs::create_directories(loadDir, error); + + const fs::path hlslPath = s_GeneratedDir / (name + ".src.ps.hlsl"); + { + std::ofstream out(hlslPath, std::ios::binary); + + const auto graphHlsl = GenerateGraphHLSL(material.GetGraph(), *result.Material); + out << InjectBody(hlsl, graphHlsl); + } + + // Compile the generated pixel shader to SPIR-V with DXC. + const fs::path dxc = FindDXC(); + const fs::path spvPath = loadDir / (name + ".ps.spirv"); + const std::string cmd = + "\"" + dxc.string() + "\" -spirv -T ps_6_0 -E main \"" + + hlslPath.string() + "\" -Fo \"" + spvPath.string() + "\""; + + const int rc = std::system(cmd.c_str()); + if (rc != 0 || !fs::exists(spvPath)) + { + EE_CORE_ERROR("Material graph: DXC compilation failed (rc={0}) for {1}.", rc, name) + result.Diagnostics = "DXC failed while compiling material."; + result.Material.reset(); + return result; + } + + result.Material->SurfaceShader = loader->LoadShader(loadDir, name); + if (!result.Material->SurfaceShader) + { + result.Diagnostics = "Shader loader could not load the compiled material."; + result.Material.reset(); + } + + return result; + } + + SCompileResult Compiler::CompileParticleSprite( + const ShaderLoader* loader, + const Material& material, + SCompileResult result + ) + { + const auto hlsl = ReadFile(s_ShadersDir / "Material" / "ParticleSprite.ps.hlsl"); + + if (hlsl.empty()) + { + result.Diagnostics = "Material template ParticleSprite.ps.hlsl was not found."; + result.Material.reset(); + return result; + } + + // Unique name per compiled graph so instances don't clobber each other. + static std::atomic counter{ 0 }; + const std::string name = "GraphMat_" + std::to_string(counter.fetch_add(1)) + "_ParticleSprite"; + + const fs::path loadDir = s_GeneratedDir / name; + std::error_code error; + fs::create_directories(loadDir, error); + + const fs::path spriteSourcePath = s_GeneratedDir / (name + ".src.ps.hlsl"); + { + std::ofstream out(spriteSourcePath, std::ios::binary); + + const auto graphHlsl = GenerateGraphHLSL(material.GetGraph(), *result.Material); + out << InjectBody(hlsl, graphHlsl); + } + + // Compile the generated pixel shader to SPIR-V with DXC. + const fs::path dxc = FindDXC(); + const fs::path spvPath = loadDir / (name + ".ps.spirv"); + const std::string cmd = + "\"" + dxc.string() + "\" -spirv -T ps_6_0 -E main \"" + + spriteSourcePath.string() + "\" -Fo \"" + spvPath.string() + "\""; + + const int rc = std::system(cmd.c_str()); + if (rc != 0 || !fs::exists(spvPath)) + { + EE_CORE_ERROR( + "Particle sprite material: DXC compilation failed (rc={0}) for {1}.", + rc, + name + ) + result.Diagnostics = "DXC failed while compiling the particle sprite material."; + result.Material.reset(); + return result; + } + + // The generated pixel stage shares the existing Aether sprite vertex ABI. + // Put both stages in an isolated directory so ShaderLoader sees one shader. + const fs::path spriteVertexSpv = s_ShadersDir / "Aether" / "Sprite.vs.spirv"; + fs::copy_file( + spriteVertexSpv, + loadDir / (name + ".vs.spirv"), + fs::copy_options::overwrite_existing, + error + ); + + if (error) + { + result.Diagnostics = "Could not prepare the particle sprite vertex shader."; + result.Material.reset(); + return result; + } + + result.Material->ParticleSpriteShader = loader->LoadShader(loadDir, name); + if (!result.Material->ParticleSpriteShader) + { + result.Diagnostics = "Shader loader could not load the particle sprite material."; + result.Material.reset(); + } + + return result; + } + + SCompileResult Compiler::CompileParticleRibbon( + const ShaderLoader* loader, + const Material& material, + SCompileResult result + ) + { + const auto vertexHlsl = ReadFile(s_ShadersDir / "Material" / "ParticleRibbon.vs.hlsl"); + const auto pixelHlsl = ReadFile(s_ShadersDir / "Material" / "ParticleRibbon.ps.hlsl"); + + if (vertexHlsl.empty() || pixelHlsl.empty()) + { + result.Diagnostics = "Material ribbon shader template was not found."; + result.Material.reset(); + return result; + } + + // Unique name per compiled graph so instances don't clobber each other. + static std::atomic counter{ 0 }; + const std::string name = "GraphMat_" + std::to_string(counter.fetch_add(1)) + "_ParticleRibbon"; + + const fs::path loadDir = s_GeneratedDir / name; + std::error_code error; + fs::create_directories(loadDir, error); + + const fs::path vertexSourcePath = s_GeneratedDir / (name + ".src.vs.hlsl"); + { + std::ofstream out(vertexSourcePath, std::ios::binary); + out << vertexHlsl; + } + + const fs::path pixelSourcePath = s_GeneratedDir / (name + ".src.ps.hlsl"); + { + std::ofstream out(pixelSourcePath, std::ios::binary); + + const auto graphHlsl = GenerateGraphHLSL(material.GetGraph(), *result.Material); + out << InjectBody(pixelHlsl, graphHlsl); + } + + // Compile the generated pixel shader to SPIR-V with DXC. + const fs::path dxc = FindDXC(); + const fs::path spvPath = loadDir / (name + ".ps.spirv"); + + + const auto compileStage = [&dxc]( + const fs::path& sourcePath, + const fs::path& spvPath, + const std::string_view profile + ) + { + const std::string cmd = + "\"" + dxc.string() + "\" -spirv -T " + std::string(profile) + " -E main \"" + + sourcePath.string() + "\" -Fo \"" + spvPath.string() + "\""; + + return std::system(cmd.c_str()) == 0 && fs::exists(spvPath); + }; + + if (!compileStage( + vertexSourcePath, + loadDir / (name + ".vs.spirv"), + "vs_6_0" + ) || !compileStage( + pixelSourcePath, + loadDir / (name + ".ps.spirv"), + "ps_6_0" + )) + { + EE_CORE_ERROR( + "Particle ribbon material: DXC compilation failed for {}.", + name + ) + result.Diagnostics = "DXC failed while compiling the particle ribbon material."; + result.Material.reset(); + return result; + } + + result.Material->ParticleRibbonShader = loader->LoadShader(loadDir, name); + if (!result.Material->ParticleRibbonShader) + { + result.Diagnostics = "Shader loader could not load the particle ribbon material."; + result.Material.reset(); + } + + return result; + } + + SCompileResult Compiler::CompileParticleMesh( + const ShaderLoader* loader, + const Material& material, + SCompileResult result + ) + { + const auto vertexHlsl = ReadFile(s_ShadersDir / "Material" / "ParticleMesh.vs.hlsl"); + const auto pixelHlsl = ReadFile(s_ShadersDir / "Material" / "ParticleMesh.ps.hlsl"); + + if (vertexHlsl.empty() || pixelHlsl.empty()) + { + result.Diagnostics = "Material mesh shader template was not found."; + result.Material.reset(); + return result; + } + + // Unique name per compiled graph so instances don't clobber each other. + static std::atomic counter{ 0 }; + const std::string name = "GraphMat_" + std::to_string(counter.fetch_add(1)) + "_ParticleMesh"; + + const fs::path loadDir = s_GeneratedDir / name; + std::error_code error; + fs::create_directories(loadDir, error); + + const fs::path vertexSourcePath = s_GeneratedDir / (name + ".src.vs.hlsl"); + { + std::ofstream out(vertexSourcePath, std::ios::binary); + out << vertexHlsl; + } + + const fs::path pixelSourcePath = s_GeneratedDir / (name + ".src.ps.hlsl"); + { + std::ofstream out(pixelSourcePath, std::ios::binary); + + const auto graphHlsl = GenerateGraphHLSL(material.GetGraph(), *result.Material); + out << InjectBody(pixelHlsl, graphHlsl); + } + + // Compile the generated pixel shader to SPIR-V with DXC. + const fs::path dxc = FindDXC(); + const fs::path spvPath = loadDir / (name + ".ps.spirv"); + + const auto compileStage = [&dxc]( + const fs::path& sourcePath, + const fs::path& spvPath, + const std::string_view profile + ) + { + const std::string cmd = + "\"" + dxc.string() + "\" -spirv -T " + std::string(profile) + " -E main \"" + + sourcePath.string() + "\" -Fo \"" + spvPath.string() + "\""; + + return std::system(cmd.c_str()) == 0 && fs::exists(spvPath); + }; + + if (!compileStage( + vertexSourcePath, + loadDir / (name + ".vs.spirv"), + "vs_6_0" + ) || !compileStage( + pixelSourcePath, + loadDir / (name + ".ps.spirv"), + "ps_6_0" + )) + { + EE_CORE_ERROR( + "Particle mesh material: DXC compilation failed for {}.", + name + ) + result.Diagnostics = "DXC failed while compiling the particle mesh material."; + result.Material.reset(); + return result; + } + + result.Material->ParticleMeshShader = loader->LoadShader(loadDir, name); + if (!result.Material->ParticleMeshShader) + { + result.Diagnostics = "Shader loader could not load the particle mesh material."; + result.Material.reset(); + } + + return result; + } +} diff --git a/Elixir/Source/Engine/Materials/Compilation/Compiler.h b/Elixir/Source/Engine/Materials/Compilation/Compiler.h new file mode 100644 index 00000000..de11dbb7 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Compilation/Compiler.h @@ -0,0 +1,160 @@ +#pragma once + +#include +#include + +namespace Elixir::Materials::Compilation +{ + /** + * @brief Describes one material parameter in compiled GPU data. + */ + struct SCompiledParameter + { + /** @brief Name used by the material graph. */ + std::string Name; + + /** @brief Parameter storage category. */ + EMaterialParameterKind Kind = EMaterialParameterKind::Value; + + /** @brief Value type used when Kind is Value. */ + EMaterialValueType ValueType = EMaterialValueType::Float4; + + /** @brief Slot in the compiled value or texture array. */ + uint32_t Slot = 0; + }; + + /** + * @brief Stores the shader programs and parameter layout of a compiled material. + */ + struct SCompiledMaterial + { + /** @brief Source material revision used during compilation. */ + uint32_t MaterialRevision = 0; + + /** @brief Bit mask of supported material usages. */ + uint32_t UsageMask = 0; + + /** @brief Shader used by surface material rendering. */ + Ref SurfaceShader; + + /** @brief Shader used by particle sprite rendering. */ + Ref ParticleSpriteShader; + + /** @brief Shader used by particle ribbon rendering. */ + Ref ParticleRibbonShader; + + /** @brief Shader used by particle mesh rendering. */ + Ref ParticleMeshShader; + + /** @brief Parameter layout shared by the material graph and GPU data. */ + std::vector Parameters; + + /** + * @brief Checks whether the compiled material supports a usage. + * @param usage Material usage to check. + * @return True when the usage is present in UsageMask. + */ + bool SupportsUsage(const EMaterialUsage usage) const + { + return (UsageMask & GetMaterialUsageMask(usage)) != 0; + } + + /** + * @brief Gets the particle shader for a material usage. + * + * Surface rendering uses SurfaceShader directly. + * + * @param usage Particle material usage. + * @return The matching particle shader, or null for an unsupported usage. + */ + const Ref& GetShader(const EMaterialUsage usage) const + { + switch (usage) + { + case EMaterialUsage::ParticleSprite: + return ParticleSpriteShader; + case EMaterialUsage::ParticleRibbon: + return ParticleRibbonShader; + case EMaterialUsage::ParticleMesh: + return ParticleMeshShader; + default: + static const Ref unsupportedUsageShader; + return unsupportedUsageShader; + } + } + }; + + /** + * @brief Reports the outcome of material compilation. + */ + struct SCompileResult + { + /** @brief Compiled material data when compilation succeeds. */ + Ref Material; + + /** @brief Human-readable diagnostic text when compilation fails. */ + std::string Diagnostics; + + /** @brief Checks whether compilation succeeded. */ + explicit operator bool() const { return Material != nullptr; } + }; + + /** + * @brief Builds material parameter layouts and shader programs from material graphs. + * + * Compilation validates the graph, generates HLSL for supported usages, invokes + * DXC, and loads the resulting shader programs. + */ + class ELIXIR_API Compiler + { + public: + /** + * @brief Validates a material graph and builds its parameter layout. + * @param material Material to validate and lower. + * @return Compiled metadata, or diagnostics when validation fails. + * @note This phase does not invoke the shader compiler. + */ + static SCompileResult Build(const Material& material); + + /** + * @brief Compiles a material graph into render-ready shader programs. + * @param loader Shader loader used to load generated SPIR-V programs. + * @param material Material to compile. + * @return Compiled material data, or diagnostics when compilation fails. + * @pre loader is valid. + */ + static SCompileResult Compile(const ShaderLoader* loader, const Material& material); + + private: + /** @brief Replaces the graph-body marker in a shader template. */ + static std::string InjectBody(const std::string& hlsl, const std::string& graphBody); + + /** @brief Compiles the surface shader program. */ + static SCompileResult CompileSurface( + const ShaderLoader* loader, + const Material& material, + SCompileResult result + ); + + /** @brief Compiles the particle sprite shader program. */ + static SCompileResult CompileParticleSprite( + const ShaderLoader* loader, + const Material& material, + SCompileResult result + ); + + /** @brief Compiles the particle ribbon shader program. */ + static SCompileResult CompileParticleRibbon( + const ShaderLoader* loader, + const Material& material, + SCompileResult result + ); + + /** @brief Compiles the particle mesh shader program. */ + static SCompileResult CompileParticleMesh( + const ShaderLoader* loader, + const Material& material, + SCompileResult result + ); + }; +} diff --git a/Elixir/Source/Engine/Materials/DefaultMaterials.cpp b/Elixir/Source/Engine/Materials/DefaultMaterials.cpp new file mode 100644 index 00000000..699ea011 --- /dev/null +++ b/Elixir/Source/Engine/Materials/DefaultMaterials.cpp @@ -0,0 +1,88 @@ +#include "epch.h" +#include "DefaultMaterials.h" + +#include +#include +#include + +namespace Elixir::Materials +{ + using namespace Nodes; + + namespace + { + Ref MakeMaterial( + std::string name, + const EMaterialUsage usage, + MaterialGraph graph + ) + { + const auto material = CreateRef(std::move(name)); + const auto result = material->SetUsage(usage, true); + EE_CORE_ASSERT(result, "Default material usage must be enabled.") + material->SetGraph(std::move(graph)); + return material; + } + + Ref CreateDefaultSpriteMaterial() + { + MaterialGraph graph; + + const auto baseColor = graph.AddNode( + glm::vec4{ 1.0f, 1.0f, 1.0f, 0.0f }, + EMaterialValueType::Float3 + ); + graph.SetChannel(EMaterialChannel::BaseColor, baseColor); + + const auto opacity = graph.AddNode( + glm::vec2{ 0.5f, 0.5f }, + 0.5f, + 2.0f + ); + graph.SetChannel(EMaterialChannel::Opacity, opacity); + + return MakeMaterial( + "Engine.Materials.Defaults.ParticleSprite", + EMaterialUsage::ParticleSprite, + std::move(graph) + ); + } + + Ref CreateDefaultRibbonMaterial() + { + MaterialGraph graph; + + const auto checkerboard = graph.AddNode(8.0f); + graph.SetChannel(EMaterialChannel::BaseColor, checkerboard); + + return MakeMaterial( + "Engine.Materials.Defaults.ParticleRibbon", + EMaterialUsage::ParticleRibbon, + std::move(graph) + ); + } + + Ref CreateDefaultMeshMaterial() + { + MaterialGraph graph; + + const auto checkerboard = graph.AddNode(8.0f); + graph.SetChannel(EMaterialChannel::BaseColor, checkerboard); + + return MakeMaterial( + "Engine.Materials.Defaults.ParticleMesh", + EMaterialUsage::ParticleMesh, + std::move(graph) + ); + } + } + + DefaultMaterialArray CreateDefaultMaterials() + { + return { + CreateDefaultSpriteMaterial(), + CreateDefaultRibbonMaterial(), + CreateDefaultMeshMaterial() + }; + } +} diff --git a/Elixir/Source/Engine/Materials/DefaultMaterials.h b/Elixir/Source/Engine/Materials/DefaultMaterials.h new file mode 100644 index 00000000..aea4f20c --- /dev/null +++ b/Elixir/Source/Engine/Materials/DefaultMaterials.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +namespace Elixir::Materials +{ + /** + * @brief Number of built-in default material entries. + * + * Entries are indexed by EMaterialUsage. + */ + inline constexpr size_t DEFAULT_MATERIAL_COUNT = (size_t)EMaterialUsage::Count; + + /** + * @brief Stores one default material for each material usage. + * + * Entries are indexed by EMaterialUsage. + */ + using DefaultMaterialArray = std::array, DEFAULT_MATERIAL_COUNT>; + + /** + * @brief Creates the engine's built-in default materials. + * @return Default materials indexed by their material usage. + */ + ELIXIR_API DefaultMaterialArray CreateDefaultMaterials(); +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Material.cpp b/Elixir/Source/Engine/Materials/Material.cpp new file mode 100644 index 00000000..6fc5076a --- /dev/null +++ b/Elixir/Source/Engine/Materials/Material.cpp @@ -0,0 +1,128 @@ +#include "epch.h" +#include "Material.h" + +#include + +namespace Elixir::Materials +{ + Ref Material::CreateInstance() + { + return CreateRef(shared_from_this()); + } + + void Material::SetGraph(MaterialGraph graph) + { + m_Graph = std::move(graph); + ++m_Revision; + } + + bool Material::SetUsage(const EMaterialUsage usage, const bool enabled) + { + const uint32_t mask = GetMaterialUsageMask(usage); + const uint32_t updatedMask = enabled + ? m_UsageMask | mask + : m_UsageMask & ~mask; + + if (updatedMask == m_UsageMask) + return false; + + m_UsageMask = updatedMask; + ++m_Revision; + return true; + } + + bool Material::SupportsUsage(const EMaterialUsage usage) const + { + return (m_UsageMask & GetMaterialUsageMask(usage)) != 0; + } + + bool Material::SetDefaultParameter(const std::string& name, const SMaterialParameter& value) + { + const auto it = m_Parameters.find(name); + if (it == m_Parameters.end() || !IsValueCompatible(it->second, value)) + return false; + + it->second.DefaultValue = value; + ++m_Revision; + return true; + } + + const SMaterialParameter* Material::GetDefaultParameter(const std::string& name) const + { + const auto* parameter = FindParameter(name); + return parameter ? ¶meter->DefaultValue : nullptr; + } + + bool Material::DefineParameter( + std::string name, + const SMaterialParameterDefinition& definition + ) + { + if (name.empty() || !IsValueCompatible(definition, definition.DefaultValue)) + return false; + + const auto [_, inserted] = m_Parameters.emplace(std::move(name), definition); + if (inserted) ++m_Revision; + + return inserted; + } + + const SMaterialParameterDefinition* Material::FindParameter(const std::string& name) const + { + const auto it = m_Parameters.find(name); + return it != m_Parameters.end() ? &it->second : nullptr; + } + + bool Material::IsParameterValueCompatible( + const std::string& name, + const SMaterialParameter& value + ) const + { + const auto* parameter = FindParameter(name); + return parameter && IsValueCompatible(*parameter, value); + } + + bool Material::ValidateGraph(std::string* error) const + { + class ParameterLookup final : public MaterialNodeValidationContext + { + public: + explicit ParameterLookup(const Material& material) : m_Material(material) {} + + bool HasValueParameter( + const std::string_view name, + const EMaterialValueType type + ) const override + { + const auto* parameter = m_Material.FindParameter(std::string(name)); + return parameter && parameter->Kind == EMaterialParameterKind::Value && + parameter->ValueType == type; + } + + bool HasTextureParameter(const std::string_view name) const override + { + const auto* parameter = m_Material.FindParameter(std::string(name)); + return parameter && parameter->Kind == EMaterialParameterKind::Texture; + } + + private: + const Material& m_Material; + }; + + return m_Graph.Validate(ParameterLookup(*this), error); + } + + bool Material::IsValueCompatible( + const SMaterialParameterDefinition& definition, + const SMaterialParameter& value + ) + { + if (definition.Kind == EMaterialParameterKind::Texture) + return value.Type == EMaterialParameterType::Texture; + + if (definition.ValueType == EMaterialValueType::Float) + return value.Type == EMaterialParameterType::Scalar; + + return value.Type == EMaterialParameterType::Vector; + } +} diff --git a/Elixir/Source/Engine/Materials/Material.h b/Elixir/Source/Engine/Materials/Material.h new file mode 100644 index 00000000..8e25774f --- /dev/null +++ b/Elixir/Source/Engine/Materials/Material.h @@ -0,0 +1,192 @@ +#pragma once + +#include +#include + +namespace Elixir::Materials +{ + class MaterialInstance; + + /** + * @brief Identifies a shader permutation supported by a material. + * + * A usage selects a renderer-specific implementation without changing the + * material graph or its parameter schema. + */ + enum class EMaterialUsage : uint8_t + { + /** Material for particle sprites. */ + ParticleSprite = 0, + + /** Material for particle ribbons. */ + ParticleRibbon, + + /** Material for particle meshes. */ + ParticleMesh, + + /** Number of supported usages. */ + Count + }; + + /** + * @brief Defines the numeric value type used by a material. + */ + enum class EMaterialValueType : uint8_t + { + Float, Float2, Float3, Float4, + }; + + /** + * @brief Defines one parameter in a material schema. + */ + struct SMaterialParameterDefinition + { + /** Parameter category. */ + EMaterialParameterKind Kind = EMaterialParameterKind::Value; + + /** Expected type for value parameters. */ + EMaterialValueType ValueType = EMaterialValueType::Float4; + + /** Value used when an instance does not provide an override. */ + SMaterialParameter DefaultValue; + }; + + /** + * @brief Defines a material graph, parameter schema, and supported usages. + * + * Instances created from a material inherit its parameter definitions and + * default values. Changing the graph, schema, or usages increments the + * material revision. + */ + class ELIXIR_API Material : public std::enable_shared_from_this + { + public: + /** + * @brief Creates a material with a name. + * @param name Material name. + */ + explicit Material(std::string name) : m_Name(std::move(name)) {} + + /** + * @brief Creates an instance of this material. + * @return A material instance initialized from this material. + * @pre This material is owned by a Ref. + */ + Ref CreateInstance(); + + /** + * @brief Replaces the material graph. + * @param graph Graph to store. + */ + void SetGraph(MaterialGraph graph); + + /** + * @brief Returns the material graph. + * @return Read-only material graph. + */ + const MaterialGraph& GetGraph() const { return m_Graph; } + + /** + * @brief Enables or disables a material usage. + * @param usage Usage to update. + * @param enabled Whether the usage is supported. + * @return `true` if the supported-usage set changed. + */ + bool SetUsage(EMaterialUsage usage, bool enabled); + + /** + * @brief Checks whether a material usage is supported. + * @param usage Usage to check. + * @return `true` if the usage is enabled. + */ + bool SupportsUsage(EMaterialUsage usage) const; + + /** + * @brief Updates a parameter default value. + * @param name Parameter name. + * @param value Compatible value to store. + * @return `true` if the parameter exists and accepts @p value. + */ + bool SetDefaultParameter(const std::string& name, const SMaterialParameter& value); + + /** + * @brief Finds a parameter default value. + * @param name Parameter name. + * @return Default value, or null if the parameter does not exist. + */ + const SMaterialParameter* GetDefaultParameter(const std::string& name) const; + + /** + * @brief Adds a parameter to the material schema. + * @param name Unique parameter name. + * @param definition Parameter definition. + * @return `true` if the parameter was added. + * @pre @p name is not empty. + * @pre `definition.DefaultValue` matches @p definition. + */ + bool DefineParameter( + std::string name, + const SMaterialParameterDefinition& definition + ); + + /** + * @brief Finds a parameter definition. + * @param name Parameter name. + * @return Definition, or null if the parameter does not exist. + */ + const SMaterialParameterDefinition* FindParameter(const std::string& name) const; + + /** + * @brief Checks whether a value matches a named parameter. + * @param name Parameter name. + * @param value Value to check. + * @return `true` if the parameter exists and accepts @p value. + */ + bool IsParameterValueCompatible( + const std::string& name, + const SMaterialParameter& value + ) const; + + /** + * @brief Validates parameter references in the material graph. + * @param[out] error Optional destination for a validation error message. + * @return `true` if all graph parameter references are valid. + */ + bool ValidateGraph(std::string* error = nullptr) const; + + /** @brief Returns the material name. */ + const std::string& GetName() const { return m_Name; } + + /** @brief Returns the parameter schema. */ + const auto& GetParameters() const { return m_Parameters; } + + /** @brief Returns the bit mask of supported usages. */ + uint32_t GetUsageMask() const { return m_UsageMask; } + + /** @brief Returns the current material revision. */ + uint32_t GetRevision() const { return m_Revision; } + + private: + /** Checks whether a value matches a parameter definition. */ + static bool IsValueCompatible( + const SMaterialParameterDefinition& definition, + const SMaterialParameter& value + ); + + std::string m_Name; + MaterialGraph m_Graph; + std::unordered_map m_Parameters; + uint32_t m_UsageMask = 0; + uint32_t m_Revision = 1; + }; + + /** + * @brief Returns the bit mask for one material usage. + * @param usage Material usage. + * @return Bit mask that represents @p usage. + */ + constexpr uint32_t GetMaterialUsageMask(const EMaterialUsage usage) + { + return 1u << static_cast(usage); + } +} diff --git a/Elixir/Source/Engine/Materials/MaterialGraph.cpp b/Elixir/Source/Engine/Materials/MaterialGraph.cpp new file mode 100644 index 00000000..5ac852a8 --- /dev/null +++ b/Elixir/Source/Engine/Materials/MaterialGraph.cpp @@ -0,0 +1,184 @@ +#include "epch.h" +#include "MaterialGraph.h" + +#include + +namespace Elixir::Materials +{ + namespace + { + const char* ChannelName(const EMaterialChannel channel) + { + switch (channel) + { + case EMaterialChannel::BaseColor: return "BaseColor"; + case EMaterialChannel::Normal: return "Normal"; + case EMaterialChannel::Metallic: return "Metallic"; + case EMaterialChannel::Roughness: return "Roughness"; + case EMaterialChannel::Opacity: return "Opacity"; + case EMaterialChannel::Emissive: return "Emissive"; + } + + return "BaseColor"; + } + + std::string CoerceForChannel( + const SMaterialExpression& expression, + const EMaterialChannel channel + ) + { + const bool isScalar = channel == EMaterialChannel::Metallic || + channel == EMaterialChannel::Roughness || + channel == EMaterialChannel::Opacity; + + if (isScalar) + return expression.ValueType == EMaterialValueType::Float + ? expression.Code + : "(" + expression.Code + ").x"; + + switch (expression.ValueType) + { + case EMaterialValueType::Float: return expression.Code + ".xxx"; + case EMaterialValueType::Float2: return "float3(" + expression.Code + ", 0.0)"; + case EMaterialValueType::Float3: return expression.Code; + case EMaterialValueType::Float4: return "(" + expression.Code + ").rgb"; + } + return expression.Code; + } + } + + uint32_t MaterialGraph::AddNode(Scope node) + { + if (!node) return 0; + + const uint32_t id = m_NextId++; + m_Nodes.emplace(id, SGraphNode{ .Node = std::move(node) }); + + return id; + } + + void MaterialGraph::Connect( + const uint32_t fromNode, + const uint32_t toNode, + const uint32_t toSlot + ) + { + const auto it = m_Nodes.find(toNode); + + if (it == m_Nodes.end() || toSlot >= it->second.Node->GetInputs().size()) + return; + + if (it->second.Inputs.size() <= toSlot) + it->second.Inputs.resize(toSlot + 1, -1); + + it->second.Inputs[toSlot] = (int32_t)fromNode; + } + + void MaterialGraph::SetChannel(const EMaterialChannel channel, const uint32_t nodeId) + { + m_Channels[channel] = nodeId; + } + + bool MaterialGraph::Validate( + const MaterialNodeValidationContext& parameters, + std::string* error + ) const + { + for (const auto& [id, graphNode] : m_Nodes) + { + std::string nodeError; + + if (graphNode.Node->Validate(parameters, nodeError)) + continue; + + if (error) + *error = std::string(graphNode.Node->GetTypeName()) + " node " + + std::to_string(id) + ": " + nodeError; + + return false; + } + + return true; + } + + std::string MaterialGraph::GenerateHLSL() const + { + return GenerateHLSL({}); + } + + std::string MaterialGraph::GenerateHLSL(const SMaterialGraphBindings& bindings) const + { + std::string body; + std::unordered_map emitted; + std::unordered_set visiting; + + for (const auto& [channel, nodeId] : m_Channels) + { + const auto expression = EmitNode(nodeId, emitted, visiting, body, &bindings); + body += " surface." + std::string(ChannelName(channel)) + " = " + + CoerceForChannel(expression, channel) + ";\n"; + } + + return body; + } + + const MaterialNode* MaterialGraph::FindNode(const uint32_t id) const + { + const auto it = m_Nodes.find(id); + return it == m_Nodes.end() ? nullptr : it->second.Node.get(); + } + + SMaterialExpression MaterialGraph::EmitNode( + const uint32_t id, + std::unordered_map& emitted, + std::unordered_set& visiting, + std::string& body, + const SMaterialGraphBindings* bindings + ) const + { + if (const auto it = emitted.find(id); it != emitted.end()) + return it->second; + + const auto it = m_Nodes.find(id); + if (it == m_Nodes.end() || visiting.contains(id)) + return { .Code = "0.0", .ValueType = EMaterialValueType::Float }; + + visiting.insert(id); + const auto& node = it->second; + const auto& definitions = node.Node->GetInputs(); + std::vector inputs; + inputs.reserve(definitions.size()); + + for (size_t slot = 0; slot < definitions.size(); ++slot) + { + const int32_t source = slot < node.Inputs.size() ? node.Inputs[slot] : -1; + + if (source >= 0) + inputs.push_back(EmitNode( + (uint32_t)source, + emitted, + visiting, + body, + bindings + )); + else + inputs.push_back({ + .Code = definitions[slot].DefaultExpression, + .ValueType = definitions[slot].DefaultValueType + }); + } + + const MaterialEmitContext context(inputs, bindings); + SMaterialExpression expression = node.Node->Emit(context); + + const std::string variable = "n" + std::to_string(id); + body += " " + std::string(MaterialEmitContext::TypeName(expression.ValueType)) + + " " + variable + " = " + expression.Code + ";\n"; + expression.Code = variable; + + visiting.erase(id); + emitted.emplace(id, expression); + + return expression; + } +} diff --git a/Elixir/Source/Engine/Materials/MaterialGraph.h b/Elixir/Source/Engine/Materials/MaterialGraph.h new file mode 100644 index 00000000..585bfb68 --- /dev/null +++ b/Elixir/Source/Engine/Materials/MaterialGraph.h @@ -0,0 +1,118 @@ +#pragma once + +#include + +namespace Elixir::Materials +{ + /** + * @brief Defines a surface property driven by a material graph. + */ + enum class EMaterialChannel : uint8_t + { + /** @brief Surface base color. */ + BaseColor, + + /** @brief Surface normal. */ + Normal, + + /** @brief Surface metallic value. */ + Metallic, + + /** @brief Surface roughness value. */ + Roughness, + + /** @brief Surface opacity value. */ + Opacity, + + /** @brief Surface emissive color. */ + Emissive, + }; + + /** @brief Maps material parameter names to generated HLSL expressions. */ + struct SMaterialGraphBindings + { + std::unordered_map Values; + std::unordered_map Textures; + }; + + /** + * @brief Stores a node graph that defines material surface properties. + * + * The graph owns node instances and their connections. Each node defines its + * own inputs, validation rules, and HLSL emission behavior. + */ + class ELIXIR_API MaterialGraph + { + public: + MaterialGraph() = default; + + MaterialGraph(const MaterialGraph&) = delete; + MaterialGraph& operator=(const MaterialGraph&) = delete; + + MaterialGraph(MaterialGraph&&) noexcept = default; + MaterialGraph& operator=(MaterialGraph&&) noexcept = default; + + template + uint32_t AddNode(TArgs&&... args) + { + static_assert(std::derived_from); + return AddNode(CreateScope(std::forward(args)...)); + } + + uint32_t AddNode(Scope node); + + /** + * @brief Connects a node output to an input slot. + * @param fromNode Source node ID. + * @param toNode Destination node ID. + * @param toSlot Destination input slot. + * + * The call has no effect when the destination node or input slot does not exist. + */ + void Connect(uint32_t fromNode, uint32_t toNode, uint32_t toSlot); + + /** + * @brief Connects a node output to a surface channel. + * @param channel Surface channel to drive. + * @param nodeId Node that provides the channel value. + */ + void SetChannel(EMaterialChannel channel, uint32_t nodeId); + + bool Validate( + const MaterialNodeValidationContext& parameters, + std::string* error = nullptr + ) const; + + /** @brief Generates HLSL statements for the configured surface channels. */ + std::string GenerateHLSL() const; + + /** + * @brief Generates HLSL statements for the configured surface channels. + * @param bindings Expressions that replace named material parameters. + * @return Generated HLSL that uses the supplied parameter bindings. + */ + std::string GenerateHLSL(const SMaterialGraphBindings& bindings) const; + + const MaterialNode* FindNode(uint32_t id) const; + + private: + /** @brief Emits HLSL for a node and its dependencies. */ + SMaterialExpression EmitNode( + uint32_t id, + std::unordered_map& emitted, + std::unordered_set& visiting, + std::string& body, + const SMaterialGraphBindings* bindings + ) const; + + struct SGraphNode + { + Scope Node; + std::vector Inputs; + }; + + std::unordered_map m_Nodes; + std::unordered_map m_Channels; + uint32_t m_NextId = 1; + }; +} diff --git a/Elixir/Source/Engine/Materials/MaterialInstance.cpp b/Elixir/Source/Engine/Materials/MaterialInstance.cpp new file mode 100644 index 00000000..630f32cf --- /dev/null +++ b/Elixir/Source/Engine/Materials/MaterialInstance.cpp @@ -0,0 +1,67 @@ +#include "epch.h" +#include "MaterialInstance.h" + +namespace Elixir::Materials +{ + bool MaterialInstance::SetScalar(const std::string& name, const float value) + { + return SetOverride(name, SMaterialParameter::MakeScalar(value)); + } + + float MaterialInstance::GetScalar(const std::string& name) const + { + const auto* param = Resolve(name); + return param ? param->Scalar : 0.0f; + } + + bool MaterialInstance::SetVector(const std::string& name, const glm::vec4& value) + { + return SetOverride(name, SMaterialParameter::MakeVector(value)); + } + + glm::vec4 MaterialInstance::GetVector(const std::string& name) const + { + const auto* param = Resolve(name); + return param ? param->Vector : glm::vec4(0.0f); + } + + bool MaterialInstance::SetTexture(const std::string& name, const Ref& texture) + { + return SetOverride(name, SMaterialParameter::MakeTexture(texture)); + } + + Ref MaterialInstance::GetTexture(const std::string& name) const + { + const auto* param = Resolve(name); + return param ? param->Texture : nullptr; + } + + const SMaterialParameter* MaterialInstance::GetResolvedParameter( + const std::string& name + ) const + { + return Resolve(name); + } + + bool MaterialInstance::SetOverride( + const std::string& name, + const SMaterialParameter& value + ) + { + if (!m_Parent || !m_Parent->IsParameterValueCompatible(name, value)) + return false; + + m_Overrides[name] = value; + ++m_Revision; + + return true; + } + + const SMaterialParameter* MaterialInstance::Resolve(const std::string& name) const + { + const auto it = m_Overrides.find(name); + if (it != m_Overrides.end()) + return &it->second; + return m_Parent ? m_Parent->GetDefaultParameter(name) : nullptr; + } +} diff --git a/Elixir/Source/Engine/Materials/MaterialInstance.h b/Elixir/Source/Engine/Materials/MaterialInstance.h new file mode 100644 index 00000000..6a2910a4 --- /dev/null +++ b/Elixir/Source/Engine/Materials/MaterialInstance.h @@ -0,0 +1,94 @@ +#pragma once + +#include + +namespace Elixir::Materials +{ + /** + * @brief Stores parameter overrides for one material. + * + * An instance uses the parent material's defaults unless it contains an + * override for the requested parameter. + */ + class ELIXIR_API MaterialInstance + { + public: + /** + * @brief Creates an instance for a parent material. + * @param parent Material that defines the parameter schema and defaults. + */ + explicit MaterialInstance(const Ref& parent) : m_Parent(parent) {} + + /** + * @brief Sets a scalar parameter override. + * @param name Parameter name. + * @param value Scalar value to store. + * @return `true` if the parent defines a compatible parameter. + */ + bool SetScalar(const std::string& name, float value); + + /** + * @brief Returns a resolved scalar parameter value. + * @param name Parameter name. + * @return Resolved scalar value, or `0.0f` if the parameter is unavailable. + */ + float GetScalar(const std::string& name) const; + + /** + * @brief Sets a vector parameter override. + * @param name Parameter name. + * @param value Vector value to store. + * @return `true` if the parent defines a compatible parameter. + */ + bool SetVector(const std::string& name, const glm::vec4& value); + + /** + * @brief Returns a resolved vector parameter value. + * @param name Parameter name. + * @return Resolved vector value, or a zero vector if the parameter is unavailable. + */ + glm::vec4 GetVector(const std::string& name) const; + + /** + * @brief Sets a texture parameter override. + * @param name Parameter name. + * @param texture Texture to store. + * @return `true` if the parent defines a compatible parameter. + */ + bool SetTexture(const std::string& name, const Ref& texture); + + /** + * @brief Returns a resolved texture parameter value. + * @param name Parameter name. + * @return Resolved texture, or null if the parameter is unavailable. + */ + Ref GetTexture(const std::string& name) const; + + /** + * @brief Returns the effective value of a parameter. + * + * Overrides take precedence over the parent material's default. + * + * @param name Parameter name. + * @return Resolved parameter, or null if it is unavailable. + */ + const SMaterialParameter* GetResolvedParameter(const std::string& name) const; + + /** @brief Returns the parent material. */ + const Ref& GetParent() const { return m_Parent; } + + /** @brief Returns the revision of this instance. */ + uint32_t GetRevision() const { return m_Revision; } + + private: + // Stores a compatible parameter override. + bool SetOverride(const std::string& name, const SMaterialParameter& value); + + // Finds an override or the parent material's default value. + const SMaterialParameter* Resolve(const std::string& name) const; + + Ref m_Parent; + std::unordered_map m_Overrides; + uint32_t m_Revision = 1; + }; +} diff --git a/Elixir/Source/Engine/Materials/MaterialNode.cpp b/Elixir/Source/Engine/Materials/MaterialNode.cpp new file mode 100644 index 00000000..41001e43 --- /dev/null +++ b/Elixir/Source/Engine/Materials/MaterialNode.cpp @@ -0,0 +1,112 @@ +#include "epch.h" +#include "MaterialNode.h" + +#include +#include + +namespace Elixir::Materials +{ + namespace + { + const SMaterialExpression s_ZeroExpression{ + .Code = "0.0", + .ValueType = EMaterialValueType::Float + }; + } + + const SMaterialExpression& MaterialEmitContext::Input(const uint32_t slot) const + { + return slot < m_Inputs.size() ? m_Inputs[slot] : s_ZeroExpression; + } + + std::string MaterialEmitContext::ValueParameter(const std::string& name) const + { + if (m_Bindings && m_Bindings->Values.contains(name)) + return m_Bindings->Values.at(name); + + return "mat." + name; + } + + std::string MaterialEmitContext::TextureParameter(const std::string& name) const + { + if (m_Bindings && m_Bindings->Textures.contains(name)) + return m_Bindings->Textures.at(name); + + return "mat." + name + ".x"; + } + + std::string MaterialEmitContext::Widen( + const SMaterialExpression& expression, + const EMaterialValueType type + ) + { + if (expression.ValueType == type) return expression.Code; + + if (expression.ValueType == EMaterialValueType::Float) + { + const char* swizzle = type == EMaterialValueType::Float2 + ? ".xx" + : type == EMaterialValueType::Float3 ? ".xxx" : ".xxxx"; + return "(" + expression.Code + ")" + swizzle; + } + + if (expression.ValueType == EMaterialValueType::Float2 && + type == EMaterialValueType::Float3) + return "float3(" + expression.Code + ", 0.0)"; + + if (expression.ValueType == EMaterialValueType::Float2 && + type == EMaterialValueType::Float4) + return "float4(" + expression.Code + ", 0.0, 0.0)"; + + if (expression.ValueType == EMaterialValueType::Float3 && + type == EMaterialValueType::Float4) + return "float4(" + expression.Code + ", 1.0)"; + + if (type == EMaterialValueType::Float) return "(" + expression.Code + ").x"; + if (type == EMaterialValueType::Float2) return "(" + expression.Code + ").xy"; + if (type == EMaterialValueType::Float3) return "(" + expression.Code + ").xyz"; + + return expression.Code; + } + + const char* MaterialEmitContext::TypeName(const EMaterialValueType type) + { + switch (type) + { + case EMaterialValueType::Float: return "float"; + case EMaterialValueType::Float2: return "float2"; + case EMaterialValueType::Float3: return "float3"; + case EMaterialValueType::Float4: return "float4"; + } + + return "float4"; + } + + int MaterialEmitContext::Components(const EMaterialValueType type) + { + switch (type) + { + case EMaterialValueType::Float: return 1; + case EMaterialValueType::Float2: return 2; + case EMaterialValueType::Float3: return 3; + case EMaterialValueType::Float4: return 4; + } + + return 4; + } + + EMaterialValueType MaterialEmitContext::Wider( + const EMaterialValueType left, + const EMaterialValueType right + ) + { + return Components(left) >= Components(right) ? left : right; + } + + MaterialEmitContext::MaterialEmitContext( + const std::vector& inputs, + const SMaterialGraphBindings* bindings + ) : m_Inputs(inputs), + m_Bindings(bindings) { + } +} diff --git a/Elixir/Source/Engine/Materials/MaterialNode.h b/Elixir/Source/Engine/Materials/MaterialNode.h new file mode 100644 index 00000000..2e237f33 --- /dev/null +++ b/Elixir/Source/Engine/Materials/MaterialNode.h @@ -0,0 +1,134 @@ +#pragma once + +#include +#include + +namespace Elixir::Materials +{ + struct SMaterialGraphBindings; + enum class EMaterialValueType : uint8_t; + + /** + * @brief Describes one input accepted by a material node. + */ + struct SMaterialNodeInput + { + /** @brief User-facing input name. */ + std::string_view Name; + + /** @brief Type expected by the node. */ + EMaterialValueType ValueType; + + /** @brief HLSL expression used when the input is not connected. */ + std::string DefaultExpression; + + /** @brief Type produced by the default expression. */ + EMaterialValueType DefaultValueType; + }; + + /** + * @brief Stores an HLSL expression and its material value type. + */ + struct SMaterialExpression + { + std::string Code; + EMaterialValueType ValueType; + }; + + /** + * @brief Lets a node verify references to the material parameter schema. + */ + class MaterialNodeValidationContext + { + public: + /** @brief Destroys the validation context. */ + virtual ~MaterialNodeValidationContext() = default; + + /** + * @brief Checks whether a value parameter has the requested type. + * @param name The parameter name. + * @param type The parameter type. + * @return True if the value parameter exists for the requested type. + */ + virtual bool HasValueParameter(std::string_view name, EMaterialValueType type) const = 0; + + /** + * @brief Checks whether a texture parameter exists. + * @param name The parameter name. + * @return True if the texture parameter exists. + */ + virtual bool HasTextureParameter(std::string_view name) const = 0; + }; + + /** + * @brief Gives a node access to resolved inputs and material parameter bindings. + */ + class ELIXIR_API MaterialEmitContext + { + friend class MaterialGraph; + + public: + /** @brief Gets a resolved input expression. */ + const SMaterialExpression& Input(uint32_t slot) const; + + /** @brief Gets the HLSL expression bound to a value parameter. */ + std::string ValueParameter(const std::string& name) const; + + /** @brief Gets the HLSL expression bound to a texture parameter. */ + std::string TextureParameter(const std::string& name) const; + + /** @brief Converts an expression to a requested value type. */ + static std::string Widen(const SMaterialExpression& expression, EMaterialValueType type); + + /** @brief Gets the HLSL type name for a material value type. */ + static const char* TypeName(EMaterialValueType type); + + /** @brief Gets the component count of a material value type. */ + static int Components(EMaterialValueType type); + + /** @brief Gets the wider of two material value types. */ + static EMaterialValueType Wider(EMaterialValueType left, EMaterialValueType right); + + private: + MaterialEmitContext( + const std::vector& inputs, + const SMaterialGraphBindings* bindings + ); + + const std::vector& m_Inputs; + const SMaterialGraphBindings* m_Bindings; + }; + + /** + * @brief Defines one operation that can be placed in a material graph. + */ + class MaterialNode + { + public: + virtual ~MaterialNode() = default; + + /** @brief Gets the stable identifier used by tools and serialization. */ + virtual std::string_view GetTypeName() const = 0; + + /** @brief Validates node-specific references against material parameters. */ + virtual bool Validate( + const MaterialNodeValidationContext& parameters, + std::string& error + ) const + { + return true; + } + + /** @brief Emits the HLSL expression represented by this node. */ + virtual SMaterialExpression Emit(const MaterialEmitContext& context) const = 0; + + /** @brief Gets the input slots defined by the node. */ + const std::vector& GetInputs() const { return m_Inputs; } + + protected: + explicit MaterialNode(std::vector inputs = {}) + : m_Inputs(std::move(inputs)) {} + + std::vector m_Inputs; + }; +} diff --git a/Elixir/Source/Engine/Materials/MaterialParameter.h b/Elixir/Source/Engine/Materials/MaterialParameter.h new file mode 100644 index 00000000..bd80b323 --- /dev/null +++ b/Elixir/Source/Engine/Materials/MaterialParameter.h @@ -0,0 +1,90 @@ +#pragma once + +namespace Elixir::Materials +{ + /** + * @brief Identifies the category of a material parameter. + */ + enum class EMaterialParameterKind : uint8_t + { + /** A scalar or vector parameter. */ + Value, + + /** A texture parameter. */ + Texture + }; + + /** + * @brief Identifies the value stored by a material parameter. + */ + enum class EMaterialParameterType : uint8_t + { + /** A single floating-point value. */ + Scalar, + + /** A four-component floating-point value. */ + Vector, + + /** A texture reference. */ + Texture + }; + + /** + * @brief Stores one material parameter value. + * + * @ref Type identifies which value member is active. + */ + struct SMaterialParameter + { + /** Type of the active value. */ + EMaterialParameterType Type = EMaterialParameterType::Scalar; + + /** Scalar value when @ref Type is `Scalar`. */ + float Scalar = 0.0f; + + /** Vector value when @ref Type is `Vector`. */ + glm::vec4 Vector{ 0.0f }; + + /** Texture value when @ref Type is `Texture`. */ + Ref Texture; + + /** + * @brief Creates a scalar material parameter. + * @param value Scalar value to store. + * @return A parameter whose type is `Scalar`. + */ + static SMaterialParameter MakeScalar(const float value) + { + SMaterialParameter param; + param.Type = EMaterialParameterType::Scalar; + param.Scalar = value; + return param; + } + + /** + * @brief Creates a vector material parameter. + * @param value Vector value to store. + * @return A parameter whose type is `Vector`. + */ + static SMaterialParameter MakeVector(const glm::vec4& value) + { + SMaterialParameter param; + param.Type = EMaterialParameterType::Vector; + param.Vector = value; + return param; + } + + /** + * @brief Creates a texture material parameter. + * @param texture Texture to store. + * @return A parameter whose type is `Texture`. + */ + static SMaterialParameter MakeTexture(const Ref& texture) + { + SMaterialParameter param; + param.Type = EMaterialParameterType::Texture; + param.Texture = texture; + return param; + } + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/MaterialProxyCache.cpp b/Elixir/Source/Engine/Materials/MaterialProxyCache.cpp new file mode 100644 index 00000000..6d40235c --- /dev/null +++ b/Elixir/Source/Engine/Materials/MaterialProxyCache.cpp @@ -0,0 +1,53 @@ +#include "epch.h" +#include "MaterialProxyCache.h" + +namespace Elixir::Materials +{ + MaterialProxyCache::MaterialProxyCache(MaterialResolver& resolver) + : m_Resolver(resolver) {} + + Ref MaterialProxyCache::Resolve( + const Ref& instance + ) + { + if (!instance || !instance->GetParent()) + return nullptr; + + const auto parent = instance->GetParent(); + const auto found = m_Entries.find(instance.get()); + + if (found != m_Entries.end()) + { + const auto cachedInstance = found->second.Instance.lock(); + const auto matches = + cachedInstance.get() == instance.get() && + found->second.Parent == parent.get() && + found->second.InstanceRevision == instance->GetRevision() && + found->second.MaterialRevision == parent->GetRevision(); + + if (matches) + return found->second.Proxy; + + m_Entries.erase(found); + } + + const auto proxy = m_Resolver.Resolve(instance); + m_Entries.emplace(instance.get(), SEntry{ + .Instance = instance, + .Proxy = proxy, + .Parent = parent.get(), + .InstanceRevision = instance->GetRevision(), + .MaterialRevision = parent->GetRevision(), + }); + + return proxy; + } + + void MaterialProxyCache::PruneExpired() + { + std::erase_if(m_Entries, [](const auto& entry) + { + return entry.second.Instance.expired(); + }); + } +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/MaterialProxyCache.h b/Elixir/Source/Engine/Materials/MaterialProxyCache.h new file mode 100644 index 00000000..1801a978 --- /dev/null +++ b/Elixir/Source/Engine/Materials/MaterialProxyCache.h @@ -0,0 +1,49 @@ +#pragma once + +#include + +namespace Elixir::Materials +{ + using namespace Rendering; + + /** + * @brief Reuses render proxies while their source instances remain current. + * + * Entries are invalidated when the instance, its parent material, or either + * revision changes. + */ + class ELIXIR_API MaterialProxyCache final : public MaterialResolver + { + public: + /** + * @brief Creates a cache that resolves misses through another resolver. + * @param resolver Resolver used when no current proxy is cached. + */ + explicit MaterialProxyCache(MaterialResolver& resolver); + + /** + * @brief Resolves an instance from the cache or through the resolver. + * @param instance Material instance to resolve. + * @return Immutable render proxy, or null when resolution fails. + */ + Ref Resolve( + const Ref& instance + ) override; + + /** @brief Removes entries whose source instances no longer exist. */ + void PruneExpired(); + + private: + struct SEntry + { + WeakRef Instance; + Ref Proxy; + const Material* Parent = nullptr; + uint32_t InstanceRevision = 0; + uint32_t MaterialRevision = 0; + }; + + MaterialResolver& m_Resolver; + std::unordered_map m_Entries; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/MaterialProxyResolver.cpp b/Elixir/Source/Engine/Materials/MaterialProxyResolver.cpp new file mode 100644 index 00000000..9ba6fbcc --- /dev/null +++ b/Elixir/Source/Engine/Materials/MaterialProxyResolver.cpp @@ -0,0 +1,21 @@ +#include "epch.h" +#include "MaterialProxyResolver.h" + +namespace Elixir::Materials +{ + MaterialProxyResolver::MaterialProxyResolver(const ShaderLoader* shaderLoader) + : m_CompilationCache(shaderLoader) {} + + Ref MaterialProxyResolver::Resolve( + const Ref& instance + ) + { + if (!instance || !instance->GetParent()) + return nullptr; + + const auto compiled = m_CompilationCache.GetOrCompile(instance->GetParent()); + return compiled + ? MaterialRenderProxy::Create(compiled, *instance) + : nullptr; + } +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/MaterialProxyResolver.h b/Elixir/Source/Engine/Materials/MaterialProxyResolver.h new file mode 100644 index 00000000..235b7c42 --- /dev/null +++ b/Elixir/Source/Engine/Materials/MaterialProxyResolver.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +namespace Elixir { class ShaderLoader; } + +namespace Elixir::Materials +{ + using namespace Compilation; + using namespace Rendering; + + /** + * @brief Resolves material instances into immutable render proxies. + * + * The resolver owns compiled-material reuse. It does not cache proxies for + * individual instances. + */ + class ELIXIR_API MaterialProxyResolver final : public MaterialResolver + { + public: + /** + * @brief Creates a resolver for one shader loader. + * @param shaderLoader Loader used to compile material shaders. + */ + explicit MaterialProxyResolver(const ShaderLoader* shaderLoader); + + /** + * @brief Resolves an instance into a render proxy. + * @param instance Material instance to resolve. + * @return Immutable render proxy, or null when resolution fails. + */ + Ref Resolve( + const Ref& instance + ) override; + + private: + CompilationCache m_CompilationCache; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/MaterialRegistry.cpp b/Elixir/Source/Engine/Materials/MaterialRegistry.cpp new file mode 100644 index 00000000..aec7154c --- /dev/null +++ b/Elixir/Source/Engine/Materials/MaterialRegistry.cpp @@ -0,0 +1,41 @@ +#include "epch.h" +#include "MaterialRegistry.h" + +#include + +namespace Elixir::Materials +{ + MaterialRegistry::MaterialRegistry() + : m_Defaults(CreateDefaultMaterials()) + { + for (const auto& material : m_Defaults) + { + const auto registered = Register(material); + EE_CORE_ASSERT(registered, "Default material names must be unique.") + } + } + + bool MaterialRegistry::Register(const Ref& material) + { + if (!material || material->GetName().empty()) return false; + return m_Materials.emplace(material->GetName(), material).second; + } + + Ref MaterialRegistry::Find(const std::string_view name) const + { + const auto found = m_Materials.find(std::string(name)); + return found != m_Materials.end() ? found->second : nullptr; + } + + const Ref& MaterialRegistry::GetDefault(const EMaterialUsage usage) const + { + return m_Defaults[GetDefaultSlot(usage)]; + } + + size_t MaterialRegistry::GetDefaultSlot(EMaterialUsage usage) + { + const auto slot = (size_t)usage; + EE_CORE_ASSERT(slot < DEFAULT_MATERIAL_COUNT, "Unsupported default material usage.") + return slot; + } +} diff --git a/Elixir/Source/Engine/Materials/MaterialRegistry.h b/Elixir/Source/Engine/Materials/MaterialRegistry.h new file mode 100644 index 00000000..c19d0648 --- /dev/null +++ b/Elixir/Source/Engine/Materials/MaterialRegistry.h @@ -0,0 +1,45 @@ +#pragma once + +#include + +namespace Elixir::Materials +{ + + /** + * @brief Stores application-owned material assets and default materials. + */ + class ELIXIR_API MaterialRegistry final + { + public: + /** @brief Creates the registry and registers all default materials. */ + MaterialRegistry(); + + /** + * @brief Registers a material by its name. + * @param material Material to register. + * @return True when the material has a valid, unique name and was registered. + */ + bool Register(const Ref& material); + + /** + * @brief Finds a registered material by name. + * @param name Material name. + * @return The registered material, or null when no material has that name. + */ + Ref Find(std::string_view name) const; + + /** + * @brief Gets the default material for a usage category. + * @param usage Required material usage. + * @return The default material for the requested usage. + */ + const Ref& GetDefault(EMaterialUsage usage) const; + + private: + /** @brief Converts a material usage value into a default-material array slot. */ + static size_t GetDefaultSlot(EMaterialUsage usage); + + DefaultMaterialArray m_Defaults; + std::unordered_map> m_Materials; + }; +} diff --git a/Elixir/Source/Engine/Materials/MaterialSystem.cpp b/Elixir/Source/Engine/Materials/MaterialSystem.cpp new file mode 100644 index 00000000..be86e744 --- /dev/null +++ b/Elixir/Source/Engine/Materials/MaterialSystem.cpp @@ -0,0 +1,71 @@ +#include "epch.h" +#include "MaterialSystem.h" + +namespace Elixir::Materials +{ + MaterialSystem::MaterialSystem( + const GraphicsContext* context, + const ShaderLoader* shaderLoader, + const SMaterialSystemConfig config + ) : m_ProxyResolver(shaderLoader), + m_ProxyCache(m_ProxyResolver), + m_Renderer(CreateScope(context, config.InitialFrameCapacity)) {} + + void MaterialSystem::BeginFrame() + { + m_SubmittedScenes.clear(); + m_ProxyCache.PruneExpired(); + m_Renderer->BeginFrame(); + m_IsCollectingFrame = true; + } + + void MaterialSystem::Submit(MaterialRenderScene scene) + { + EE_CORE_ASSERT( + m_IsCollectingFrame, + "Material scenes must be submitted after BeginFrame." + ) + m_SubmittedScenes.push_back(std::move(scene)); + } + + SRenderResult MaterialSystem::RenderFrame() + { + EE_CORE_ASSERT(m_IsCollectingFrame, "Material rendering requires BeginFrame.") + + std::vector scenes; + scenes.reserve(m_SubmittedScenes.size()); + + for (const auto& scene : m_SubmittedScenes) + scenes.push_back(PrepareScene(scene)); + + m_IsCollectingFrame = false; + return m_Renderer->RenderFrame(scenes); + } + + SPreparedScene MaterialSystem::PrepareScene(const MaterialRenderScene& scene) + { + SPreparedScene prepared{ + .Scene = &scene, + }; + + for (const auto& item : scene.GetItems()) + { + const auto& proxy = ResolveMaterialProxy(item.Material); + if (!proxy) continue; + + prepared.Items.push_back({ + .Item = &item, + .Proxy = proxy, + }); + } + + return prepared; + } + + Ref MaterialSystem::ResolveMaterialProxy( + const Ref& instance + ) + { + return m_ProxyCache.Resolve(instance); + } +} diff --git a/Elixir/Source/Engine/Materials/MaterialSystem.h b/Elixir/Source/Engine/Materials/MaterialSystem.h new file mode 100644 index 00000000..b35770da --- /dev/null +++ b/Elixir/Source/Engine/Materials/MaterialSystem.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include +#include + +namespace Elixir { class ShaderLoader; } + +namespace Elixir::Materials +{ + using namespace Rendering; + + /** + * @brief Configures the initial storage used for frame material data. + */ + struct SMaterialSystemConfig + { + /** @brief Initial number of material entries supported by a frame snapshot. */ + uint32_t InitialFrameCapacity = 256; + }; + + /** + * @brief Resolves material instances for rendering. + */ + class ELIXIR_API MaterialSystem final + { + public: + /** + * @brief Creates a material system. + * @param context Graphics context used by the material renderer. + * @param shaderLoader Loader used to obtain material shaders. + * @param config Initial frame-data storage configuration. + * @pre context and shaderLoader are valid. + * @pre config.InitialFrameCapacity is greater than zero. + */ + MaterialSystem( + const GraphicsContext* context, + const ShaderLoader* shaderLoader, + SMaterialSystemConfig config + ); + + /** @brief Starts material collection for the current graphics frame. */ + void BeginFrame(); + + /** + * @brief Adds a scene to the current material frame. + * @param scene Frame-local material draw data. + * @pre BeginFrame was called for the current graphics frame. + */ + void Submit(MaterialRenderScene scene); + + /** + * @brief Records all scenes submitted for the current graphics frame. + * @return Counts of prepared materials, batches, and draws. + * @pre BeginFrame was called for the current graphics frame. + */ + SRenderResult RenderFrame(); + + private: + /** + * @brief Resolves material proxies for one scene. + * @param scene Scene that provides material render items. + * @return Scene items with immutable material proxies. + */ + SPreparedScene PrepareScene(const MaterialRenderScene& scene); + + /** + * @brief Resolves an instance into a render-ready material proxy. + * @param instance Material instance to resolve. + * @return The render proxy, or null when the instance cannot be resolved. + */ + Ref ResolveMaterialProxy(const Ref& instance); + + MaterialProxyResolver m_ProxyResolver; + MaterialProxyCache m_ProxyCache; + Scope m_Renderer; + + std::vector m_SubmittedScenes; + + bool m_IsCollectingFrame = false; + }; +} diff --git a/Elixir/Source/Engine/Materials/Nodes/Add.h b/Elixir/Source/Engine/Materials/Nodes/Add.h new file mode 100644 index 00000000..c91b93c6 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/Add.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Adds two values component by component. + */ + class Add final : public BinaryOperationNode + { + public: + std::string_view GetTypeName() const override { return "Material.Add"; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + const auto outputType = GetOutputType(context); + const auto a = context.Widen(context.Input(0), outputType); + const auto b = context.Widen(context.Input(1), outputType); + return { + .Code = "(" + a + " + " + b + ")", + .ValueType = outputType + }; + } + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Nodes/BinaryOperationNode.h b/Elixir/Source/Engine/Materials/Nodes/BinaryOperationNode.h new file mode 100644 index 00000000..e54beecb --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/BinaryOperationNode.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Combines two values, widening operands to a shared width. + */ + class BinaryOperationNode : public MaterialNode + { + protected: + BinaryOperationNode() + : MaterialNode({ + { "A", EMaterialValueType::Float4, "0.0" }, + { "B", EMaterialValueType::Float4, "0.0" } + }) {} + + static EMaterialValueType GetOutputType(const MaterialEmitContext& context) + { + return MaterialEmitContext::Wider( + context.Input(0).ValueType, + context.Input(1).ValueType + ); + } + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Nodes/Checkerboard.h b/Elixir/Source/Engine/Materials/Nodes/Checkerboard.h new file mode 100644 index 00000000..f76a8136 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/Checkerboard.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Generates a procedural checkerboard. + */ + class Checkerboard final : public MaterialNode + { + public: + /** + * @brief Creates a checkerboard with the requested number of cells. + * @param scale The checkerboard scale. + */ + explicit Checkerboard(const float scale) + : MaterialNode({{ + "UV", + EMaterialValueType::Float2, + "input.TexCoord", + EMaterialValueType::Float2 + }}), + m_Scale(scale) {} + + std::string_view GetTypeName() const override { return "Material.Checkerboard"; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + const auto uv = context.Widen(context.Input(0), EMaterialValueType::Float2); + const auto scale = std::to_string(std::max(m_Scale, 1.0f)); + return { + "(fmod(floor(" + uv + ".x * " + scale + ") + floor(" + uv + ".y * " + + scale + "), 2.0) < 1.0" + + "? float3(0.08, 0.08, 0.08)" + + ": float3(0.72, 0.72, 0.72))", + EMaterialValueType::Float3 + }; + } + + private: + float m_Scale; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Nodes/ComponentMask.h b/Elixir/Source/Engine/Materials/Nodes/ComponentMask.h new file mode 100644 index 00000000..7c538789 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/ComponentMask.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Selects one component from an input value. + */ + class ComponentMask final : public MaterialNode + { + public: + /** + * @brief Creates a component mask that selects a zero-based component. + * @param componentIndex Zero-based component index. + */ + explicit ComponentMask(const uint32_t componentIndex) + : MaterialNode({{ + .Name = "Value", + .ValueType = EMaterialValueType::Float4, + .DefaultExpression = "float4(0.0, 0.0, 0.0, 0.0)", + .DefaultValueType = EMaterialValueType::Float4 + }}), + m_ComponentIndex(componentIndex) {} + + std::string_view GetTypeName() const override { return "Material.ComponentMask"; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + static constexpr std::array components{ ".x", ".y", ".z", ".w" }; + const auto component = std::min(m_ComponentIndex, uint32_t(components.size() - 1)); + return { + .Code = "(" + context.Input(0).Code + ")" + components[component], + .ValueType = EMaterialValueType::Float + }; + } + + private: + uint32_t m_ComponentIndex; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Nodes/Constant.h b/Elixir/Source/Engine/Materials/Nodes/Constant.h new file mode 100644 index 00000000..bff946c9 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/Constant.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Outputs a literal scalar or vector value. + */ + class Constant final : public MaterialNode + { + public: + /** + * @brief Creates a constant with a value and output type. + * @param value The constant value. + * @param type The output type. + */ + Constant(const glm::vec4& value, const EMaterialValueType type) + : m_Value(value), + m_ValueType(type) {} + + std::string_view GetTypeName() const override { return "Material.Constant"; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + const auto number = [](const float value) { return std::to_string(value); }; + + switch (m_ValueType) + { + case EMaterialValueType::Float: return { number(m_Value.x), m_ValueType }; + case EMaterialValueType::Float2: + return { + "float2(" + number(m_Value.x) + ", " + number(m_Value.y) + ")", + m_ValueType + }; + case EMaterialValueType::Float3: + return { + "float3(" + number(m_Value.x) + ", " + number(m_Value.y) + ", " + + number(m_Value.z) + ")", + m_ValueType + }; + case EMaterialValueType::Float4: + return { + "float4(" + number(m_Value.x) + ", " + number(m_Value.y) + ", " + + number(m_Value.z) + ", " + number(m_Value.w) + ")", + m_ValueType + }; + } + + return { "0.0", EMaterialValueType::Float }; + } + + private: + glm::vec4 m_Value; + EMaterialValueType m_ValueType; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Nodes/Divide.h b/Elixir/Source/Engine/Materials/Nodes/Divide.h new file mode 100644 index 00000000..262aa796 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/Divide.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Divides the first value by the second. + */ + class Divide final : public BinaryOperationNode + { + public: + std::string_view GetTypeName() const override { return "Material.Divide"; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + const auto outputType = GetOutputType(context); + const auto a = context.Widen(context.Input(0), outputType); + const auto b = context.Widen(context.Input(1), outputType); + return { + .Code = "(" + a + " / " + b + ")", + .ValueType = outputType + }; + } + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Nodes/Dot.h b/Elixir/Source/Engine/Materials/Nodes/Dot.h new file mode 100644 index 00000000..978e98c4 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/Dot.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Calculates the dot product of two values. + */ + class Dot final : public BinaryOperationNode + { + public: + std::string_view GetTypeName() const override { return "Material.Dot"; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + const auto outputType = GetOutputType(context); + const auto a = context.Widen(context.Input(0), outputType); + const auto b = context.Widen(context.Input(1), outputType); + return { + .Code = "dot(" + a + ", " + b + ")", + .ValueType = EMaterialValueType::Float + }; + } + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Nodes/Fresnel.h b/Elixir/Source/Engine/Materials/Nodes/Fresnel.h new file mode 100644 index 00000000..1e2e53e8 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/Fresnel.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Calculates a Schlick Fresnel factor. + */ + class Fresnel final : public MaterialNode + { + public: + std::string_view GetTypeName() const override { return "Material.Fresnel"; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + return { + .Code = "pow(saturate(1.0 - dot(N, V)), 5.0)", + .ValueType = EMaterialValueType::Float + }; + } + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Nodes/Lerp.h b/Elixir/Source/Engine/Materials/Nodes/Lerp.h new file mode 100644 index 00000000..2e8abf3c --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/Lerp.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Linearly interpolates between two values. + */ + class Lerp final : public MaterialNode + { + public: + Lerp() + : MaterialNode({ + { "A", EMaterialValueType::Float4, "0.0" }, + { "B", EMaterialValueType::Float4, "0.0" }, + { "T", EMaterialValueType::Float4, "0.0" } + }) {} + + std::string_view GetTypeName() const override { return "Material.Lerp"; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + const auto type = MaterialEmitContext::Wider( + context.Input(0).ValueType, + context.Input(1).ValueType + ); + + return { + .Code = "lerp(" + context.Widen(context.Input(0), type) + ", " + + context.Widen(context.Input(1), type) + ", " + + context.Widen(context.Input(2), type) + ")", + .ValueType = type + }; + } + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Nodes/Multiply.h b/Elixir/Source/Engine/Materials/Nodes/Multiply.h new file mode 100644 index 00000000..48f8a24a --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/Multiply.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Multiplies two values component by component. + */ + class Multiply final : public BinaryOperationNode + { + public: + std::string_view GetTypeName() const override { return "Material.Multiply"; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + const auto outputType = GetOutputType(context); + const auto a = context.Widen(context.Input(0), outputType); + const auto b = context.Widen(context.Input(1), outputType); + return { + .Code = "(" + a + " * " + b + ")", + .ValueType = outputType + }; + } + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Nodes/OneMinus.h b/Elixir/Source/Engine/Materials/Nodes/OneMinus.h new file mode 100644 index 00000000..20669db2 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/OneMinus.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Subtracts every component from one. + */ + class OneMinus final : public UnaryOperationNode + { + public: + std::string_view GetTypeName() const override { return "Material.OneMinus"; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + const auto& input = context.Input(0); + return { + .Code = "(1.0 - " + input.Code + ")", + .ValueType = input.ValueType, + }; + } + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Nodes/Panner.h b/Elixir/Source/Engine/Materials/Nodes/Panner.h new file mode 100644 index 00000000..00cac758 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/Panner.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Offsets texture coordinates over time. + */ + class Panner final : public MaterialNode + { + public: + explicit Panner(const glm::vec2& speed) + : MaterialNode({{ + "UV", + EMaterialValueType::Float2, + "input.TexCoord", + EMaterialValueType::Float2 + }}), + m_Speed(speed) {} + + std::string_view GetTypeName() const override { return "Material.Panner"; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + const auto speed = "float2(" + std::to_string(m_Speed.x) + ", " + + std::to_string(m_Speed.y) + ")"; + return { + .Code = "(" + context.Widen(context.Input(0), EMaterialValueType::Float2) + + " + Time * " + speed + ")", + .ValueType = EMaterialValueType::Float2 + }; + } + + private: + glm::vec2 m_Speed; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Nodes/Parameter.h b/Elixir/Source/Engine/Materials/Nodes/Parameter.h new file mode 100644 index 00000000..9ba541f9 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/Parameter.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Reads a named numeric material parameter. + */ + class Parameter final : public MaterialNode + { + public: + /** + * @brief Creates a parameter reader with its expected value type. + * @param name Parameter name. + * @param type Expected value type. + */ + Parameter(std::string name, const EMaterialValueType type) + : m_Name(std::move(name)), + m_ValueType(type) {} + + std::string_view GetTypeName() const override { return "Material.Parameter"; } + + bool Validate( + const MaterialNodeValidationContext& parameters, + std::string& error + ) const override + { + if (parameters.HasValueParameter(m_Name, m_ValueType)) return true; + error = "Invalid value parameter: " + m_Name; + return false; + } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + return { + .Code = context.ValueParameter(m_Name), + .ValueType = m_ValueType + }; + } + + private: + std::string m_Name; + EMaterialValueType m_ValueType; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Nodes/Power.h b/Elixir/Source/Engine/Materials/Nodes/Power.h new file mode 100644 index 00000000..e962aa73 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/Power.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Raises the first value to the second. + */ + class Power final : public BinaryOperationNode + { + public: + std::string_view GetTypeName() const override { return "Material.Power"; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + const auto outputType = GetOutputType(context); + const auto a = context.Widen(context.Input(0), outputType); + const auto b = context.Widen(context.Input(1), outputType); + return { + .Code = "pow(" + a + ", " + b + ")", + .ValueType = outputType + }; + } + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Nodes/RadialGradientExponential.h b/Elixir/Source/Engine/Materials/Nodes/RadialGradientExponential.h new file mode 100644 index 00000000..90931c6b --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/RadialGradientExponential.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Generates an exponential radial gradient. + */ + class RadialGradientExponential final : public MaterialNode + { + public: + /** + * @brief Creates a radial gradient that fades from its center. + * @param center Center in UV coordinates. + * @param radius Distance from the center where the gradient reaches zero. + * @param exponent Controls the falloff shape. Higher values create a sharper fade. + */ + RadialGradientExponential( + const glm::vec2& center, + const float radius, + const float exponent + ) : MaterialNode({{ + "UV", + EMaterialValueType::Float2, + "input.TexCoord", + EMaterialValueType::Float2 + }}), + m_Center(center), + m_Radius(radius), + m_Exponent(exponent) {} + + std::string_view GetTypeName() const override { return "Material.RadialGradientExponential"; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + const auto uv = context.Widen(context.Input(0), EMaterialValueType::Float2); + const auto center = "float2(" + std::to_string(m_Center.x) + ", " + std::to_string(m_Center.y) + ")"; + const auto radius = std::to_string(std::max(m_Radius, 0.0001f)); + const auto exponent = std::to_string(std::max(m_Exponent, 0.0001f)); + return { + .Code = "pow(saturate(1.0 - length((" + uv + " - " + center + ") / " + radius + + ")), " + exponent + ")", + .ValueType = EMaterialValueType::Float + }; + } + + private: + glm::vec2 m_Center; + float m_Radius; + float m_Exponent; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Nodes/Saturate.h b/Elixir/Source/Engine/Materials/Nodes/Saturate.h new file mode 100644 index 00000000..7b053a28 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/Saturate.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Clamps every component to the zero-to-one range. + */ + class Saturate final : public UnaryOperationNode + { + public: + std::string_view GetTypeName() const override { return "Material.Saturate"; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + const auto& input = context.Input(0); + return { + .Code = "saturate(" + input.Code + ")", + .ValueType = input.ValueType, + }; + } + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Nodes/Sine.h b/Elixir/Source/Engine/Materials/Nodes/Sine.h new file mode 100644 index 00000000..1b7ce2e4 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/Sine.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Applies sine to every component. + */ + class Sine final : public UnaryOperationNode + { + public: + std::string_view GetTypeName() const override { return "Material.Sine"; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + const auto& input = context.Input(0); + return { + .Code = "sin(" + input.Code + ")", + .ValueType = input.ValueType, + }; + } + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Nodes/Subtract.h b/Elixir/Source/Engine/Materials/Nodes/Subtract.h new file mode 100644 index 00000000..093d2546 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/Subtract.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Subtracts the second value from the first. + */ + class Subtract final : public BinaryOperationNode + { + public: + std::string_view GetTypeName() const override { return "Material.Subtract"; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + const auto outputType = GetOutputType(context); + const auto a = context.Widen(context.Input(0), outputType); + const auto b = context.Widen(context.Input(1), outputType); + return { + .Code = "(" + a + " - " + b + ")", + .ValueType = outputType + }; + } + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Nodes/TexCoord.h b/Elixir/Source/Engine/Materials/Nodes/TexCoord.h new file mode 100644 index 00000000..8c439692 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/TexCoord.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Outputs the input texture coordinates. + */ + class TexCoord final : public MaterialNode + { + public: + std::string_view GetTypeName() const override { return "Material.TexCoord"; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + return { + .Code = "input.TexCoord", + .ValueType = EMaterialValueType::Float2 + }; + } + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Nodes/TextureSample.h b/Elixir/Source/Engine/Materials/Nodes/TextureSample.h new file mode 100644 index 00000000..8f598628 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/TextureSample.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Samples a named texture material parameter. + */ + class TextureSample final : public MaterialNode + { + public: + /** + * @brief Creates a texture sampler for a material texture parameter. + * @param parameterName Texture parameter name. + */ + explicit TextureSample(std::string parameterName) + : MaterialNode({{ + "UV", + EMaterialValueType::Float2, + "input.TexCoord", + EMaterialValueType::Float2 + }}), + m_ParameterName(std::move(parameterName)) {} + + std::string_view GetTypeName() const override { return "Material.TextureSample"; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + const auto index = context.TextureParameter(m_ParameterName); + return { + .Code = "(" + index + " == 0xFFFFFFFFu " + + "? float4(1.0, 1.0, 1.0, 1.0) " + + ": SampleTex(" + + index + ", " + + context.Widen(context.Input(0), EMaterialValueType::Float2) + + "))", + .ValueType = EMaterialValueType::Float4 + }; + } + + private: + std::string m_ParameterName; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Nodes/Time.h b/Elixir/Source/Engine/Materials/Nodes/Time.h new file mode 100644 index 00000000..e9542c70 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/Time.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Outputs elapsed time in seconds. + */ + class Time final : public MaterialNode + { + public: + std::string_view GetTypeName() const override { return "Material.Time"; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + return { .Code = "Time", .ValueType = EMaterialValueType::Float }; + } + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Nodes/UnaryOperationNode.h b/Elixir/Source/Engine/Materials/Nodes/UnaryOperationNode.h new file mode 100644 index 00000000..b90f0d76 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Nodes/UnaryOperationNode.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Applies a single-input operation while preserving input width. + */ + class UnaryOperationNode : public MaterialNode + { + protected: + UnaryOperationNode() + : m_Inputs{{ + "Value", + EMaterialValueType::Float4, + "0.0" + }} {} + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Rendering/FrameTable.cpp b/Elixir/Source/Engine/Materials/Rendering/FrameTable.cpp new file mode 100644 index 00000000..e89b37aa --- /dev/null +++ b/Elixir/Source/Engine/Materials/Rendering/FrameTable.cpp @@ -0,0 +1,57 @@ +#include "epch.h" +#include "FrameTable.h" + +namespace Elixir::Materials::Rendering +{ + FrameTable::FrameTable( + const uint32_t capacity, + const uint32_t fallbackTextureIndex, + TextureIndexResolver resolver + ) : m_Capacity(capacity), + m_FallbackTextureIndex(fallbackTextureIndex), + m_TextureIndexResolver(std::move(resolver)) {} + + std::optional FrameTable::Add(const MaterialRenderProxy& material) + { + if (const auto found = Find(material)) + return found; + + if (m_Data.size() >= m_Capacity) + return std::nullopt; + + const auto index = (uint32_t)m_Data.size(); + m_Data.push_back(BuildData(material)); + m_Indices.emplace(&material, index); + return index; + } + + std::optional FrameTable::Find(const MaterialRenderProxy& material) const + { + const auto found = m_Indices.find(&material); + if (found == m_Indices.end()) + return std::nullopt; + + return found->second; + } + + SMaterialFrameData FrameTable::BuildData(const MaterialRenderProxy& material) const + { + SMaterialFrameData data{}; + std::ranges::fill(data.TextureIndices, m_FallbackTextureIndex); + + const auto& values = material.GetValues(); + const auto valueCount = std::min(values.size(), data.Values.size()); + std::copy_n(values.begin(), valueCount, data.Values.begin()); + + const auto& textures = material.GetTextures(); + const auto textureCount = std::min(textures.size(), data.TextureIndices.size()); + + for (size_t slot = 0; slot < textureCount; ++slot) + { + if (textures[slot]) + data.TextureIndices[slot] = m_TextureIndexResolver(textures[slot]); + } + + return data; + } +} diff --git a/Elixir/Source/Engine/Materials/Rendering/FrameTable.h b/Elixir/Source/Engine/Materials/Rendering/FrameTable.h new file mode 100644 index 00000000..bcca3e94 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Rendering/FrameTable.h @@ -0,0 +1,82 @@ +#pragma once + +#include +#include + +#include + +namespace Elixir::Materials::Rendering +{ + /** + * @brief Defines the GPU data layout for one resolved material. + * + * Material templates share this layout with the shader interface. + */ + struct alignas(16) SMaterialFrameData + { + /** @brief Numeric material values, stored by material value slot. */ + std::array Values{}; + + /** @brief Bindless texture indices, stored by material texture slot. */ + std::array TextureIndices{}; + }; + + /** + * @brief Stores resolved material data for one frame submission. + * + * Each material proxy is assigned one stable table index. Values and textures + * beyond the fixed GPU layout are not included. + */ + class ELIXIR_API FrameTable final + { + public: + /** @brief Resolves a texture into an index usable by the GPU. */ + using TextureIndexResolver = std::function&)>; + + /** + * @brief Creates an empty material frame table. + * @param capacity Maximum number of material entries. + * @param fallbackTextureIndex Index used for missing texture bindings. + * @param resolver Function that resolves material textures to GPU indices. + * @pre resolver is valid. + */ + FrameTable( + uint32_t capacity, + uint32_t fallbackTextureIndex, + TextureIndexResolver resolver + ); + + /** + * @brief Adds a material to the table when capacity is available. + * + * Adding an existing material returns its current index. + * + * @param material Resolved material data to add. + * @return The material table index, or no value when the table is full. + */ + std::optional Add(const MaterialRenderProxy& material); + + /** + * @brief Finds the table index assigned to a material. + * @param material Resolved material data to find. + * @return The material table index, or no value when it is not in the table. + */ + std::optional Find(const MaterialRenderProxy& material) const; + + /** @brief Gets the GPU material data in table-index order. */ + const std::vector& GetData() const { return m_Data; } + + /** @brief Gets the number of materials stored in the table. */ + uint32_t GetCount() const { return static_cast(m_Data.size()); } + + private: + /** @brief Builds the GPU data for a resolved material. */ + SMaterialFrameData BuildData(const MaterialRenderProxy& material) const; + + uint32_t m_Capacity = 0; + uint32_t m_FallbackTextureIndex = 0; + TextureIndexResolver m_TextureIndexResolver; + std::unordered_map m_Indices; + std::vector m_Data; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Rendering/MaterialRenderProxy.cpp b/Elixir/Source/Engine/Materials/Rendering/MaterialRenderProxy.cpp new file mode 100644 index 00000000..fc4669fa --- /dev/null +++ b/Elixir/Source/Engine/Materials/Rendering/MaterialRenderProxy.cpp @@ -0,0 +1,44 @@ +#include "epch.h" +#include "MaterialRenderProxy.h" + +#include + +namespace Elixir::Materials::Rendering +{ + Ref MaterialRenderProxy::Create( + Ref material, + const MaterialInstance& instance + ) + { + if (!material || + !instance.GetParent() || + material->MaterialRevision != instance.GetParent()->GetRevision()) + return nullptr; + + auto proxy = CreateRef(); + proxy->m_CompiledMaterial = std::move(material); + proxy->m_InstanceRevision = instance.GetRevision(); + + for (const auto& parameter : proxy->m_CompiledMaterial->Parameters) + { + const auto* value = instance.GetResolvedParameter(parameter.Name); + if (!value) return nullptr; + + if (parameter.Kind == EMaterialParameterKind::Texture) + { + const auto texCount = std::max(proxy->m_Textures.size(), size_t(parameter.Slot + 1)); + proxy->m_Textures.resize(texCount); + proxy->m_Textures[parameter.Slot] = value->Texture; + continue; + } + + const auto valueCount = std::max(proxy->m_Values.size(), size_t(parameter.Slot + 1)); + proxy->m_Values.resize(valueCount); + proxy->m_Values[parameter.Slot] = value->Type == EMaterialParameterType::Scalar + ? glm::vec4(value->Scalar, 0.0f, 0.0f, 0.0f) + : value->Vector; + } + + return proxy; + } +} diff --git a/Elixir/Source/Engine/Materials/Rendering/MaterialRenderProxy.h b/Elixir/Source/Engine/Materials/Rendering/MaterialRenderProxy.h new file mode 100644 index 00000000..385d6398 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Rendering/MaterialRenderProxy.h @@ -0,0 +1,69 @@ +#pragma once + +#include + +namespace Elixir::Materials::Rendering +{ + using namespace Compilation; + + /** + * @brief Stores render-ready data for one material instance. + * + * The proxy copies resolved parameter values into slots defined by the compiled + * material. It is valid only for the material and instance revisions used to + * create it. + */ + class ELIXIR_API MaterialRenderProxy final + { + public: + /** + * @brief Creates render-ready data for a material instance. + * @param material Compiled material that defines parameter slots. + * @param instance Material instance that provides resolved parameter values. + * @return A render proxy, or null if the material is stale or a parameter + * cannot be resolved. + * @pre @p material was compiled from `instance.GetParent()`. + * @pre The compiled material revision matches the parent material revision. + */ + static Ref Create( + Ref material, + const MaterialInstance& instance + ); + + /** + * @brief Returns the compiled material that defines the parameter slots. + * @return Read-only compiled material. + */ + const Ref GetCompiledMaterial() const + { + return m_CompiledMaterial; + } + + /** + * @brief Returns the source instance revision. + * @return Instance revision used to create this proxy. + */ + uint32_t GetInstanceRevision() const { return m_InstanceRevision; } + + /** + * @brief Returns resolved scalar and vector values by compiled slots. + * + * Scalar values use the X component. Vector values use all components. + * + * @return Read-only value slots. + */ + const std::vector& GetValues() const { return m_Values; } + + /** + * @brief Returns resolved textures by compiled slot. + * @return Read-only texture slots. + */ + const std::vector>& GetTextures() const { return m_Textures; } + + private: + Ref m_CompiledMaterial; + uint32_t m_InstanceRevision = 0; + std::vector m_Values; + std::vector> m_Textures; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Materials/Rendering/MaterialRenderScene.cpp b/Elixir/Source/Engine/Materials/Rendering/MaterialRenderScene.cpp new file mode 100644 index 00000000..b2ad415d --- /dev/null +++ b/Elixir/Source/Engine/Materials/Rendering/MaterialRenderScene.cpp @@ -0,0 +1,62 @@ +#include "epch.h" +#include "MaterialRenderScene.h" + +namespace Elixir::Materials::Rendering +{ + /* SMaterialPushConstants */ + + std::array + SMaterialPushConstants::Resolve(const uint32_t materialIndex) const + { + auto resolved = Data; + + const auto patch = [&resolved](const uint32_t offset, const uint32_t value) + { + if (offset == NO_OFFSET) return; + + EE_CORE_ASSERT( + offset + sizeof(value) <= CAPACITY, + "Material push constant index offset is out of range." + ) + + Memory::Memcpy(resolved.data() + offset, &value, sizeof(value)); + }; + + patch(MaterialIndexOffset, materialIndex); + + return resolved; + } + + /* MaterialRenderScene */ + + uint32_t MaterialRenderScene::AddGeometry(SRenderGeometry geometry) + { + EE_CORE_ASSERT( + geometry.Pipeline.VertexLayout, + "Material render geometry requires a vertex layout." + ) + + const auto index = (uint32_t)m_Geometries.size(); + m_Geometries.push_back(std::move(geometry)); + + return index; + } + + void MaterialRenderScene::Add(SRenderItem item) + { + EE_CORE_ASSERT( + item.GeometryIndex < m_Geometries.size(), + "Material render item references a unknown geometry." + ) + + m_Items.push_back(std::move(item)); + } + + const SRenderGeometry* MaterialRenderScene::FindGeometry(const uint32_t index) const + { + if (index >= m_Geometries.size()) + return nullptr; + + return &m_Geometries[index]; + } +} diff --git a/Elixir/Source/Engine/Materials/Rendering/MaterialRenderScene.h b/Elixir/Source/Engine/Materials/Rendering/MaterialRenderScene.h new file mode 100644 index 00000000..aede595d --- /dev/null +++ b/Elixir/Source/Engine/Materials/Rendering/MaterialRenderScene.h @@ -0,0 +1,186 @@ +#pragma once + +#include +#include +#include +#include + +namespace Elixir::Materials::Rendering +{ + /** + * @brief Stores push constants for one material draw. + * + * The structure keeps the raw constant data and can replace the material index + * before the draw is recorded. + */ + struct ELIXIR_API SMaterialPushConstants + { + /** Maximum number of bytes available for push constants. */ + static constexpr uint32_t CAPACITY = 128; + + /** Indicates that no material-index replacement is required. */ + static constexpr uint32_t NO_OFFSET = UINT32_MAX; + + /** Raw push-constant data. */ + std::array Data{}; + + /** Number of valid bytes in @ref Data. */ + uint32_t Size = 0; + + /** Byte offset of the material index in @ref Data, or @ref NO_OFFSET. */ + uint32_t MaterialIndexOffset = NO_OFFSET; + + /** + * @brief Creates push constants from a value. + * + * @tparam T Type of the source value. + * @param value Value copied into the push-constant storage. + * @param materialIndexOffset Byte offset of the material index, if present. + * @return Push constants containing a copy of @p value. + * + * @pre `sizeof(T)` must not exceed @ref CAPACITY. + */ + template + static SMaterialPushConstants Create( + const T& value, + const uint32_t materialIndexOffset = NO_OFFSET + ) + { + EE_CORE_ASSERT( + sizeof(T) <= CAPACITY, + "Material push constants exceed the scene storage capacity." + ) + + SMaterialPushConstants pc{}; + Memory::Memcpy(pc.Data.data(), &value, sizeof(T)); + pc.Size = sizeof(T); + pc.MaterialIndexOffset = materialIndexOffset; + + return pc; + } + + /** + * @brief Returns the push constants with the material index applied. + * + * @param materialIndex Index of the material in the current frame table. + * @return A copy of @ref Data with the material index written when required. + */ + std::array Resolve(uint32_t materialIndex) const; + }; + + /** + * @brief Associates a vertex buffer with a pipeline binding. + */ + struct SVertexBufferBinding + { + /** Source vertex buffer. */ + const Buffer* Buffer = nullptr; + + /** Vertex-input binding index. */ + uint32_t Binding = 0; + }; + + /** + * @brief Describes one indexed range of a draw call. + */ + struct SDrawCommand + { + /** Number of vertices to draw. */ + uint32_t VertexCount = 0; + + /** Number of instances to draw. */ + uint32_t InstanceCount = 1; + + /** First vertex in the source buffer. */ + uint32_t FirstVertex = 0; + + /** First instance in the source buffer. */ + uint32_t FirstInstance = 0; + }; + + /** + * @brief Stores shared resources for render items with compatible geometry. + */ + struct SRenderGeometry + { + /** Pipeline configuration for the geometry. */ + SPipelineRequest Pipeline; + + /** Constant buffers required by the material pass. */ + std::vector ConstantBuffers; + + /** Storage buffers required by the material pass. */ + std::vector StorageBuffers; + + /** Vertex buffers required by the draw. */ + std::vector VertexBuffers; + }; + + /** + * @brief Describes one material draw recorded for the current frame. + */ + struct SRenderItem + { + /** Material pass used to render the item. */ + EMaterialPass Pass = EMaterialPass::ParticleSprite; + + /** Material instance used by the pass. */ + Ref Material; + + /** Index of the geometry used by this item. */ + uint32_t GeometryIndex = UINT32_MAX; + + /** Push constants applied before the draw. */ + SMaterialPushConstants PushConstants; + + /** Draw range for the item. */ + SDrawCommand Draw; + }; + + /** + * @brief Collects geometry and material draws for one frame. + * + * The scene becomes immutable after publication and is consumed while the + * frame command buffer is recorded. + */ + class ELIXIR_API MaterialRenderScene final + { + public: + /** + * @brief Adds reusable geometry to the scene. + * + * @param geometry Geometry resources to store. + * @return Index that identifies the stored geometry. + * + * @pre `geometry.Pipeline.VertexLayout` is not null. + */ + uint32_t AddGeometry(SRenderGeometry geometry); + + /** + * @brief Adds a material draw to the scene. + * + * @param item Draw description to store. + * + * @pre `item.GeometryIndex` identifies geometry added to this scene. + */ + void Add(SRenderItem item); + + /** + * @brief Finds geometry by index. + * + * @param index Geometry index. + * @return The geometry, or null when @p index is invalid. + */ + const SRenderGeometry* FindGeometry(uint32_t index) const; + + /** + * @brief Returns the material draws in insertion order. + * @return Read-only view of the frame-local draw items. + */ + std::span GetItems() const { return m_Items; } + + private: + std::vector m_Geometries; + std::vector m_Items; + }; +} diff --git a/Elixir/Source/Engine/Materials/Rendering/MaterialResolver.h b/Elixir/Source/Engine/Materials/Rendering/MaterialResolver.h new file mode 100644 index 00000000..c5f9972f --- /dev/null +++ b/Elixir/Source/Engine/Materials/Rendering/MaterialResolver.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +namespace Elixir::Materials::Rendering +{ + /** + * @brief Resolves a material instance into render-ready material data. + * + * Implementations publish an immutable proxy that render code can safely use. + */ + class ELIXIR_API MaterialResolver + { + public: + /** @brief Destroys the resolver. */ + virtual ~MaterialResolver() = default; + + /** + * @brief Resolves an instance into a render proxy. + * @param instance Material instance to resolve. + * @return The render proxy, or null when the instance cannot be resolved. + */ + virtual Ref Resolve( + const Ref& instance + ) = 0; + }; +} diff --git a/Elixir/Source/Engine/Materials/Rendering/Renderer.cpp b/Elixir/Source/Engine/Materials/Rendering/Renderer.cpp new file mode 100644 index 00000000..f4ec6d2d --- /dev/null +++ b/Elixir/Source/Engine/Materials/Rendering/Renderer.cpp @@ -0,0 +1,480 @@ +#include "epch.h" +#include "Renderer.h" + +#include +#include +#include + +namespace Elixir::Materials::Rendering +{ + Renderer::Renderer( + const GraphicsContext* context, + const uint32_t materialCapacity + ) : m_MaterialCapacity(materialCapacity), + m_FrameSlots(*context), + m_Textures(context), + m_Context(context) + { + EE_CORE_ASSERT(context, "Material renderer requires a graphics context.") + EE_CORE_ASSERT( + m_MaterialCapacity > 0, + "Material renderer frame capacity must be greater than zero." + ) + + m_FrameSlots.ForEach([this, context](SFrameSlot& slot) + { + slot.MaterialBuffer = DynamicStorageBuffer::Create( + context, + sizeof(SMaterialFrameData) * m_MaterialCapacity + ); + }); + } + + void Renderer::BeginFrame() + { + EE_CORE_ASSERT(m_Context, "Material renderer graphics context is unavailable.") + + m_CurrentFrameNumber = m_Context->GetFrameNumber(); + m_Textures.BeginFrame(m_CurrentFrameNumber); + } + + SRenderResult Renderer::RenderFrame(std::span scenes) + { + SRenderResult result{}; + if (scenes.empty()) return result; + + EE_CORE_ASSERT( + m_CurrentFrameNumber == m_Context->GetFrameNumber(), + "Material rendering requires BeginFrame for the current graphics frame." + ) + + const auto cmd = m_Context->GetSecondaryCommandBuffer(); + const auto extent = m_Context->GetRenderTarget()->GetExtent(); + + const SRenderingInfo renderingInfo{ + .ColorAttachment = m_Context->GetRenderTarget(), + .DepthStencilAttachment = m_Context->GetDepthStencilRenderTarget(), + .RenderArea = extent, + }; + + cmd->Begin(renderingInfo); + cmd->BeginRendering(renderingInfo); + + cmd->SetViewports({{ + .Width = (float)extent.Width, + .Height = (float)extent.Height, + .MinDepth = 0.0f, + .MaxDepth = 1.0f, + }}); + + cmd->SetScissors({{ + .Offset = { 0, 0 }, + .Extent = extent, + }}); + + for (const auto& scene : scenes) + { + const auto prepared = PrepareScene(scene); + const auto sceneResult = RecordScene(cmd, prepared); + result.MaterialCount += sceneResult.MaterialCount; + result.BatchCount += sceneResult.BatchCount; + result.DrawCount += sceneResult.DrawCount; + } + + cmd->EndRendering(); + cmd->End(); + m_Context->EnqueueSecondaryCommandBuffer(cmd); + + return result; + } + + std::optional Renderer::GetProgramKey( + const EMaterialPass pass, + const MaterialRenderProxy& material + ) + { + const auto usage = GetUsage(pass); + const auto compiled = material.GetCompiledMaterial(); + if (!compiled || !compiled->SupportsUsage(usage)) + return std::nullopt; + + const auto& shader = compiled->GetShader(usage); + if (!shader) + return std::nullopt; + + return SProgramKey{ .Identity = shader.get() }; + } + + EMaterialUsage Renderer::GetUsage(const EMaterialPass pass) + { + switch (pass) + { + case EMaterialPass::ParticleSprite: return EMaterialUsage::ParticleSprite; + case EMaterialPass::ParticleRibbon: return EMaterialUsage::ParticleRibbon; + case EMaterialPass::ParticleMesh: return EMaterialUsage::ParticleMesh; + } + + EE_CORE_ASSERT(false, "Material pass does not have a material usage.") + return EMaterialUsage::ParticleSprite; + } + + uint32_t Renderer::GetPassOrder(const EMaterialPass pass) + { + switch (pass) + { + case EMaterialPass::ParticleSprite: return 2; + case EMaterialPass::ParticleRibbon: return 1; + case EMaterialPass::ParticleMesh: return 0; + } + + return UINT32_MAX; + } + + Renderer::SPreparedRenderScene Renderer::PrepareScene(const SPreparedScene& scene) + { + SPreparedRenderScene prepared{ + .Scene = scene.Scene, + }; + + if (!scene.Scene) return prepared; + + const auto table = CreateRef( + m_MaterialCapacity, + m_Textures.GetFallbackIndex(), + [this](const Ref& texture) + { + return m_Textures.Resolve(texture); + } + ); + + for (const auto& resolved : scene.Items) + { + if (!resolved.Item || !resolved.Proxy) continue; + + const auto materialIndex = table->Add(*resolved.Proxy); + EE_CORE_ASSERT(materialIndex, "Material frame capacity was exceeded.") + if (!materialIndex) continue; + + prepared.Items.push_back({ + .Item = resolved.Item, + .Proxy = resolved.Proxy, + .MaterialIndex = *materialIndex, + }); + } + + if (!table->GetData().empty()) + { + GetActiveMaterialBuffer()->UpdateData( + table->GetData().data(), + table->GetData().size() * sizeof(SMaterialFrameData) + ); + } + + prepared.MaterialCount = table->GetCount(); + return prepared; + } + + SRenderResult Renderer::RecordScene( + const Ref& cmd, + const SPreparedRenderScene& scene + ) + { + SRenderResult result{ + .MaterialCount = scene.MaterialCount, + }; + + if (!cmd || !scene.Scene) return result; + + std::vector batches; + + for (const auto& prepared : scene.Items) + { + if (!prepared.Item || !prepared.Proxy) continue; + + const auto& item = *prepared.Item; + const auto* geometry = scene.Scene->FindGeometry(item.GeometryIndex); + EE_CORE_ASSERT(geometry, "Material render item geometry is unavailable."); + if (!geometry) continue; + + const auto program = GetProgramKey(item.Pass, *prepared.Proxy); + EE_CORE_ASSERT(program, "Material render item does not support its requested pass.") + if (!program) continue; + + const SBatchKey key{ + .Pass = item.Pass, + .GeometryIndex = item.GeometryIndex, + .Program = *program + }; + + auto batch = std::ranges::find_if( + batches, + [&key](const SBatch& candidate) + { + return candidate.Key == key; + } + ); + + if (batch == batches.end()) + { + batches.push_back({ .Key = key }); + batch = std::prev(batches.end()); + } + + batch->Items.push_back(&prepared); + } + + std::ranges::stable_sort( + batches, + [](const SBatch& left, const SBatch& right) + { + if (left.Key.Pass != right.Key.Pass) + return GetPassOrder(left.Key.Pass) < GetPassOrder(right.Key.Pass); + + if (left.Key.GeometryIndex != right.Key.GeometryIndex) + return left.Key.GeometryIndex < right.Key.GeometryIndex; + + return std::less{}( + left.Key.Program.Identity, + right.Key.Program.Identity + ); + } + ); + + for (const auto& batch : batches) + { + if (batch.Items.empty()) continue; + + const auto* geometry = scene.Scene->FindGeometry(batch.Key.GeometryIndex); + if (!geometry) continue; + + const auto& first = *batch.Items.front(); + const auto prepared = PreparePass({ + .Pass = batch.Key.Pass, + .Material = first.Proxy.get(), + .Pipeline = geometry->Pipeline, + .ExternalResources = { + .ConstantBuffers = geometry->ConstantBuffers, + .StorageBuffers = geometry->StorageBuffers, + }, + .MaterialBuffer = GetActiveMaterialBuffer(), + .InitialPushConstants = std::span{ + first.Item->PushConstants.Data.data(), + first.Item->PushConstants.Size + }, + }); + if (!prepared) continue; + + ++result.BatchCount; + prepared->Pipeline->Bind(cmd); + + for (const auto& binding : geometry->VertexBuffers) + { + cmd->BindBuffer( + binding.Buffer, + std::span{}, + 1, + binding.Binding + ); + } + + for (const auto* resolved : batch.Items) + { + const auto constants = resolved->Item->PushConstants.Resolve( + resolved->MaterialIndex + ); + + prepared->Shader->SetPushConstant( + cmd, + "pc", + const_cast(constants.data()), + resolved->Item->PushConstants.Size + ); + + cmd->Draw( + resolved->Item->Draw.VertexCount, + resolved->Item->Draw.InstanceCount, + resolved->Item->Draw.FirstVertex, + resolved->Item->Draw.FirstInstance + ); + + ++result.DrawCount; + } + } + + return result; + } + + std::optional Renderer::PreparePass(const SPassRequest& request) + { + if (!request.Material || !request.Pipeline.VertexLayout || !request.MaterialBuffer) + return std::nullopt; + + const auto program = GetProgramKey(request.Pass, *request.Material); + if (!program) + return std::nullopt; + + const auto compiled = request.Material->GetCompiledMaterial(); + const auto& shader = compiled->GetShader(GetUsage(request.Pass)); + + if (!BindDescriptorResources(shader, request)) + return std::nullopt; + + if (!request.InitialPushConstants.empty()) + { + shader->SetPushConstant( + "pc", + const_cast(static_cast(request.InitialPushConstants.data())), + request.InitialPushConstants.size() + ); + } + + return SPreparedPass{ + .Shader = shader, + .Pipeline = GetPipeline(request.Pass, shader, request.Pipeline), + }; + } + + const Ref& Renderer::GetActiveMaterialBuffer() const + { + EE_CORE_ASSERT(m_Context, "Material renderer graphics context is unavailable.") + return m_FrameSlots.GetCurrent().MaterialBuffer; + } + + Ref Renderer::GetPipeline( + const EMaterialPass pass, + const Ref& shader, + const SPipelineRequest& request + ) + { + const SPipelineKey key{ + .Pass = pass, + .Shader = shader.get(), + .VertexLayoutKey = request.VertexLayoutKey, + }; + + if (const auto found = m_Pipelines.find(key); found != m_Pipelines.end()) + return found->second; + + PipelineBuilder builder; + builder.SetShader(shader); + builder.SetInputTopology(EPrimitiveTopology::TriangleList); + builder.SetPolygonMode(EPolygonMode::Fill); + builder.SetColorAttachmentFormat(EImageFormat::R8G8B8A8_SRGB); + builder.SetDepthAttachmentFormat(EDepthStencilImageFormat::D32_SFLOAT); + builder.SetBufferLayout(*request.VertexLayout); + + switch (pass) + { + case EMaterialPass::ParticleSprite: + builder.SetCullMode(ECullMode::None, EFrontFace::CounterClockwise); + builder.EnableAlphaBlending(); + builder.DisableDepthTest(); + break; + case EMaterialPass::ParticleRibbon: + builder.SetCullMode(ECullMode::None, EFrontFace::CounterClockwise); + builder.EnableAlphaBlendingMax(); + builder.DisableDepthTest(); + break; + case EMaterialPass::ParticleMesh: + builder.SetCullMode(ECullMode::Back, EFrontFace::CounterClockwise); + builder.EnableAlphaBlendingMax(); + break; + } + + auto info = builder.GetCreateInfo(); + + if (pass == EMaterialPass::ParticleMesh) + { + info.DepthStencil.DepthTestEnable = true; + info.DepthStencil.DepthWriteEnable = true; + info.DepthStencil.DepthCompareOp = ECompareOp::LessOrEqual; + } + + const auto pipeline = GraphicsPipeline::Create(m_Context, info); + m_Pipelines.emplace(key, pipeline); + return pipeline; + } + + bool Renderer::BindDescriptorResources( + const Ref& shader, + const SPassRequest& request + ) + { + SDescriptorBindingState state{ + .Pass = request.Pass, + }; + + state.ExternalResources.reserve( + request.ExternalResources.GetResourceCount() + ); + + for (const auto& binding : request.ExternalResources.ConstantBuffers) + { + state.ExternalResources.push_back({ + .Name = std::string(binding.Name), + .Resource = binding.Buffer.get(), + .Type = EDescriptorBindingType::ConstantBuffer, + }); + } + + for (const auto& binding : request.ExternalResources.StorageBuffers) + { + std::visit( + [&state, &binding](const auto& buffer) + { + using TBuffer = std::remove_cvref_t; + + state.ExternalResources.push_back({ + .Name = std::string(binding.Name), + .Resource = buffer.get(), + .Type = std::is_same_v> + ? EDescriptorBindingType::StorageBuffer + : EDescriptorBindingType::DynamicStorageBuffer + }); + }, + binding.Buffer + ); + } + + const auto found = m_DescriptorBindings.find(shader.get()); + if (found != m_DescriptorBindings.end()) + { + if (found->second != state) + { + EE_CORE_ERROR("Material shader descriptor bindings changed after initialization.") + return false; + } + + if (shader->HasBinding("materials")) + shader->BindStorageBuffer("materials", request.MaterialBuffer); + + return true; + } + + for (const auto& binding : request.ExternalResources.ConstantBuffers) + { + shader->BindConstantBuffer(std::string(binding.Name), binding.Buffer); + } + + for (const auto& binding : request.ExternalResources.StorageBuffers) + { + std::visit( + [&shader, &binding](const auto& buffer) + { + shader->BindStorageBuffer(std::string(binding.Name), buffer); + }, + binding.Buffer + ); + } + + if (shader->HasBinding("materials")) + shader->BindStorageBuffer("materials", request.MaterialBuffer); + if (shader->HasBinding("sprites")) + shader->BindTextureSet("sprites", m_Textures.GetTextureSet()); + if (shader->HasBinding("spriteSampler")) + shader->BindSampler("spriteSampler", m_Textures.GetSampler()); + + m_DescriptorBindings.emplace(shader.get(), std::move(state)); + return true; + } +} diff --git a/Elixir/Source/Engine/Materials/Rendering/Renderer.h b/Elixir/Source/Engine/Materials/Rendering/Renderer.h new file mode 100644 index 00000000..490c9d94 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Rendering/Renderer.h @@ -0,0 +1,357 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace Elixir { class ShaderLoader; } +namespace Elixir::Materials { struct SMaterialSystemConfig; } + +namespace Elixir::Materials::Rendering +{ + class MaterialRenderScene; + struct SRenderItem; + + /** + * @brief Identifies a material render pass. + */ + enum class EMaterialPass : uint8_t + { + ParticleSprite, + ParticleRibbon, + ParticleMesh, + }; + + /** + * @brief Identifies a compiled material program. + * + * The identity is suitable for grouping render items that use the same shader. + */ + struct SProgramKey + { + /** Opaque identity of the compiled shader program. */ + const void* Identity = nullptr; + + /** @brief Checks whether this key identifies a program. */ + explicit operator bool() const { return Identity != nullptr; } + + /** @brief Compares two program keys. */ + bool operator==(const SProgramKey&) const = default; + }; + + /** + * @brief Associates a scene draw with resolved material data. + */ + struct SResolvedRenderItem + { + /** Source draw item owned by the render scene. */ + const SRenderItem* Item = nullptr; + + /** Immutable material data resolved by MaterialSystem. */ + Ref Proxy; + }; + + /** @brief Stores material proxies resolved for one render scene. */ + struct SPreparedScene + { + /** Scene that owns geometry and source draw items. */ + const MaterialRenderScene* Scene = nullptr; + + /** Draw items with their already resolved material proxies. */ + std::vector Items; + }; + + /** + * @brief Reports the work recorded by a material renderer. + */ + struct SRenderResult + { + /** Number of materials uploaded for the rendered scene. */ + uint32_t MaterialCount = 0; + + /** Number of material batches rendered. */ + uint32_t BatchCount = 0; + + /** Number of draw commands recorded. */ + uint32_t DrawCount = 0; + }; + + /** + * @brief Describes the vertex input layout for a material pipeline. + */ + struct SPipelineRequest + { + /** Stable key that identifies the vertex layout. */ + uint64_t VertexLayoutKey = 0; + + /** Vertex layout used to create the graphics pipeline. */ + const BufferLayout* VertexLayout = nullptr; + }; + + /** + * @brief Associates a constant buffer with a shader binding name. + */ + struct SConstantBufferBinding + { + /** Shader binding name. */ + std::string_view Name; + + /** Constant buffer to bind. */ + Ref Buffer; + }; + + /** @brief Holds a storage-buffer type supported by material passes. */ + using MaterialStorageBuffer = std::variant, Ref>; + + /** + * @brief Associates a storage buffer with a shader binding name. + */ + struct SStorageBufferBinding + { + /** Shader binding name. */ + std::string_view Name; + + /** Storage buffer to bind. */ + MaterialStorageBuffer Buffer; + }; + + /** + * @brief Groups external buffers required by a material pass. + */ + struct SExternalResources + { + std::span ConstantBuffers; + std::span StorageBuffers; + + /** + * @brief Returns the number of external resource bindings. + * @return Total number of constant and storage buffer bindings. + */ + uint32_t GetResourceCount() const + { + return (uint32_t)(ConstantBuffers.size() + StorageBuffers.size()); + } + }; + + /** + * @brief Describes the resources required to prepare one material pass. + */ + struct SPassRequest + { + /** Material pass to prepare. */ + EMaterialPass Pass = EMaterialPass::ParticleSprite; + + /** Resolved material data for the pass. */ + const MaterialRenderProxy* Material = nullptr; + + /** Pipeline requirements for the pass. */ + SPipelineRequest Pipeline; + + /** External buffers required by the pass. */ + SExternalResources ExternalResources; + + /** Per-frame buffer that stores resolved material data. */ + Ref MaterialBuffer; + + /** Push constants applied before the first draw. */ + std::span InitialPushConstants; + }; + + /** + * @brief Stores a prepared shader and graphics pipeline. + */ + struct SPreparedPass + { + /** Shader prepared for the material pass. */ + Ref Shader; + + /** Graphics pipeline prepared for the material pass. */ + Ref Pipeline; + + /** @brief Checks whether the shader and pipeline are available. */ + explicit operator bool() const { return Shader && Pipeline; } + }; + + /** + * @brief Owns frame material resources and records material draw commands. + * + * The renderer owns texture bindings, per-frame material buffers, descriptor + * bindings, graphics pipelines, and secondary command buffers. + */ + class ELIXIR_API Renderer final + { + public: + /** + * @brief Creates a material renderer. + * @param context Graphics context used to create pipelines. + * @param materialCapacity Maximum unique materials supported by one scene. + * @pre All arguments are valid for the renderer lifetime. + */ + Renderer( + const GraphicsContext* context, + uint32_t materialCapacity + ); + + /** @brief Selects resources for the current graphics frame. */ + void BeginFrame(); + + /** + * @brief Records all resolved material scenes for the current frame. + * @param scenes Scenes with material proxies resolved by MaterialSystem. + * @return Counts of materials, batches, and draw commands recorded. + */ + SRenderResult RenderFrame(std::span scenes); + + /** + * @brief Returns the program key for a material pass. + * @param pass Material pass. + * @param material Resolved material data. + * @return Program key, or no value if the material does not support the pass. + */ + static std::optional GetProgramKey( + EMaterialPass pass, + const MaterialRenderProxy& material + ); + + /** + * @brief Returns the material usage required by a render pass. + * @param pass Material pass. + * @return Corresponding material usage. + */ + static EMaterialUsage GetUsage(EMaterialPass pass); + + /** + * @brief Returns the draw order for a material pass. + * + * Lower values are rendered first. + * + * @param pass Material pass. + * @return Render-order value for the pass. + */ + static uint32_t GetPassOrder(EMaterialPass pass); + + private: + /** Identifies the type of a cached descriptor binding. */ + enum class EDescriptorBindingType : uint8_t + { + ConstantBuffer, + StorageBuffer, + DynamicStorageBuffer, + }; + + /** Describes one descriptor binding used by a shader. */ + struct SDescriptorBinding + { + std::string Name; + const void* Resource = nullptr; + EDescriptorBindingType Type = EDescriptorBindingType::ConstantBuffer; + + bool operator==(const SDescriptorBinding&) const = default; + }; + + /** Stores the descriptor bindings established for a shader. */ + struct SDescriptorBindingState + { + EMaterialPass Pass = EMaterialPass::ParticleSprite; + std::vector ExternalResources; + + bool operator==(const SDescriptorBindingState&) const = default; + }; + + /** Identifies a cached graphics pipeline. */ + struct SPipelineKey + { + EMaterialPass Pass = EMaterialPass::ParticleSprite; + const Shader* Shader = nullptr; + uint64_t VertexLayoutKey = 0; + + bool operator==(const SPipelineKey&) const = default; + }; + + /** Hashes a graphics pipeline key. */ + struct SPipelineKeyHasher + { + size_t operator()(const SPipelineKey& key) const + { + size_t hash = Hash::Hash(static_cast(key.Pass)); + Hash::HashCombine(hash, Hash::Hash(key.Shader)); + Hash::HashCombine(hash, Hash::Hash(key.VertexLayoutKey)); + return hash; + } + }; + + /** Stores data needed to record one material draw. */ + struct SPreparedRenderItem + { + const SRenderItem* Item = nullptr; + Ref Proxy; + uint32_t MaterialIndex = UINT32_MAX; + }; + + /** Stores frame-buffer indices assigned to one render scene. */ + struct SPreparedRenderScene + { + const MaterialRenderScene* Scene = nullptr; + std::vector Items; + uint32_t MaterialCount = 0; + }; + + /** Stores resources that are safe to reuse for one graphics frame slot. */ + struct SFrameSlot + { + Ref MaterialBuffer; + }; + + struct SBatchKey + { + EMaterialPass Pass = EMaterialPass::ParticleSprite; + uint32_t GeometryIndex = UINT32_MAX; + SProgramKey Program; + + bool operator==(const SBatchKey&) const = default; + }; + + struct SBatch + { + SBatchKey Key; + std::vector Items; + }; + + /** Builds and uploads the material table for one resolved scene. */ + SPreparedRenderScene PrepareScene(const SPreparedScene& scene); + + /** Records draw commands for a scene with prepared material indices. */ + SRenderResult RecordScene( + const Ref& cmd, + const SPreparedRenderScene& scene + ); + + /** Prepares a shader and graphics pipeline for a material pass. */ + std::optional PreparePass(const SPassRequest& request); + + /** Returns the buffer for the active graphics frame slot. */ + const Ref& GetActiveMaterialBuffer() const; + + /** Returns a cached pipeline or creates one for the request. */ + Ref GetPipeline( + EMaterialPass pass, + const Ref& shader, + const SPipelineRequest& request + ); + + /** Binds and validates the descriptor resources for a shader. */ + bool BindDescriptorResources(const Ref& shader, const SPassRequest& request); + + uint32_t m_MaterialCapacity = 0; + FrameSlotState m_FrameSlots; + TextureRegistry m_Textures; + std::unordered_map, SPipelineKeyHasher> m_Pipelines; + std::unordered_map m_DescriptorBindings; + + uint64_t m_CurrentFrameNumber = UINT64_MAX; + const GraphicsContext* m_Context = nullptr; + }; +} diff --git a/Elixir/Source/Engine/Materials/Rendering/TextureRegistry.cpp b/Elixir/Source/Engine/Materials/Rendering/TextureRegistry.cpp new file mode 100644 index 00000000..5b834105 --- /dev/null +++ b/Elixir/Source/Engine/Materials/Rendering/TextureRegistry.cpp @@ -0,0 +1,63 @@ +#include "epch.h" +#include "TextureRegistry.h" + +#include +#include + +namespace Elixir::Materials::Rendering +{ + TextureRegistry::TextureRegistry(const GraphicsContext* context) + : m_Textures(TextureSet::Create(context)), + m_Sampler(SamplerBuilder().Build(context)), + m_GraphicsContext(context) + { + const auto whiteTexture = Texture2D::Create( + m_GraphicsContext, + EImageFormat::R8G8B8A8_SRGB, + 1, + 1, + &Color::WhiteAlpha + ); + + m_FallbackTextureHandle = m_Textures->AddTexture(whiteTexture); + } + + void TextureRegistry::BeginFrame(const uint64_t submissionSerial) + { + m_SubmissionSerial = submissionSerial; + } + + uint32_t TextureRegistry::Resolve(const Ref& texture) + { + if (!texture) + return GetFallbackIndex(); + + const auto found = m_Bindings.find(texture); + if (found != m_Bindings.end()) + return Find(texture); + + // Bindless descriptor updates become visible before the next render callback. + const auto handle = m_Textures->AddTexture(texture); + m_Bindings.emplace(texture, STextureBinding{ + .Handle = handle, + .ReadySubmission = m_SubmissionSerial + 1, + }); + + return GetFallbackIndex(); + } + + uint32_t TextureRegistry::Find(const Ref& texture) const + { + if (!texture) + return GetFallbackIndex(); + + const auto found = m_Bindings.find(texture); + if (found == m_Bindings.end()) + return GetFallbackIndex(); + + return found->second.GetIndexForSubmission( + m_SubmissionSerial, + GetFallbackIndex() + ); + } +} diff --git a/Elixir/Source/Engine/Materials/Rendering/TextureRegistry.h b/Elixir/Source/Engine/Materials/Rendering/TextureRegistry.h new file mode 100644 index 00000000..5f10e82c --- /dev/null +++ b/Elixir/Source/Engine/Materials/Rendering/TextureRegistry.h @@ -0,0 +1,94 @@ +#pragma once + +#include +#include + +namespace Elixir::Materials::Rendering +{ + /** + * @brief Stores a texture binding and submission where it becomes available. + */ + struct STextureBinding + { + /** @brief Handle of the texture in the texture set. */ + SResourceHandle Handle{}; + + /** @brief First submission that can use Handle. */ + uint64_t ReadySubmission = 0; + + /** + * @brief Gets the texture index that is safe for a submission. + * @param submissionSerial Serial of the submission being prepared. + * @param fallbackIndex Index to use before the binding is available. + * @return The texture index, or fallbackIndex when the binding is not ready. + */ + uint32_t GetIndexForSubmission( + const uint64_t submissionSerial, + const uint32_t fallbackIndex + ) const + { + return ReadySubmission <= submissionSerial + ? Handle.Index + : fallbackIndex; + } + }; + + /** + * @brief Manages bindless texture bindings used by material rendering. + * + * Newly added bindings use the fallback texture until descriptor updates become + * visible to a later submission. + */ + class ELIXIR_API TextureRegistry final + { + public: + /** + * @brief Creates a registry and its fallback texture resources. + * @param context Graphics context that owns the texture resources. + * @pre context is valid. + */ + explicit TextureRegistry(const GraphicsContext* context); + + /** + * @brief Starts texture resolution for a frame submission. + * @param submissionSerial Serial of the submission being prepared. + */ + void BeginFrame(uint64_t submissionSerial); + + /** + * @brief Resolves a texture to an index in the material texture set. + * + * A newly registered texture returns the fallback index until it is ready. + * + * @param texture Texture to resolve. + * @return A usable texture index. + */ + uint32_t Resolve(const Ref& texture); + + /** + * @brief Finds the usable index for an already resolved texture. + * @param texture Texture to find. + * @return The texture index, or the fallback index when unavailable. + */ + uint32_t Find(const Ref& texture) const; + + /** @brief Gets the index of the fallback texture. */ + uint32_t GetFallbackIndex() const { return m_FallbackTextureHandle.Index; } + + /** @brief Gets the texture set used by material rendering. */ + const Ref& GetTextureSet() const { return m_Textures; } + + /** @brief Gets the sampler used with material textures. */ + const Ref& GetSampler() const { return m_Sampler; } + + private: + Ref m_Textures; + Ref m_Sampler; + SResourceHandle m_FallbackTextureHandle; + std::unordered_map, STextureBinding> m_Bindings; + + uint64_t m_SubmissionSerial = 0; + + const GraphicsContext* m_GraphicsContext; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Graphics/Vulkan/VulkanDescriptorPool.cpp b/Elixir/Source/Graphics/Vulkan/VulkanDescriptorPool.cpp index d9218825..1a048afc 100644 --- a/Elixir/Source/Graphics/Vulkan/VulkanDescriptorPool.cpp +++ b/Elixir/Source/Graphics/Vulkan/VulkanDescriptorPool.cpp @@ -38,10 +38,15 @@ namespace Elixir::Vulkan ); } - void VulkanBaseDescriptorPool::DestroyPool() const + void VulkanBaseDescriptorPool::DestroyPool() { EE_PROFILE_ZONE_SCOPED() + + if (m_Pool == VK_NULL_HANDLE) + return; + vkDestroyDescriptorPool(m_GraphicsContext->GetDevice(), m_Pool, nullptr); + m_Pool = VK_NULL_HANDLE; } /* VulkanDescriptorPool */ @@ -462,4 +467,4 @@ namespace Elixir::Vulkan return 0; } } -} \ No newline at end of file +} diff --git a/Elixir/Source/Graphics/Vulkan/VulkanDescriptorPool.h b/Elixir/Source/Graphics/Vulkan/VulkanDescriptorPool.h index 37a04558..5ccd833a 100644 --- a/Elixir/Source/Graphics/Vulkan/VulkanDescriptorPool.h +++ b/Elixir/Source/Graphics/Vulkan/VulkanDescriptorPool.h @@ -33,7 +33,7 @@ namespace Elixir::Vulkan std::vector sizes, VkDescriptorPoolCreateFlags flags ); - virtual void DestroyPool() const; + virtual void DestroyPool(); uint32_t m_MaxSets; @@ -163,4 +163,4 @@ namespace Elixir::Vulkan uint32_t m_TextureCount = 0; mutable std::mutex m_TextureMutex; }; -} \ No newline at end of file +} diff --git a/Elixir/Source/Graphics/Vulkan/VulkanGraphicsContext.cpp b/Elixir/Source/Graphics/Vulkan/VulkanGraphicsContext.cpp index aa5567b0..ddeff100 100644 --- a/Elixir/Source/Graphics/Vulkan/VulkanGraphicsContext.cpp +++ b/Elixir/Source/Graphics/Vulkan/VulkanGraphicsContext.cpp @@ -87,7 +87,7 @@ namespace Elixir EE_CORE_INFO("Vulkan Renderer:") EE_CORE_INFO(" Vendor: {0}", DeviceUtils::GetVendorName(m_GPUProperties.vendorID)); - EE_CORE_INFO(" Renderer: {0}", m_GPUProperties.deviceName) + EE_CORE_INFO(" Renderer: {0}", std::string_view{ m_GPUProperties.deviceName }) EE_CORE_INFO(" Version: {0}", DeviceUtils::GetApiVersion(m_GPUProperties.apiVersion)); m_IsInitialized = true; @@ -178,7 +178,7 @@ namespace Elixir m_Executor->ShutdownRenderPool(); WaitDeviceIdle(); - WaitForAllFrames(); + ResetFrameUsageState(); } void VulkanGraphicsContext::SetClearColor(const glm::vec4& color) @@ -231,6 +231,12 @@ namespace Elixir m_CommandPoolManager->EnqueueSecondaryCommandBuffer(cmd); } + void VulkanGraphicsContext::WaitDeviceIdle() const + { + EE_PROFILE_ZONE_SCOPED() + VK_CHECK_RESULT(vkDeviceWaitIdle(m_Device)); + } + void VulkanGraphicsContext::InitVulkan() { EE_PROFILE_ZONE_SCOPED() @@ -394,7 +400,7 @@ namespace Elixir { VK_DESCRIPTOR_TYPE_SAMPLER, 0.05 } }; - m_DescriptorPool = CreateRef(*this, 128, sizes); + m_DescriptorPool = CreateRef(*this, 128 * FRAMES, sizes); m_BindlessDescriptorPool = CreateRef(*this); } @@ -499,13 +505,7 @@ namespace Elixir m_DepthStencilRenderTarget = CreateRef(this, depthStencilInfo); } - void VulkanGraphicsContext::WaitDeviceIdle() const - { - EE_PROFILE_ZONE_SCOPED() - VK_CHECK_RESULT(vkDeviceWaitIdle(m_Device)); - } - - void VulkanGraphicsContext::WaitForAllFrames() + void VulkanGraphicsContext::ResetFrameUsageState() { EE_PROFILE_ZONE_SCOPED() diff --git a/Elixir/Source/Graphics/Vulkan/VulkanGraphicsContext.h b/Elixir/Source/Graphics/Vulkan/VulkanGraphicsContext.h index 4e06a861..2662ec5e 100644 --- a/Elixir/Source/Graphics/Vulkan/VulkanGraphicsContext.h +++ b/Elixir/Source/Graphics/Vulkan/VulkanGraphicsContext.h @@ -70,6 +70,8 @@ namespace Elixir::Vulkan Ref GetUploadCommandBuffer() const override; void EnqueueSecondaryCommandBuffer(const Ref& cmd) const override; + void WaitDeviceIdle() const override; + Extent3D GetSwapchainExtent() const override { return m_SwapchainExtent;} SFrameData& GetCurrentFrame() { return m_Frames[GetFrameIndex()]; } @@ -103,8 +105,7 @@ namespace Elixir::Vulkan void CreateRenderTargets() override; - void WaitDeviceIdle() const; - void WaitForAllFrames(); + void ResetFrameUsageState(); bool Prepare(); void Submit(); diff --git a/Elixir/Source/Graphics/Vulkan/VulkanShader.cpp b/Elixir/Source/Graphics/Vulkan/VulkanShader.cpp index de2a7dc3..dd4e3630 100644 --- a/Elixir/Source/Graphics/Vulkan/VulkanShader.cpp +++ b/Elixir/Source/Graphics/Vulkan/VulkanShader.cpp @@ -9,11 +9,13 @@ #include "VulkanTextureSet.h" #include +#include namespace Elixir::Vulkan { VulkanShader::VulkanShader(const GraphicsContext* context, SShaderCreateInfo&& info) - : Shader(context, std::move(info)) + : Shader(context, std::move(info)), + m_DescriptorSets(*context) { EE_PROFILE_ZONE_SCOPED() m_GraphicsContext = static_cast(context); @@ -33,11 +35,11 @@ namespace Elixir::Vulkan nullptr ); - if (!m_DescriptorSets.empty()) + m_DescriptorSets.ForEach([this](auto& sets) { - m_GraphicsContext->GetDescriptorPool()->FreeDescriptorSets(m_DescriptorSets); - m_DescriptorSets.clear(); - } + if (!sets.empty()) + m_GraphicsContext->GetDescriptorPool()->FreeDescriptorSets(sets); + }); for (const auto& layout : m_DescriptorSetLayouts) { @@ -55,6 +57,7 @@ namespace Elixir::Vulkan { const auto vkCmd = static_pointer_cast(cmd); + ApplyPendingDescriptorState(); vkCmd->BindDescriptorSets(pipeline, m_PipelineLayout, 0, GetDescriptorSets()); for (const auto& [binding, constant] : m_Resources.PushConstants) @@ -146,8 +149,8 @@ namespace Elixir::Vulkan { if (const auto binding = GetShaderBinding(name)) { - m_Textures[*binding] = texture; - UpdateDescriptorSet(*binding, texture.get()); + if (m_DescriptorSets.Set(*binding, DescriptorValue{ texture })) + m_Textures[*binding] = texture; return; } @@ -169,8 +172,8 @@ namespace Elixir::Vulkan { if (const auto binding = GetShaderBinding(name)) { - m_Samplers[*binding] = sampler; - UpdateDescriptorSet(*binding, sampler); + if (m_DescriptorSets.Set(*binding, DescriptorValue{ sampler })) + m_Samplers[*binding] = sampler; return; } @@ -184,8 +187,8 @@ namespace Elixir::Vulkan { if (const auto binding = GetShaderBinding(name)) { - m_StorageBuffers[*binding] = buffer; - UpdateDescriptorSet(*binding, buffer); + if (m_DescriptorSets.Set(*binding, DescriptorValue{ buffer })) + m_StorageBuffers[*binding] = buffer; return; } @@ -199,8 +202,8 @@ namespace Elixir::Vulkan { if (const auto binding = GetShaderBinding(name)) { - m_DynStorageBuffers[*binding] = buffer; - UpdateDescriptorSet(*binding, buffer); + if (m_DescriptorSets.Set(*binding, DescriptorValue{ buffer })) + m_DynStorageBuffers[*binding] = buffer; return; } @@ -214,8 +217,8 @@ namespace Elixir::Vulkan { if (const auto binding = GetShaderBinding(name)) { - m_ConstantBuffers[*binding] = buffer; - UpdateDescriptorSet(*binding, buffer); + if (m_DescriptorSets.Set(*binding, DescriptorValue{ buffer })) + m_ConstantBuffers[*binding] = buffer; return; } @@ -224,7 +227,7 @@ namespace Elixir::Vulkan std::vector VulkanShader::GetDescriptorSets() const { - std::vector sets(m_DescriptorSets); + std::vector sets(m_DescriptorSets.GetCurrent()); if (m_BindlessSet) { const auto bindlessPool = m_GraphicsContext->GetBindlessDescriptorPool(); @@ -339,26 +342,28 @@ namespace Elixir::Vulkan if (m_DescriptorSetLayouts.empty()) return; - m_DescriptorSets.resize(m_DescriptorSetLayouts.size()); - - for (auto i = 0; i < m_DescriptorSetLayouts.size(); i++) + m_DescriptorSets.ForEach([this](auto& sets) { - VkDescriptorSetAllocateInfo allocInfo = {}; - allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; - allocInfo.pSetLayouts = &m_DescriptorSetLayouts[i]; - allocInfo.descriptorSetCount = 1; - allocInfo.descriptorPool = m_GraphicsContext->GetDescriptorPool()->GetVulkanDescriptorPool(); - - VK_CHECK_RESULT( - vkAllocateDescriptorSets( - m_GraphicsContext->GetDevice(), - &allocInfo, - &m_DescriptorSets[i] - ) - ); - } + sets.resize(m_DescriptorSetLayouts.size()); - UpdateDescriptorSets(); + for (auto i = 0; i < m_DescriptorSetLayouts.size(); ++i) + { + VkDescriptorSetAllocateInfo allocInfo = {}; + allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + allocInfo.pSetLayouts = &m_DescriptorSetLayouts[i]; + allocInfo.descriptorSetCount = 1; + allocInfo.descriptorPool = + m_GraphicsContext->GetDescriptorPool()->GetVulkanDescriptorPool(); + + VK_CHECK_RESULT( + vkAllocateDescriptorSets( + m_GraphicsContext->GetDevice(), + &allocInfo, + &sets[i] + ) + ); + } + }); } void VulkanShader::CreatePipelineLayout() @@ -390,38 +395,36 @@ namespace Elixir::Vulkan ); } - void VulkanShader::UpdateDescriptorSets() + void VulkanShader::ApplyPendingDescriptorState() { - std::vector writeDescriptorSets; - - for (const auto [binding, texture] : m_Textures) - { - const auto writeSet = GetWriteDescriptorSet(binding, texture.get()); - writeDescriptorSets.push_back(writeSet); - } - - for (const auto [binding, buffer] : m_StorageBuffers) + m_DescriptorSets.ApplyPendingState([this](auto&, const auto changes) { - const auto writeSet = GetWriteDescriptorSet(binding, buffer); - writeDescriptorSets.push_back(writeSet); - } + std::vector writes; + writes.reserve(changes.size()); - for (const auto [binding, buffer] : m_DynStorageBuffers) - { - const auto writeSet = GetWriteDescriptorSet(binding, buffer); - writeDescriptorSets.push_back(writeSet); - } - - for (const auto [binding, buffer] : m_ConstantBuffers) - { - const auto writeSet = GetWriteDescriptorSet(binding, buffer); - writeDescriptorSets.push_back(writeSet); - } + for (const auto& change : changes) + { + std::visit( + [this, &writes, binding = change.Key](const auto& value) + { + using Value = std::decay_t; + if constexpr (std::is_same_v>) + writes.push_back(GetWriteDescriptorSet(binding, value.get())); + else + writes.push_back(GetWriteDescriptorSet(binding, value)); + }, + change.Value + ); + } - vkUpdateDescriptorSets( - m_GraphicsContext->GetDevice(), writeDescriptorSets.size(), - writeDescriptorSets.data(), 0, nullptr - ); + vkUpdateDescriptorSets( + m_GraphicsContext->GetDevice(), + static_cast(writes.size()), + writes.data(), + 0, + nullptr + ); + }); } VkWriteDescriptorSet VulkanShader::GetWriteDescriptorSet( @@ -446,7 +449,7 @@ namespace Elixir::Vulkan VkWriteDescriptorSet writeSet = {}; writeSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writeSet.dstSet = m_DescriptorSets[resource.GetSet()]; + writeSet.dstSet = m_DescriptorSets.GetCurrent()[resource.GetSet()]; writeSet.dstBinding = resource.GetBinding(); writeSet.descriptorType = Converters::GetDescriptorType(resource.GetType()); writeSet.descriptorCount = m_ImageInfoCache[binding].size(); @@ -455,22 +458,6 @@ namespace Elixir::Vulkan return writeSet; } - void VulkanShader::UpdateDescriptorSet( - const SShaderBinding binding, - const Texture* texture - ) const - { - const auto writeSet = GetWriteDescriptorSet(binding, texture); - - vkUpdateDescriptorSets( - m_GraphicsContext->GetDevice(), - 1, - &writeSet, - 0, - nullptr - ); - } - VkWriteDescriptorSet VulkanShader::GetWriteDescriptorSet( const SShaderBinding binding, const Ref& sampler @@ -493,7 +480,7 @@ namespace Elixir::Vulkan VkWriteDescriptorSet writeSet = {}; writeSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writeSet.dstSet = m_DescriptorSets[resource.GetSet()]; + writeSet.dstSet = m_DescriptorSets.GetCurrent()[resource.GetSet()]; writeSet.dstBinding = resource.GetBinding(); writeSet.descriptorType = Converters::GetDescriptorType(resource.GetType()); writeSet.descriptorCount = m_ImageInfoCache[binding].size(); @@ -502,22 +489,6 @@ namespace Elixir::Vulkan return writeSet; } - void VulkanShader::UpdateDescriptorSet( - const SShaderBinding binding, - const Ref& sampler - ) const - { - const auto writeSet = GetWriteDescriptorSet(binding, sampler); - - vkUpdateDescriptorSets( - m_GraphicsContext->GetDevice(), - 1, - &writeSet, - 0, - nullptr - ); - } - VkWriteDescriptorSet VulkanShader::GetWriteDescriptorSet( const SShaderBinding binding, const Ref& buffer @@ -530,7 +501,7 @@ namespace Elixir::Vulkan VkWriteDescriptorSet writeSet = {}; writeSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writeSet.dstSet = m_DescriptorSets[resource.GetSet()]; + writeSet.dstSet = m_DescriptorSets.GetCurrent()[resource.GetSet()]; writeSet.dstBinding = resource.GetBinding(); writeSet.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; writeSet.descriptorCount = 1; @@ -539,22 +510,6 @@ namespace Elixir::Vulkan return writeSet; } - void VulkanShader::UpdateDescriptorSet( - const SShaderBinding binding, - const Ref& buffer - ) const - { - const auto writeSet = GetWriteDescriptorSet(binding, buffer); - - vkUpdateDescriptorSets( - m_GraphicsContext->GetDevice(), - 1, - &writeSet, - 0, - nullptr - ); - } - VkWriteDescriptorSet VulkanShader::GetWriteDescriptorSet( const SShaderBinding binding, const Ref& buffer @@ -567,7 +522,7 @@ namespace Elixir::Vulkan VkWriteDescriptorSet writeSet = {}; writeSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writeSet.dstSet = m_DescriptorSets[resource.GetSet()]; + writeSet.dstSet = m_DescriptorSets.GetCurrent()[resource.GetSet()]; writeSet.dstBinding = resource.GetBinding(); writeSet.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; writeSet.descriptorCount = 1; @@ -576,22 +531,6 @@ namespace Elixir::Vulkan return writeSet; } - void VulkanShader::UpdateDescriptorSet( - const SShaderBinding binding, - const Ref& buffer - ) const - { - const auto writeSet = GetWriteDescriptorSet(binding, buffer); - - vkUpdateDescriptorSets( - m_GraphicsContext->GetDevice(), - 1, - &writeSet, - 0, - nullptr - ); - } - VkWriteDescriptorSet VulkanShader::GetWriteDescriptorSet( const SShaderBinding binding, const Ref& buffer @@ -604,7 +543,7 @@ namespace Elixir::Vulkan VkWriteDescriptorSet writeSet = {}; writeSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writeSet.dstSet = m_DescriptorSets[resource.GetSet()]; + writeSet.dstSet = m_DescriptorSets.GetCurrent()[resource.GetSet()]; writeSet.dstBinding = resource.GetBinding(); writeSet.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; writeSet.descriptorCount = 1; @@ -612,20 +551,4 @@ namespace Elixir::Vulkan return writeSet; } - - void VulkanShader::UpdateDescriptorSet( - const SShaderBinding binding, - const Ref& buffer - ) const - { - const auto writeSet = GetWriteDescriptorSet(binding, buffer); - - vkUpdateDescriptorSets( - m_GraphicsContext->GetDevice(), - 1, - &writeSet, - 0, - nullptr - ); - } -} \ No newline at end of file +} diff --git a/Elixir/Source/Graphics/Vulkan/VulkanShader.h b/Elixir/Source/Graphics/Vulkan/VulkanShader.h index 93ec839b..c65928f1 100644 --- a/Elixir/Source/Graphics/Vulkan/VulkanShader.h +++ b/Elixir/Source/Graphics/Vulkan/VulkanShader.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -45,49 +46,45 @@ namespace Elixir::Vulkan void CreateDescriptorSets(); void CreatePipelineLayout(); - void UpdateDescriptorSets(); + void ApplyPendingDescriptorState(); VkWriteDescriptorSet GetWriteDescriptorSet( SShaderBinding binding, const Texture* texture ) const; - void UpdateDescriptorSet(SShaderBinding binding, const Texture* texture) const; - VkWriteDescriptorSet GetWriteDescriptorSet( SShaderBinding binding, const Ref& sampler ) const; - void UpdateDescriptorSet(SShaderBinding binding, const Ref& sampler) const; - VkWriteDescriptorSet GetWriteDescriptorSet( SShaderBinding binding, const Ref& buffer ) const; - void UpdateDescriptorSet( - SShaderBinding binding, - const Ref& buffer - ) const; - VkWriteDescriptorSet GetWriteDescriptorSet( SShaderBinding binding, const Ref& buffer ) const; - void UpdateDescriptorSet( - SShaderBinding binding, - const Ref& buffer - ) const; - VkWriteDescriptorSet GetWriteDescriptorSet( SShaderBinding binding, const Ref& buffer ) const; - void UpdateDescriptorSet( - SShaderBinding binding, - const Ref& buffer - ) const; + + using DescriptorValue = std::variant< + Ref, + Ref, + Ref, + Ref, + Ref + >; + + using DescriptorSetState = FrameSlotPendingState< + std::vector, + SShaderBinding, + DescriptorValue + >; bool m_BindlessSet = false; - std::vector m_DescriptorSets; + DescriptorSetState m_DescriptorSets; std::vector m_DescriptorSetLayouts; VkPipelineLayout m_PipelineLayout; diff --git a/Elixir/Tests/Engine/Aether/Effect/MaterialResolverTest.cpp b/Elixir/Tests/Engine/Aether/Effect/MaterialResolverTest.cpp new file mode 100644 index 00000000..a8bb1bed --- /dev/null +++ b/Elixir/Tests/Engine/Aether/Effect/MaterialResolverTest.cpp @@ -0,0 +1,39 @@ +#include + +#include +#include +#include + +using namespace Elixir; +using namespace Elixir::Aether; +using namespace Elixir::Aether::Core; +using namespace Elixir::Materials; + +TEST(MaterialResolverTest, CreatesAuthoredMaterialsAndUsesUsageDefaults) +{ + MaterialRegistry registry; + const Effect::MaterialResolver resolver{ registry }; + System system{ "Effect material resolution" }; + + auto& sprite = system.AddEmitter("Sprite", 8, 0.0f); + sprite.SetMaterialDescription({ + .BaseColor = { 0.25f, 0.5f, 0.75f }, + .Opacity = 0.4f, + .Emissive = {0.1f, 0.0f, 0.0f }, + }); + + auto& ribbon = system.AddEmitter("Ribbon", 8, 0.0f); + ribbon.SetRenderMode(EParticleRenderMode::Ribbon); + + ASSERT_TRUE(resolver.Resolve(system)); + + ASSERT_TRUE(sprite.GetMaterial()); + EXPECT_NE(sprite.GetMaterial()->GetParent(), registry.GetDefault(EMaterialUsage::ParticleSprite)); + + EXPECT_TRUE(sprite.GetMaterial()->GetParent()->SupportsUsage(EMaterialUsage::ParticleSprite)); + + ASSERT_TRUE(ribbon.GetMaterial()); + EXPECT_EQ(ribbon.GetMaterial()->GetParent(), registry.GetDefault(EMaterialUsage::ParticleRibbon)); + + EXPECT_TRUE(resolver.Resolve(system)); +} diff --git a/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp b/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp deleted file mode 100644 index 9d3604b8..00000000 --- a/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp +++ /dev/null @@ -1,41 +0,0 @@ -#include - -#include - -using namespace Elixir; -using namespace Elixir::Aether; - -TEST(AetherFrameSubmissionTest, RetainsEachSystemInstanceAtMostOnce) -{ - const auto compiledSystem = CreateRef(); - const SystemInstance firstInstance{ compiledSystem }; - const SystemInstance secondInstance{ compiledSystem }; - - FrameSubmission submission; - - EXPECT_TRUE(submission.Submit(firstInstance)); - EXPECT_FALSE(submission.Submit(firstInstance)); - EXPECT_TRUE(submission.Submit(secondInstance)); - - ASSERT_EQ(submission.GetInstanceCount(), 2); - EXPECT_EQ(submission.GetInstances()[0], &firstInstance); - EXPECT_EQ(submission.GetInstances()[1], &secondInstance); -} - -TEST(AetherFrameSubmissionTest, ResetKeepsTheSubmissionReusable) -{ - const auto compiledSystem = CreateRef(); - const SystemInstance firstInstance{ compiledSystem }; - const SystemInstance secondInstance{ compiledSystem }; - - FrameSubmission submission; - ASSERT_TRUE(submission.Submit(firstInstance)); - - submission.Reset(); - - EXPECT_TRUE(submission.IsEmpty()); - EXPECT_EQ(submission.GetInstanceCount(), 0); - EXPECT_TRUE(submission.Submit(firstInstance)); - EXPECT_TRUE(submission.Submit(secondInstance)); - EXPECT_EQ(submission.GetInstanceCount(), 2); -} \ No newline at end of file diff --git a/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionPublisherTest.cpp b/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionPublisherTest.cpp new file mode 100644 index 00000000..6e148079 --- /dev/null +++ b/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionPublisherTest.cpp @@ -0,0 +1,103 @@ +#include + +#include +#include + +#include + +#include "../TestInstanceRegistry.h" + +using namespace Elixir; +using namespace Elixir::Aether; +using namespace Elixir::Aether::Rendering; + +TEST(FrameSubmissionPublisherTest, PublishesOnlySealedSubmissions) +{ + TestInstanceRegistry runtime; + const auto instance = runtime.CreateRegisteredInstance("Sealed submission system"); + ASSERT_TRUE(instance); + + const auto submission = CreateRef(); + FrameSubmissionPublisher publisher; + + ASSERT_TRUE(submission->Submit(*instance)); + publisher.Publish(submission); + + const auto published = publisher.Acquire(); + ASSERT_TRUE(published); + EXPECT_TRUE(published->IsSealed()); + EXPECT_FALSE(submission->Submit(*instance)); +} + +TEST(FrameSubmissionPublisherTest, RemovesDestroyedInstanceFromPublishedFrame) +{ + TestInstanceRegistry runtime; + const auto instance = runtime.CreateRegisteredInstance("Removed published system"); + ASSERT_TRUE(instance); + + const auto submission = CreateRef(); + FrameSubmissionPublisher publisher; + + ASSERT_TRUE(submission->Submit(*instance)); + publisher.Publish(submission); + publisher.Remove(*instance); + + EXPECT_TRUE(publisher.Acquire()->IsEmpty()); +} + +TEST(FrameSubmissionPublisherTest, FiltersInstanceRejectedAtPublication) +{ + TestInstanceRegistry runtime; + const auto instance = runtime.CreateRegisteredInstance("Filtered published system"); + ASSERT_TRUE(instance); + + const auto submission = CreateRef(); + FrameSubmissionPublisher publisher; + + ASSERT_TRUE(submission->Submit(*instance)); + publisher.Publish(submission, [](const SSystemInstanceKey&) + { + return false; + }); + + EXPECT_TRUE(publisher.Acquire()->IsEmpty()); +} + +TEST(FrameSubmissionPublisherTest, PublishesAndAcquiresSealedFramesConcurrently) +{ + TestInstanceRegistry runtime; + const auto instance = runtime.CreateRegisteredInstance("Concurrent published system"); + ASSERT_TRUE(instance); + + const auto firstSubmission = CreateRef(); + const auto replacementSubmission = CreateRef(); + FrameSubmissionPublisher publisher; + std::barrier firstFramePublished{ 2 }; + std::barrier beginConcurrentAccess{ 2 }; + + ASSERT_TRUE(firstSubmission->Submit(*instance)); + ASSERT_TRUE(replacementSubmission->Submit(*instance)); + + std::thread publicationThread([&] + { + publisher.Publish(firstSubmission); + firstFramePublished.arrive_and_wait(); + beginConcurrentAccess.arrive_and_wait(); + publisher.Publish(replacementSubmission); + }); + + firstFramePublished.arrive_and_wait(); + beginConcurrentAccess.arrive_and_wait(); + const auto concurrentFrame = publisher.Acquire(); + + publicationThread.join(); + + ASSERT_TRUE(concurrentFrame); + EXPECT_TRUE(concurrentFrame->IsSealed()); + EXPECT_EQ(concurrentFrame->GetInstanceCount(), 1); + + const auto finalFrame = publisher.Acquire(); + ASSERT_TRUE(finalFrame); + EXPECT_TRUE(finalFrame->IsSealed()); + EXPECT_EQ(finalFrame->GetInstanceCount(), 1); +} diff --git a/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionTest.cpp b/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionTest.cpp new file mode 100644 index 00000000..17de5fb8 --- /dev/null +++ b/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionTest.cpp @@ -0,0 +1,112 @@ +#include + +#include +#include + +#include + +#include "../TestInstanceRegistry.h" + +using namespace Elixir; +using namespace Elixir::Aether; +using namespace Elixir::Aether::Rendering; + +template +concept HasPublicSystemInstanceId = requires(const T& instance) +{ + instance.GetId(); +}; + +static_assert(!HasPublicSystemInstanceId); + +template +concept HasFrameSnapshots = requires(const T& submission) +{ + submission.GetSnapshots(); +}; + +static_assert(!HasFrameSnapshots); + +static_assert( + std::same_as< + decltype(std::declval().GetRenderProxies()), + const std::vector>& + > +); + +TEST(FrameSubmissionTest, RetainsEachSystemInstanceAtMostOnce) +{ + TestInstanceRegistry runtime; + const auto system = CreateRef("Frame submission system"); + const auto firstInstance = runtime.CreateRegisteredInstance(system); + const auto secondInstance = runtime.CreateRegisteredInstance(system); + + ASSERT_TRUE(firstInstance); + ASSERT_TRUE(secondInstance); + + FrameSubmission submission; + + EXPECT_TRUE(submission.Submit(*firstInstance)); + EXPECT_FALSE(submission.Submit(*firstInstance)); + EXPECT_TRUE(submission.Submit(*secondInstance)); + + ASSERT_EQ(submission.GetInstanceCount(), 2); + EXPECT_NE(submission.GetRenderProxies()[0], submission.GetRenderProxies()[1]); +} + +TEST(FrameSubmissionTest, ResetKeepsTheSubmissionReusable) +{ + TestInstanceRegistry runtime; + const auto system = CreateRef("Reusable submission system"); + const auto firstInstance = runtime.CreateRegisteredInstance(system); + const auto secondInstance = runtime.CreateRegisteredInstance(system); + + ASSERT_TRUE(firstInstance); + ASSERT_TRUE(secondInstance); + + FrameSubmission submission; + ASSERT_TRUE(submission.Submit(*firstInstance)); + + submission.Reset(); + + EXPECT_TRUE(submission.IsEmpty()); + EXPECT_EQ(submission.GetInstanceCount(), 0); + EXPECT_TRUE(submission.Submit(*firstInstance)); + EXPECT_TRUE(submission.Submit(*secondInstance)); + EXPECT_EQ(submission.GetInstanceCount(), 2); +} + +TEST(FrameSubmissionTest, RemovesAnInstanceBeforeItIsRetired) +{ + TestInstanceRegistry runtime; + const auto instance = runtime.CreateRegisteredInstance("Removed submission system"); + ASSERT_TRUE(instance); + + FrameSubmission submission; + + ASSERT_TRUE(submission.Submit(*instance)); + EXPECT_TRUE(submission.Remove(*instance)); + EXPECT_EQ(submission.GetInstanceCount(), 0); + EXPECT_FALSE(submission.Remove(*instance)); + EXPECT_TRUE(submission.Submit(*instance)); +} + +TEST(FrameSubmissionTest, RetainsTheStateCapturedAtSubmission) +{ + TestInstanceRegistry runtime; + const auto instance = runtime.CreateRegisteredInstance("Captured submission system"); + ASSERT_TRUE(instance); + + FrameSubmission submission; + + ASSERT_TRUE(submission.Submit(*instance)); + + glm::mat4 transform{ 1.0f }; + transform[3] = { 3.0f, 2.0f, 1.0f, 1.0f }; + instance->SetWorldTransform(transform); + + const auto& proxy = submission.GetRenderProxies().front(); + EXPECT_FLOAT_EQ(proxy->GetWorldTransform()[3].x, 0.0f); + EXPECT_FLOAT_EQ(proxy->GetWorldTransform()[3].y, 0.0f); + EXPECT_FLOAT_EQ(proxy->GetWorldTransform()[3].z, 0.0f); +} diff --git a/Elixir/Tests/Engine/Aether/Rendering/RendererTest.cpp b/Elixir/Tests/Engine/Aether/Rendering/RendererTest.cpp new file mode 100644 index 00000000..78b90725 --- /dev/null +++ b/Elixir/Tests/Engine/Aether/Rendering/RendererTest.cpp @@ -0,0 +1,51 @@ +#include + +#include + +#include +#include + +namespace Elixir { class ShaderLoader; } + +using namespace Elixir; +using namespace Elixir::Aether::Rendering; + +namespace +{ + template + concept RendersFrameSubmission = requires( + T& renderer, + const FrameSubmission& submission, + const Camera& camera, + const Ref& cmd + ) + { + renderer.Render(submission, camera, cmd); + }; +} + +static_assert(!RendersFrameSubmission); +static_assert(std::is_constructible_v< + Aether::Rendering::Renderer, + const GraphicsContext* +>); +static_assert(!std::is_constructible_v< + Aether::Rendering::Renderer, + const GraphicsContext*, + const ShaderLoader* +>); + +TEST(RendererTest, MetricsContainOnlyRenderingResults) +{ + constexpr SRenderingMetrics metrics{ + .SubmissionSerial = 12u, + .RenderBatchCount = 4u, + .SubmittedRenderItemCount = 6u, + .SubmittedMaterialCount = 2u, + }; + + EXPECT_EQ(metrics.SubmissionSerial, 12u); + EXPECT_EQ(metrics.RenderBatchCount, 4u); + EXPECT_EQ(metrics.SubmittedRenderItemCount, 6u); + EXPECT_EQ(metrics.SubmittedMaterialCount, 2u); +} diff --git a/Elixir/Tests/Engine/Aether/Rendering/SystemInstanceRetirementQueueTest.cpp b/Elixir/Tests/Engine/Aether/Rendering/SystemInstanceRetirementQueueTest.cpp new file mode 100644 index 00000000..6d0fbc14 --- /dev/null +++ b/Elixir/Tests/Engine/Aether/Rendering/SystemInstanceRetirementQueueTest.cpp @@ -0,0 +1,76 @@ +#include + +#include +#include +#include +#include + +#include + +#include "../TestInstanceRegistry.h" + +using namespace Elixir; +using namespace Elixir::Aether; +using namespace Elixir::Aether::Rendering; + +TEST(SystemInstanceRetirementQueueTest, TransfersPendingInstancesExactlyOnce) +{ + TestInstanceRegistry runtime; + const auto system = CreateRef("Retirement queue system"); + const auto first = runtime.CreateRegisteredInstance(system); + const auto second = runtime.CreateRegisteredInstance(system); + + ASSERT_TRUE(first); + ASSERT_TRUE(second); + + SystemInstanceRetirementQueue queue; + queue.Enqueue(first); + queue.Enqueue(second); + + const auto retired = queue.Drain(); + + ASSERT_EQ(retired.size(), 2); + EXPECT_EQ(retired[0], first); + EXPECT_EQ(retired[1], second); + EXPECT_TRUE(queue.Drain().empty()); +} + +TEST(SystemInstanceRetirementQueueTest, DrainsDestroyRequestsEnqueuedDuringUpdate) +{ + constexpr uint32_t destroyRequestCount = 256; + TestInstanceRegistry runtime; + const auto system = CreateRef("Concurrent retirement system"); + std::vector> instances; + instances.reserve(destroyRequestCount); + + for (uint32_t instance = 0; instance < destroyRequestCount; ++instance) + instances.push_back(runtime.CreateRegisteredInstance(system)); + + SystemInstanceRetirementQueue queue; + std::barrier beginConcurrentAccess{ 2 }; + std::atomic_bool producerFinished = false; + size_t retiredCount = 0; + + std::thread destructionThread([&] + { + beginConcurrentAccess.arrive_and_wait(); + + for (uint32_t request = 0; request < destroyRequestCount; ++request) + queue.Enqueue(instances[request]); + + producerFinished.store(true, std::memory_order_release); + }); + + beginConcurrentAccess.arrive_and_wait(); + + while (!producerFinished.load(std::memory_order_acquire)) + { + retiredCount += queue.Drain().size(); + std::this_thread::yield(); + } + + destructionThread.join(); + retiredCount += queue.Drain().size(); + + EXPECT_EQ(retiredCount, destroyRequestCount); +} diff --git a/Elixir/Tests/Engine/Aether/Runtime/InstanceRegistryTest.cpp b/Elixir/Tests/Engine/Aether/Runtime/InstanceRegistryTest.cpp new file mode 100644 index 00000000..bff51083 --- /dev/null +++ b/Elixir/Tests/Engine/Aether/Runtime/InstanceRegistryTest.cpp @@ -0,0 +1,197 @@ +#include + +#include +#include + +#include + +#include "../TestInstanceRegistry.h" + +using namespace Elixir; +using namespace Elixir::Aether; +using namespace Elixir::Aether::Rendering; +using namespace Elixir::Aether::Runtime; + +namespace +{ + Ref Capture( + const Ref& instance + ) + { + FrameSubmission submission; + EXPECT_TRUE(submission.Submit(*instance)); + + if (submission.IsEmpty()) + return nullptr; + + return submission.GetRenderProxies().front(); + } +} + +TEST(InstanceRegistryTest, RetainsAuthoredSystemUntilFirstRegistration) +{ + TestInstanceRegistry runtime; + std::weak_ptr authoredSystem; + Ref instance; + + { + auto system = CreateRef("Transient authored system"); + system->GetParameters().SetFloat4("Tint", { 1.0f, 0.5f, 0.25f, 1.0f }); + authoredSystem = system; + instance = system->CreateInstance(); + + ASSERT_TRUE(instance); + EXPECT_EQ(instance->GetSourceSystemId(), system->GetId()); + + system.reset(); + EXPECT_FALSE(authoredSystem.expired()); + } + + ASSERT_TRUE(runtime.Registry.Register(instance)); + EXPECT_TRUE(authoredSystem.expired()); + + const auto tint = instance->GetParameterValue("Tint"); + ASSERT_TRUE(tint.has_value()); + EXPECT_EQ(*tint, glm::vec4(1.0f, 0.5f, 0.25f, 1.0f)); +} + +TEST(InstanceRegistryTest, ReusesCompiledDataForTheSameSystem) +{ + TestInstanceRegistry runtime; + const auto system = CreateRef("Shared compiled system"); + const auto first = system->CreateInstance(); + const auto second = system->CreateInstance(); + + ASSERT_TRUE(first); + ASSERT_TRUE(second); + ASSERT_TRUE(runtime.Registry.Register(first)); + ASSERT_TRUE(runtime.Registry.Register(second)); + + const auto firstProxy = Capture(first); + const auto secondProxy = Capture(second); + + ASSERT_TRUE(firstProxy); + ASSERT_TRUE(secondProxy); + EXPECT_EQ( + &firstProxy->GetCompiledSystem(), + &secondProxy->GetCompiledSystem() + ); + EXPECT_EQ(firstProxy->GetCompiledSystem().CompilationRevision, 1u); +} + +TEST(InstanceRegistryTest, RecompilesExistingInstancesAndRetainsOverrides) +{ + TestInstanceRegistry runtime; + const auto system = CreateRef("Recompiled system"); + system->GetParameters().SetFloat4("Tint", { 1.0f, 1.0f, 1.0f, 1.0f }); + + const auto instance = system->CreateInstance(); + ASSERT_TRUE(instance); + ASSERT_TRUE(instance->SetParameterOverride( + "Tint", + { 0.25f, 0.5f, 0.75f, 1.0f } + )); + ASSERT_TRUE(runtime.Registry.Register(instance)); + + const auto before = Capture(instance); + ASSERT_TRUE(before); + + system->GetParameters().SetFloat4("Tint", { 0.0f, 0.0f, 0.0f, 1.0f }); + ASSERT_TRUE(runtime.Registry.Recompile(system)); + + const auto after = Capture(instance); + ASSERT_TRUE(after); + EXPECT_EQ(after->GetRevision(), before->GetRevision() + 1); + EXPECT_EQ( + after->GetCompiledSystem().CompilationRevision, + before->GetCompiledSystem().CompilationRevision + 1 + ); + EXPECT_NE(&after->GetCompiledSystem(), &before->GetCompiledSystem()); + EXPECT_EQ( + after->GetParameterValue(0), + glm::vec4(0.25f, 0.5f, 0.75f, 1.0f) + ); +} + +TEST(InstanceRegistryTest, DetachesAnInstanceFromPublishedFrames) +{ + TestInstanceRegistry runtime; + const auto instance = runtime.CreateRegisteredInstance("Detached system"); + + ASSERT_TRUE(instance); + runtime.Registry.PublishActiveInstances(); + + ASSERT_EQ(runtime.Registry.Unregister(instance), instance); + + const auto published = runtime.Registry.AcquireSubmission(); + ASSERT_TRUE(published); + EXPECT_TRUE(published->IsEmpty()); + + EXPECT_FALSE(runtime.Registry.Register(instance)); + EXPECT_FALSE(runtime.Registry.Unregister(instance)); +} + +TEST(InstanceRegistryTest, PublishesRegisteredInstanceInEveryFrame) +{ + TestInstanceRegistry runtime; + const auto instance = runtime.CreateRegisteredInstance("Persistent system"); + ASSERT_TRUE(instance); + + runtime.Registry.PublishActiveInstances(); + const auto firstFrame = runtime.Registry.AcquireSubmission(); + + runtime.Registry.PublishActiveInstances(); + const auto secondFrame = runtime.Registry.AcquireSubmission(); + + ASSERT_TRUE(firstFrame); + ASSERT_TRUE(secondFrame); + EXPECT_EQ(firstFrame->GetInstanceCount(), 1); + EXPECT_EQ(secondFrame->GetInstanceCount(), 1); + EXPECT_NE(firstFrame, secondFrame); +} + +TEST(InstanceRegistryTest, RejectsDuplicateRegistration) +{ + TestInstanceRegistry runtime; + const auto system = CreateRef("Duplicate submission"); + const auto instance = system->CreateInstance(); + + EXPECT_TRUE(runtime.Registry.Register(instance)); + EXPECT_FALSE(runtime.Registry.Register(instance)); +} + +TEST(InstanceRegistryTest, AcceptsConcurrentRegistrations) +{ + constexpr size_t instanceCount = 64; + + TestInstanceRegistry runtime; + const auto system = CreateRef("Concurrent registration"); + std::vector> instances; + std::vector threads; + std::atomic_size_t accepted = 0; + + instances.reserve(instanceCount); + threads.reserve(instanceCount); + + for (size_t index = 0; index < instanceCount; ++index) + instances.push_back(system->CreateInstance()); + + for (const auto& instance : instances) + { + threads.emplace_back([&runtime, &accepted, instance] + { + if (runtime.Registry.Register(instance)) + accepted.fetch_add(1, std::memory_order_relaxed); + }); + } + + for (auto& thread : threads) + thread.join(); + + runtime.Registry.PublishActiveInstances(); + const auto submission = runtime.Registry.AcquireSubmission(); + + ASSERT_TRUE(submission); + EXPECT_EQ(accepted.load(std::memory_order_relaxed), instanceCount); + EXPECT_EQ(submission->GetInstanceCount(), instanceCount); +} diff --git a/Elixir/Tests/Engine/Aether/Simulation/RenderFrameTest.cpp b/Elixir/Tests/Engine/Aether/Simulation/RenderFrameTest.cpp new file mode 100644 index 00000000..6a556dc7 --- /dev/null +++ b/Elixir/Tests/Engine/Aether/Simulation/RenderFrameTest.cpp @@ -0,0 +1,92 @@ +#include + +#include +#include +#include + +#include + +using namespace Elixir; +using namespace Elixir::Aether::Core; +using namespace Elixir::Aether::Simulation; + +static_assert(!std::is_copy_constructible_v); +static_assert(!std::is_copy_assignable_v); +static_assert(!std::is_move_constructible_v); +static_assert(!std::is_move_assignable_v); + +static_assert(std::same_as< + decltype(std::declval().GetResources()), + const std::vector& +>); +static_assert(std::same_as< + decltype(std::declval().GetEmitterBuffer()), + const Ref& +>); +static_assert(std::same_as< + decltype(std::declval().GetItems()), + const std::vector& +>); + +TEST(RenderFrameTest, PublishesResolvedSimulationData) +{ + SSystemInstanceAllocation allocation{ + .InstanceIndex = 7u, + .ParticleStateLayout = EParticleStateLayout::CoreV1, + .Generation = 3u, + .Particles = { .Offset = 128u, .Count = 64u }, + .Emitters = { .Offset = 11u, .Count = 2u }, + }; + + glm::mat4 transform{ 1.0f }; + transform[3] = { 4.0f, 5.0f, 6.0f, 1.0f }; + + std::vector resources{ + { + .Layout = EParticleStateLayout::CoreV1, + .ParticleStateBuffer = {}, + }, + }; + std::vector items{ + { + .Allocation = allocation, + .ParticleStateLayout = EParticleStateLayout::CoreV1, + .RenderMode = EParticleRenderMode::Ribbon, + .Material = {}, + .WorldTransform = transform, + .EmitterIndex = 1u, + .LocalParticleOffset = 16u, + .ParticleCount = 48u, + }, + }; + + const RenderFrame frame{ + std::move(resources), + {}, + std::move(items), + 42u, + 3.5f + }; + + ASSERT_EQ(frame.GetResources().size(), 1u); + EXPECT_EQ(frame.GetResources()[0].Layout, EParticleStateLayout::CoreV1); + EXPECT_FALSE(frame.GetResources()[0].ParticleStateBuffer); + EXPECT_FALSE(frame.GetEmitterBuffer()); + + ASSERT_EQ(frame.GetItems().size(), 1u); + const auto& item = frame.GetItems()[0]; + EXPECT_EQ(item.Allocation.InstanceIndex, 7u); + EXPECT_EQ(item.Allocation.Generation, 3u); + EXPECT_EQ(item.Allocation.Particles.Offset, 128u); + EXPECT_EQ(item.Allocation.Emitters.Offset, 11u); + EXPECT_EQ(item.ParticleStateLayout, EParticleStateLayout::CoreV1); + EXPECT_EQ(item.RenderMode, EParticleRenderMode::Ribbon); + EXPECT_EQ(item.EmitterIndex, 1u); + EXPECT_EQ(item.LocalParticleOffset, 16u); + EXPECT_EQ(item.ParticleCount, 48u); + EXPECT_EQ(item.WorldTransform, transform); + EXPECT_FALSE(item.Material); + + EXPECT_EQ(frame.GetSubmissionSerial(), 42u); + EXPECT_FLOAT_EQ(frame.GetElapsedTimeSeconds(), 3.5f); +} diff --git a/Elixir/Tests/Engine/Aether/ParticleResourcePoolTest.cpp b/Elixir/Tests/Engine/Aether/Simulation/ResourcePoolTest.cpp similarity index 88% rename from Elixir/Tests/Engine/Aether/ParticleResourcePoolTest.cpp rename to Elixir/Tests/Engine/Aether/Simulation/ResourcePoolTest.cpp index 858b9e9f..a321a6f0 100644 --- a/Elixir/Tests/Engine/Aether/ParticleResourcePoolTest.cpp +++ b/Elixir/Tests/Engine/Aether/Simulation/ResourcePoolTest.cpp @@ -1,9 +1,10 @@ #include -#include +#include using namespace Elixir; using namespace Elixir::Aether; +using namespace Elixir::Aether::Core; namespace { @@ -24,7 +25,7 @@ namespace return system; } - SParticlePoolLimits MakePoolLimits() + SResourcePoolLimits MakePoolLimits() { return { .MaxSystemInstances = 4, @@ -38,11 +39,11 @@ namespace } } -TEST(AetherParticleResourcePoolTest, AllocatesDisjointRangesForLiveInstances) +TEST(ResourcePoolTest, AllocatesDisjointRangesForLiveInstances) { const auto limits = MakePoolLimits(); const ParticleStateLayoutRegistry layouts{ limits.ParticleCapacity }; - ParticleResourcePool pool{ limits, layouts }; + ResourcePool pool{ limits, layouts }; const auto first = pool.Allocate(MakeCompiledSystem(10, 2, 3, 4, 1)); const auto second = pool.Allocate(MakeCompiledSystem(20, 3, 5, 6, 2)); @@ -64,11 +65,11 @@ TEST(AetherParticleResourcePoolTest, AllocatesDisjointRangesForLiveInstances) EXPECT_EQ(second->TriggerQueueStates.Offset, first->TriggerQueueStates.Count); } -TEST(AetherParticleResourcePoolTest, ReusesReleasedRanges) +TEST(ResourcePoolTest, ReusesReleasedRanges) { const auto limits = MakePoolLimits(); const ParticleStateLayoutRegistry layouts{ limits.ParticleCapacity }; - ParticleResourcePool pool{ limits, layouts }; + ResourcePool pool{ limits, layouts }; const auto system = MakeCompiledSystem(10, 2, 3, 4, 1); const auto first = pool.Allocate(system); @@ -92,13 +93,13 @@ TEST(AetherParticleResourcePoolTest, ReusesReleasedRanges) EXPECT_EQ(reused->TriggerQueueStates.Offset, first->TriggerQueueStates.Offset); } -TEST(AetherParticleResourcePoolTest, RollsBackPartialAllocationFailure) +TEST(ResourcePoolTest, RollsBackPartialAllocationFailure) { auto limits = MakePoolLimits(); limits.ParticleCapacity = 4; const ParticleStateLayoutRegistry layouts{ limits.ParticleCapacity }; - ParticleResourcePool pool{ limits, layouts }; + ResourcePool pool{ limits, layouts }; EXPECT_FALSE(pool.Allocate(MakeCompiledSystem(5, 1, 1, 1, 1))); @@ -117,14 +118,14 @@ TEST(AetherParticleResourcePoolTest, RollsBackPartialAllocationFailure) EXPECT_EQ(allocation->TriggerQueueStates.Offset, 0u); } -TEST(AetherParticleResourcePoolTest, RejectsAnUnregisteredParticleStateLayout) +TEST(ResourcePoolTest, RejectsAnUnregisteredParticleStateLayout) { const auto limits = MakePoolLimits(); const ParticleStateLayoutRegistry layouts{ limits.ParticleCapacity }; - ParticleResourcePool pool{ limits, layouts }; + ResourcePool pool{ limits, layouts }; auto system = MakeCompiledSystem(4, 1, 1, 1, 1); system.ParticleStateLayout = (EParticleStateLayout)1; EXPECT_FALSE(pool.Allocate(system)); -} \ No newline at end of file +} diff --git a/Elixir/Tests/Engine/Aether/Simulation/SimulatorTest.cpp b/Elixir/Tests/Engine/Aether/Simulation/SimulatorTest.cpp new file mode 100644 index 00000000..9d466fce --- /dev/null +++ b/Elixir/Tests/Engine/Aether/Simulation/SimulatorTest.cpp @@ -0,0 +1,48 @@ +#include + +#include +#include +#include + +#include +#include + +namespace Elixir::Materials { class MaterialSystem; } + +using namespace Elixir; +using namespace Elixir::Aether::Core; +using namespace Elixir::Aether::Rendering; +using namespace Elixir::Aether::Simulation; + +using TSimulatorResult = decltype( + std::declval().Simulate( + std::declval(), + std::declval&>() + ) +); + +static_assert(std::same_as>); +static_assert(std::is_constructible_v< + Simulator, + const GraphicsContext*, + const ShaderLoader*, + const SResourcePoolLimits& +>); +static_assert(!std::is_constructible_v< + Simulator, + const GraphicsContext*, + Materials::MaterialSystem& +>); + +TEST(SimulatorTest, MetricsContainOnlySimulationResults) +{ + const SSimulationMetrics metrics{ + .SubmissionSerial = 12u, + .SubmittedSystemInstanceCount = 3u, + .SimulationBatchCount = 2u, + }; + + EXPECT_EQ(metrics.SubmissionSerial, 12u); + EXPECT_EQ(metrics.SubmittedSystemInstanceCount, 3u); + EXPECT_EQ(metrics.SimulationBatchCount, 2u); +} diff --git a/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp b/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp index dd467994..f6684f42 100644 --- a/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp @@ -1,130 +1,212 @@ #include +#include +#include +#include + #include +#include + +#include "TestInstanceRegistry.h" using namespace Elixir; using namespace Elixir::Aether; +using namespace Elixir::Aether::Rendering; + +template +concept HasPublicSnapshotCapture = requires(const T& instance) +{ + instance.CaptureSnapshot(); +}; + +static_assert(!HasPublicSnapshotCapture); + +static_assert( + !std::constructible_from< + SystemInstance, + Ref + > +); namespace { - Ref MakeCompiledSystem() + Ref CaptureForTest( + const Ref& instance + ) { - const auto system = CreateRef(); - - system->Parameters = { - { "Tint", { 1.0f, 1.0f, 1.0f, 1.0f } }, - { "SizeOverLife:0", { 0.0f, 0.5f, 1.0f, 1.0f } }, - }; + FrameSubmission submission; + EXPECT_TRUE(submission.Submit(*instance)); + return submission.GetRenderProxies().front(); + } - system->ExposedParameters = { - { "Tint", 0u }, - }; + Ref MakeSystem() + { + const auto system = CreateRef("System instance test"); + system->GetParameters().SetFloat4("Tint", { 1.0f, 1.0f, 1.0f, 1.0f }); + system->GetCurves().SetCurve("SizeOverLife", { 0.0f, 0.5f, 1.0f }); return system; } } -TEST(AetherSystemInstanceTest, ReplacesCompiledSystemAndIncrementsRevision) +TEST(SystemInstanceTest, RecompilesSystemAndIncrementsRevision) { - const auto initialSystem = CreateRef(); - const auto replacementSystem = CreateRef(); - SystemInstance instance{ initialSystem }; + TestInstanceRegistry runtime; + const auto system = MakeSystem(); + const auto instance = runtime.CreateRegisteredInstance(system); + ASSERT_TRUE(instance); - const auto initialRevision = instance.GetRevision(); + const auto initialRevision = CaptureForTest(instance)->GetRevision(); + system->GetParameters().SetFloat4("Tint", { 0.5f, 0.5f, 0.5f, 1.0f }); - instance.SetCompiledSystem(replacementSystem); + ASSERT_TRUE(runtime.Registry.Recompile(system)); - EXPECT_EQ(instance.GetRevision(), initialRevision + 1); - EXPECT_EQ(&instance.GetCompiledSystem(), replacementSystem.get()); + const auto snapshot = CaptureForTest(instance); + + EXPECT_EQ(snapshot->GetRevision(), initialRevision + 1); + EXPECT_EQ(snapshot->GetCompiledSystem().CompilationRevision, 2u); } -TEST(AetherSystemInstanceTest, DoesNotIncrementRevisionForSameCompiledSystem) +TEST(SystemInstanceTest, ReturnsOverrideOrCompiledDefaultForExposedParameter) { - const auto compiledSystem = CreateRef(); - SystemInstance instance{ compiledSystem }; - - const auto initialRevision = instance.GetRevision(); - instance.SetCompiledSystem(compiledSystem); - - EXPECT_EQ(instance.GetRevision(), initialRevision); + TestInstanceRegistry runtime; + const auto instance = runtime.CreateRegisteredInstance(MakeSystem()); + ASSERT_TRUE(instance); + + const auto defaultValue = instance->GetParameterValue("Tint"); + + ASSERT_TRUE(defaultValue.has_value()); + EXPECT_FLOAT_EQ(defaultValue->x, 1.0f); + EXPECT_FLOAT_EQ(defaultValue->y, 1.0f); + EXPECT_FLOAT_EQ(defaultValue->z, 1.0f); + EXPECT_FLOAT_EQ(defaultValue->w, 1.0f); + + ASSERT_TRUE(instance->SetParameterOverride( + "Tint", + { 0.25f, 0.5f, 0.75f, 1.0f } + )); + + const auto overrideValue = instance->GetParameterValue("Tint"); + + ASSERT_TRUE(overrideValue.has_value()); + EXPECT_FLOAT_EQ(overrideValue->x, 0.25f); + EXPECT_FLOAT_EQ(overrideValue->y, 0.5f); + EXPECT_FLOAT_EQ(overrideValue->z, 0.75f); + EXPECT_FLOAT_EQ(overrideValue->w, 1.0f); + EXPECT_FALSE(instance->GetParameterValue("SizeOverLife:0").has_value()); + EXPECT_FALSE(instance->GetParameterValue("Missing").has_value()); } -TEST(AetherSystemInstanceTest, AppliesOverridesOnlyToExposedParameters) +TEST(SystemInstanceTest, AppliesOverridesOnlyToExposedParameters) { - const auto compiledSystem = MakeCompiledSystem(); - SystemInstance instance{ compiledSystem }; + TestInstanceRegistry runtime; + const auto instance = runtime.CreateRegisteredInstance(MakeSystem()); + ASSERT_TRUE(instance); + + const auto initialParameterRevision = CaptureForTest(instance)->GetParameterRevision(); - const auto initialParameterRevision = instance.GetParameterRevision(); + EXPECT_TRUE(instance->SetParameterOverride("Tint", { 0.25f, 0.5f, 0.75f, 1.0f })); + EXPECT_FALSE(instance->SetParameterOverride("SizeOverLife:0", { 1.0f, 1.0f, 1.0f, 1.0f })); - EXPECT_TRUE(instance.SetParameterOverride("Tint", { 0.25f, 0.5f, 0.75f, 1.0f })); - EXPECT_FALSE(instance.SetParameterOverride("SizeOverLife:0", { 1.0f, 1.0f, 1.0f, 1.0f })); - EXPECT_EQ(instance.GetParameterRevision(), initialParameterRevision + 1); + const auto snapshot = CaptureForTest(instance); + EXPECT_EQ(snapshot->GetParameterRevision(), initialParameterRevision + 1); - const auto tint = instance.ResolveParameterValue(0); + const auto tint = snapshot->GetParameterValue(0); EXPECT_FLOAT_EQ(tint.x, 0.25f); EXPECT_FLOAT_EQ(tint.y, 0.5f); EXPECT_FLOAT_EQ(tint.z, 0.75f); EXPECT_FLOAT_EQ(tint.w, 1.0f); - const auto colorChunk = instance.ResolveParameterValue(1); + const auto colorChunk = snapshot->GetParameterValue(1); EXPECT_FLOAT_EQ(colorChunk.x, 0.0f); EXPECT_FLOAT_EQ(colorChunk.y, 0.5f); EXPECT_FLOAT_EQ(colorChunk.z, 1.0f); EXPECT_FLOAT_EQ(colorChunk.w, 1.0f); } -TEST(AetherSystemInstanceTest, ClearsOverridesAndRestoresCompiledDefaults) +TEST(SystemInstanceTest, ClearsOverridesAndRestoresCompiledDefaults) { - const auto compiledSystem = MakeCompiledSystem(); - SystemInstance instance{ compiledSystem }; + TestInstanceRegistry runtime; + const auto instance = runtime.CreateRegisteredInstance(MakeSystem()); + ASSERT_TRUE(instance); - ASSERT_TRUE(instance.SetParameterOverride("Tint", { 0.25f, 0.5f, 0.75f, 1.0f })); - ASSERT_TRUE(instance.ClearParameterOverride("Tint")); - EXPECT_FALSE(instance.ClearParameterOverride("Tint")); + ASSERT_TRUE(instance->SetParameterOverride("Tint", { 0.25f, 0.5f, 0.75f, 1.0f })); + ASSERT_TRUE(instance->ClearParameterOverride("Tint")); + EXPECT_FALSE(instance->ClearParameterOverride("Tint")); - const auto tint = instance.ResolveParameterValue(0); + const auto tint = CaptureForTest(instance)->GetParameterValue(0); EXPECT_FLOAT_EQ(tint.x, 1.0f); EXPECT_FLOAT_EQ(tint.y, 1.0f); EXPECT_FLOAT_EQ(tint.z, 1.0f); EXPECT_FLOAT_EQ(tint.w, 1.0f); } -TEST(AetherSystemInstanceTest, RetainsOnlyOverridesExposedByReplacementSystem) +TEST(SystemInstanceTest, RetainsCompatibleOverridesAfterRecompilation) { - const auto initialSystem = MakeCompiledSystem(); - SystemInstance instance{ initialSystem }; - - ASSERT_TRUE(instance.SetParameterOverride("Tint", { 0.25f, 0.5f, 0.75f, 1.0f })); + TestInstanceRegistry runtime; + const auto system = MakeSystem(); + const auto instance = runtime.CreateRegisteredInstance(system); + ASSERT_TRUE(instance); - const auto replacementSystem = CreateRef(); - replacementSystem->Parameters = { - { "Tint", { 1.0f, 1.0f, 1.0f, 1.0f } }, - }; - replacementSystem->ExposedParameters = { - { "Tint", 0u }, - }; + ASSERT_TRUE(instance->SetParameterOverride("Tint", { 0.25f, 0.5f, 0.75f, 1.0f })); - instance.SetCompiledSystem(replacementSystem); + system->GetParameters().SetFloat4("Tint", { 0.0f, 0.0f, 0.0f, 1.0f }); + ASSERT_TRUE(runtime.Registry.Recompile(system)); - const auto tint = instance.ResolveParameterValue(0); + const auto tint = CaptureForTest(instance)->GetParameterValue(0); EXPECT_FLOAT_EQ(tint.x, 0.25f); EXPECT_FLOAT_EQ(tint.y, 0.5f); EXPECT_FLOAT_EQ(tint.z, 0.75f); EXPECT_FLOAT_EQ(tint.w, 1.0f); } -TEST(AetherSystemInstanceTest, StoresWorldTransformWithoutChangingCompiledSystem) +TEST(SystemInstanceTest, StoresWorldTransformWithoutChangingCompiledSystem) { - const auto compiledSystem = MakeCompiledSystem(); - SystemInstance instance{ compiledSystem }; + TestInstanceRegistry runtime; + const auto instance = runtime.CreateRegisteredInstance(MakeSystem()); + ASSERT_TRUE(instance); + const auto* compiledSystem = &CaptureForTest(instance)->GetCompiledSystem(); glm::mat4 transform{ 1.0f }; transform[3] = { 5.0f, 2.0f, -3.0f, 1.0f }; - instance.SetWorldTransform(transform); + instance->SetWorldTransform(transform); - EXPECT_FLOAT_EQ(instance.GetWorldTransform()[3].x, 5.0f); - EXPECT_FLOAT_EQ(instance.GetWorldTransform()[3].y, 2.0f); - EXPECT_FLOAT_EQ(instance.GetWorldTransform()[3].z, -3.0f); - EXPECT_EQ(&instance.GetCompiledSystem(), compiledSystem.get()); -} \ No newline at end of file + const auto snapshot = CaptureForTest(instance); + + EXPECT_FLOAT_EQ(snapshot->GetWorldTransform()[3].x, 5.0f); + EXPECT_FLOAT_EQ(snapshot->GetWorldTransform()[3].y, 2.0f); + EXPECT_FLOAT_EQ(snapshot->GetWorldTransform()[3].z, -3.0f); + EXPECT_EQ(&snapshot->GetCompiledSystem(), compiledSystem); +} + +TEST(SystemInstanceTest, KeepsCapturedProxyImmutableDuringConcurrentOverrides) +{ + TestInstanceRegistry runtime; + const auto instance = runtime.CreateRegisteredInstance(MakeSystem()); + ASSERT_TRUE(instance); + const auto capturedBeforeOverrides = CaptureForTest(instance); + std::barrier beginUpdates{ 2 }; + + std::thread overrideThread([&] + { + beginUpdates.arrive_and_wait(); + + for (uint32_t value = 2; value <= 64; ++value) + instance->SetParameterOverride("Tint", { (float)value, 0.0f, 0.0f, 1.0f }); + }); + + beginUpdates.arrive_and_wait(); + + for (uint32_t capture = 0; capture < 64; ++capture) + { + const auto proxy = CaptureForTest(instance); + EXPECT_GE(proxy->GetParameterValue(0).x, 1.0f); + EXPECT_LE(proxy->GetParameterValue(0).x, 64.0f); + } + + overrideThread.join(); + + EXPECT_FLOAT_EQ(capturedBeforeOverrides->GetParameterValue(0).x, 1.0f); + EXPECT_FLOAT_EQ(CaptureForTest(instance)->GetParameterValue(0).x, 64.0f); +} diff --git a/Elixir/Tests/Engine/Aether/SystemTest.cpp b/Elixir/Tests/Engine/Aether/SystemTest.cpp index 8117619f..5a051060 100644 --- a/Elixir/Tests/Engine/Aether/SystemTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemTest.cpp @@ -1,32 +1,67 @@ #include #include +#include +#include + +#include "TestInstanceRegistry.h" using namespace Elixir; using namespace Elixir::Aether; +using namespace Elixir::Aether::Core; +using namespace Elixir::Materials; -TEST(AetherSystemTest, CompilePreservesEmitterSimulationSpace) +template +concept HasPublicCompile = requires(const T& system) { - System system{ "Simulation space contract" }; - auto& worldEmitter = system.AddEmitter("World", 8, 0.0f); - auto& localEmitter = system.AddEmitter("Local", 8, 0.0f); + system.Compile(); +}; + +static_assert(!HasPublicCompile); + +namespace +{ + SCompiledSystem Compile(const Ref& system) + { + TestInstanceRegistry runtime; + const auto instance = system->CreateInstance(); + EXPECT_TRUE(instance); + + if (!instance || !runtime.Registry.Register(instance)) + return {}; + + Elixir::Aether::Rendering::FrameSubmission submission; + EXPECT_TRUE(submission.Submit(*instance)); + + if (submission.IsEmpty()) + return {}; + + return submission.GetRenderProxies().front()->GetCompiledSystem(); + } +} + +TEST(SystemTest, CompilePreservesEmitterSimulationSpace) +{ + const auto system = CreateRef("Simulation space contract"); + system->AddEmitter("World", 8, 0.0f); // world emitter + auto& localEmitter = system->AddEmitter("Local", 8, 0.0f); localEmitter.SetSimulationSpace(EParticleSimulationSpace::Local); - const auto compiled = system.Compile(); + const auto compiled = Compile(system); ASSERT_EQ(compiled.Emitters.size(), 2); EXPECT_EQ(compiled.Emitters[0].SimulationSpace, EParticleSimulationSpace::World); EXPECT_EQ(compiled.Emitters[1].SimulationSpace, EParticleSimulationSpace::Local); } -TEST(AetherSystemTest, CompileAssignsContiguousLocalEmitterParticleOffsets) +TEST(SystemTest, CompileAssignsContiguousLocalEmitterParticleOffsets) { - System system{ "Particle offset contract" }; - system.AddEmitter("First", 3u, 0.0f); - system.AddEmitter("Second", 7u, 0.0f); - system.AddEmitter("Third", 11u, 0.0f); + const auto system = CreateRef("Particle offset contract"); + system->AddEmitter("First", 3u, 0.0f); + system->AddEmitter("Second", 7u, 0.0f); + system->AddEmitter("Third", 11u, 0.0f); - const auto compiled = system.Compile(); + const auto compiled = Compile(system); ASSERT_EQ(compiled.Emitters.size(), 3u); EXPECT_EQ(compiled.ParticleStateLayout, EParticleStateLayout::CoreV1); @@ -36,16 +71,16 @@ TEST(AetherSystemTest, CompileAssignsContiguousLocalEmitterParticleOffsets) EXPECT_EQ(compiled.TotalMaxParticles, 21u); } -TEST(AetherSystemTest, CompileResolvesTriggerEmitterByCompiledIndex) +TEST(SystemTest, CompileResolvesTriggerEmitterByCompiledIndex) { - System system{ "Trigger contract" }; - system.AddEmitter("Source", 8, 0.0f); + const auto system = CreateRef("Trigger contract"); + system->AddEmitter("Source", 8, 0.0f); - auto& target = system.AddEmitter("Target", 8, 0.0f); + auto& target = system->AddEmitter("Target", 8, 0.0f); target.SetBurst(8, 1.0f); target.SetTriggerEmitter("Source", 0.25f); - const auto compiled = system.Compile(); + const auto compiled = Compile(system); ASSERT_EQ(compiled.Emitters.size(), 2u); @@ -59,16 +94,16 @@ TEST(AetherSystemTest, CompileResolvesTriggerEmitterByCompiledIndex) EXPECT_FLOAT_EQ(compiled.TriggerTargets[0].DelaySeconds, 0.25f); } -TEST(AetherSystemTest, CompileExposesOnlyAuthoredParameters) +TEST(SystemTest, CompileExposesOnlyAuthoredParameters) { - System system{ "Parameter contract" }; - system.GetParameters().SetFloat("SystemRate", 4.0f); - system.GetCurves().SetCurve("SizeOverLife", { 0.0f, 1.0f }); + const auto system = CreateRef("Parameter contract"); + system->GetParameters().SetFloat("SystemRate", 4.0f); + system->GetCurves().SetCurve("SizeOverLife", { 0.0f, 1.0f }); - auto& emitter = system.AddEmitter("Smoke", 8, 0.0f); + auto& emitter = system->AddEmitter("Smoke", 8, 0.0f); emitter.GetParameters().SetFloat4("Tint", { 1.0f, 0.5f, 0.25f, 1.0f }); - const auto compiled = system.Compile(); + const auto compiled = Compile(system); ASSERT_EQ(compiled.ExposedParameters.size(), 2); EXPECT_EQ(compiled.ExposedParameters[0].Name, "SystemRate"); @@ -79,4 +114,108 @@ TEST(AetherSystemTest, CompileExposesOnlyAuthoredParameters) ASSERT_EQ(compiled.Parameters.size(), 4); EXPECT_EQ(compiled.Parameters[2].Name, "SizeOverLife:0"); EXPECT_EQ(compiled.Parameters[3].Name, "SizeOverLife:1"); -} \ No newline at end of file +} + +TEST(SystemTest, FindsNamedEmitterForMaterialPublication) +{ + System system{ "Named emitters" }; + auto& flame = system.AddEmitter("FlameCore", 8, 0.0f); + system.AddEmitter("Smoke", 8, 0.0f); + + EXPECT_EQ(system.FindEmitter("FlameCore"), &flame); + EXPECT_NE(system.FindEmitter("Smoke"), nullptr); + EXPECT_EQ(system.FindEmitter("Missing"), nullptr); +} + +TEST(SystemTest, CompilePublishesParticleSpriteMaterialInstance) +{ + const auto material = CreateRef("Particle tint"); + ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleSprite, true)); + ASSERT_TRUE(material->DefineParameter("Tint", { + .Kind = EMaterialParameterKind::Value, + .ValueType = EMaterialValueType::Float4, + .DefaultValue = SMaterialParameter::MakeVector({ 1.0f, 1.0f, 1.0f, 1.0f }), + })); + + const auto instance = material->CreateInstance(); + ASSERT_TRUE(instance->SetVector("Tint", { 0.25f, 0.5f, 0.75f, 1.0f })); + + const auto system = CreateRef("Material snapshot contract"); + auto& emitter = system->AddEmitter("Smoke", 8, 0.0f); + + emitter.SetMaterial(instance); + const auto first = Compile(system); + + ASSERT_EQ(first.Emitters.size(), 1); + ASSERT_TRUE(first.Emitters[0].Material); + EXPECT_TRUE(first.Emitters[0].Material->GetParent()->SupportsUsage( + EMaterialUsage::ParticleSprite + )); + EXPECT_EQ(first.Emitters[0].Material, instance); + EXPECT_FLOAT_EQ(first.Emitters[0].Material->GetVector("Tint").x, 0.25f); + + ASSERT_TRUE(instance->SetVector("Tint", { 0.75f, 0.5f, 0.25f, 1.0f })); + + emitter.SetMaterial(instance); + const auto second = Compile(system); + + ASSERT_TRUE(second.Emitters[0].Material); + EXPECT_EQ(first.Emitters[0].Material, instance); + EXPECT_FLOAT_EQ(second.Emitters[0].Material->GetVector("Tint").x, 0.75f); +} + +TEST(SystemTest, CompileSnapshotsParticleRibbonMaterialForRenderData) +{ + const auto material = CreateRef("Particle ribbon"); + ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleRibbon, true)); + + const auto system = CreateRef("Ribbon material snapshot contract"); + auto& emitter = system->AddEmitter("Ribbon", 8, 0.0f); + emitter.SetRenderMode(EParticleRenderMode::Ribbon); + + const auto instance = material->CreateInstance(); + emitter.SetMaterial(instance); + + const auto compiled = Compile(system); + + ASSERT_EQ(compiled.Emitters.size(), 1); + ASSERT_TRUE(compiled.Emitters[0].Material); + EXPECT_TRUE(compiled.Emitters[0].Material->GetParent()->SupportsUsage( + EMaterialUsage::ParticleRibbon + )); +} + +TEST(SystemTest, CompileSnapshotsParticleMeshMaterialForRenderData) +{ + const auto material = CreateRef("Particle mesh"); + material->SetUsage(EMaterialUsage::ParticleMesh, true); + + const auto system = CreateRef("Mesh material"); + auto& emitter = system->AddEmitter("Mesh", 8, 0.0f); + emitter.SetRenderMode(EParticleRenderMode::Mesh); + + const auto instance = material->CreateInstance(); + emitter.SetMaterial(instance); + + const auto compiled = Compile(system); + + ASSERT_EQ(compiled.Emitters.size(), 1); + ASSERT_TRUE(compiled.Emitters[0].Material); + EXPECT_TRUE(compiled.Emitters[0].Material->GetParent()->SupportsUsage( + EMaterialUsage::ParticleMesh + )); +} + +TEST(SystemTest, CompileAssignsTheDefaultMaterialWhenNoneIsExplicit) +{ + const auto system = CreateRef("Default material contract"); + system->AddEmitter("Smoke", 8, 0.0f); + + const auto compiled = Compile(system); + + ASSERT_EQ(compiled.Emitters.size(), 1); + ASSERT_TRUE(compiled.Emitters[0].Material); + EXPECT_TRUE(compiled.Emitters[0].Material->GetParent()->SupportsUsage( + EMaterialUsage::ParticleSprite + )); +} diff --git a/Elixir/Tests/Engine/Aether/TestInstanceRegistry.h b/Elixir/Tests/Engine/Aether/TestInstanceRegistry.h new file mode 100644 index 00000000..3b31c6dd --- /dev/null +++ b/Elixir/Tests/Engine/Aether/TestInstanceRegistry.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +using namespace Elixir; +using namespace Elixir::Aether; +using namespace Elixir::Materials; + +class TestInstanceRegistry final +{ +private: + MaterialRegistry m_MaterialRegistry; + +public: + TestInstanceRegistry() + : Registry(m_MaterialRegistry) {} + + Ref CreateRegisteredInstance(const Ref& system) + { + const auto instance = system->CreateInstance(); + return Registry.Register(instance) ? instance : nullptr; + } + + Ref CreateRegisteredInstance(std::string name = "Test system") + { + return CreateRegisteredInstance(CreateRef(std::move(name))); + } + + Runtime::InstanceRegistry Registry; +}; diff --git a/Elixir/Tests/Engine/Aether/TestMaterialResolver.h b/Elixir/Tests/Engine/Aether/TestMaterialResolver.h new file mode 100644 index 00000000..48d27b99 --- /dev/null +++ b/Elixir/Tests/Engine/Aether/TestMaterialResolver.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include +#include + +using namespace Elixir; +using namespace Elixir::Materials; +using namespace Elixir::Materials::Compilation; +using namespace Elixir::Materials::Rendering; + +class TestMaterialResolver : public MaterialResolver +{ +public: + Ref Resolve(const Ref& instance) override + { + if (!instance || !instance->GetParent()) return nullptr; + const auto result = Compiler::Build(*instance->GetParent()); + return result ? MaterialRenderProxy::Create(result.Material, *instance) : nullptr; + } +}; diff --git a/Elixir/Tests/Engine/Graphics/FrameSlotStateTest.cpp b/Elixir/Tests/Engine/Graphics/FrameSlotStateTest.cpp new file mode 100644 index 00000000..e383967b --- /dev/null +++ b/Elixir/Tests/Engine/Graphics/FrameSlotStateTest.cpp @@ -0,0 +1,108 @@ +#include + +#include +#include + +namespace Elixir +{ + class FrameSlotStateTestContext final : public GraphicsContext + { + public: + FrameSlotStateTestContext() + : GraphicsContext(EGraphicsAPI::Vulkan, nullptr) {} + + void SetFrameNumber(const uint32_t frameNumber) { m_FrameNumber = frameNumber; } + + void Init() override {} + void Shutdown() override {} + void ProcessEvent(Event&) override {} + void RenderFrame(std::function) override {} + void DrainRenderQueue() override {} + void SetClearColor(const glm::vec4&) override {} + void Clear() override {} + void Resize(Extent2D) override {} + Ref GetSecondaryCommandBuffer() const override { return nullptr; } + Ref GetUploadCommandBuffer() const override { return nullptr; } + void EnqueueSecondaryCommandBuffer(const Ref&) const override {} + Extent3D GetSwapchainExtent() const override { return {}; } + + private: + void CreateRenderTargets() override {} + }; + + TEST(FrameSlotStateTest, SelectsValuesForTheCurrentFrameSlot) + { + FrameSlotStateTestContext context; + FrameSlotState state(context); + + context.SetFrameNumber(0); + state.GetCurrent() = 3; + + context.SetFrameNumber(1); + state.GetCurrent() = 8; + + context.SetFrameNumber(2); + EXPECT_EQ(state.GetCurrent(), 3u); + + context.SetFrameNumber(3); + EXPECT_EQ(state.GetCurrent(), 8u); + } + + TEST(FrameSlotPendingStateTest, AppliesEachRevisionOncePerFrameSlot) + { + FrameSlotStateTestContext context; + FrameSlotPendingState state(context); + uint32_t applyCount = 0; + + const auto apply = [&applyCount](uint32_t& resource, const auto changes) + { + ASSERT_EQ(changes.size(), 1u); + resource = changes.front().Value; + ++applyCount; + }; + + EXPECT_TRUE(state.Set("value", 3)); + + context.SetFrameNumber(0); + state.ApplyPendingState(apply); + state.ApplyPendingState(apply); + EXPECT_EQ(state.GetCurrent(), 3u); + + context.SetFrameNumber(1); + state.ApplyPendingState(apply); + EXPECT_EQ(state.GetCurrent(), 3u); + EXPECT_EQ(applyCount, 2u); + + EXPECT_TRUE(state.Set("value", 8)); + state.ApplyPendingState(apply); + EXPECT_EQ(state.GetCurrent(), 8u); + + context.SetFrameNumber(2); + state.ApplyPendingState(apply); + EXPECT_EQ(state.GetCurrent(), 8u); + EXPECT_EQ(applyCount, 4u); + } + + TEST(FrameSlotPendingStateTest, DoesNotApplyAnUnchangedValue) + { + FrameSlotStateTestContext context; + FrameSlotPendingState state(context); + uint32_t applyCount = 0; + + EXPECT_TRUE(state.Set("value", 3)); + EXPECT_FALSE(state.Set("value", 3)); + + state.ApplyPendingState([&applyCount](uint32_t&, const auto changes) + { + EXPECT_EQ(changes.size(), 1u); + ++applyCount; + }); + + state.ApplyPendingState([&applyCount](uint32_t&, const auto) + { + ++applyCount; + }); + + EXPECT_EQ(applyCount, 1u); + } +} diff --git a/Elixir/Tests/Engine/Materials/Compilation/CompilationCacheTest.cpp b/Elixir/Tests/Engine/Materials/Compilation/CompilationCacheTest.cpp new file mode 100644 index 00000000..25072c78 --- /dev/null +++ b/Elixir/Tests/Engine/Materials/Compilation/CompilationCacheTest.cpp @@ -0,0 +1,29 @@ +#include + +#include + +using namespace Elixir; +using namespace Elixir::Materials; +using namespace Elixir::Materials::Compilation; + +TEST(CompilationCacheTest, ReusesACompiledMaterialUntilTheSourceRevisionChanges) +{ + CompilationCache cache{ nullptr }; + const auto material = CreateRef("Cache test"); + + ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleSprite, true)); + + const auto first = cache.GetOrCompile(material); + const auto second = cache.GetOrCompile(material); + + ASSERT_TRUE(first); + EXPECT_EQ(first, second); + + ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleRibbon, true)); + + const auto rebuilt = cache.GetOrCompile(material); + ASSERT_TRUE(rebuilt); + + EXPECT_NE(first, rebuilt); + EXPECT_TRUE(rebuilt->SupportsUsage(EMaterialUsage::ParticleRibbon)); +} diff --git a/Elixir/Tests/Engine/Materials/Compilation/CompilerTest.cpp b/Elixir/Tests/Engine/Materials/Compilation/CompilerTest.cpp new file mode 100644 index 00000000..128756a6 --- /dev/null +++ b/Elixir/Tests/Engine/Materials/Compilation/CompilerTest.cpp @@ -0,0 +1,65 @@ +#include + +#include + +using namespace Elixir; +using namespace Elixir::Materials; +using namespace Elixir::Materials::Compilation; + +TEST(CompilerTest, AssignsStableSlotsByParameterKindAndName) +{ + MaterialGraph graph; + const auto material = CreateRef("Test"); + material->SetGraph(std::move(graph)); + + ASSERT_TRUE(material->DefineParameter("Tint", { + .Kind = EMaterialParameterKind::Value, + .ValueType = EMaterialValueType::Float4, + .DefaultValue = SMaterialParameter::MakeVector(glm::vec4(1.0f)), + })); + ASSERT_TRUE(material->DefineParameter("Albedo", { + .Kind = EMaterialParameterKind::Texture, + .DefaultValue = SMaterialParameter::MakeTexture(nullptr), + })); + + const auto result = Compiler::Build(*material); + + ASSERT_TRUE(result); + ASSERT_EQ(result.Material->Parameters.size(), 2); + EXPECT_EQ(result.Material->Parameters[0].Name, "Albedo"); + EXPECT_EQ(result.Material->Parameters[0].Slot, 0); + EXPECT_EQ(result.Material->Parameters[1].Name, "Tint"); + EXPECT_EQ(result.Material->Parameters[1].Slot, 0); +} + +TEST(CompilerTest, PreservesEnabledRendererUsages) +{ + const auto material = CreateRef("Particle material"); + ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleSprite, true)); + ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleRibbon, true)); + ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleMesh, true)); + + const auto result = Compiler::Build(*material); + + ASSERT_TRUE(result); + EXPECT_TRUE(result.Material->SupportsUsage(EMaterialUsage::ParticleSprite)); + EXPECT_TRUE(result.Material->SupportsUsage(EMaterialUsage::ParticleRibbon)); + EXPECT_TRUE(result.Material->SupportsUsage(EMaterialUsage::ParticleMesh)); +} + +TEST(CompilerTest, DoesNotAliasParticleUsageShadersToSurfaceShader) +{ + SCompiledMaterial material; + + const auto& spriteShader = material.GetShader(EMaterialUsage::ParticleSprite); + const auto& ribbonShader = material.GetShader(EMaterialUsage::ParticleRibbon); + const auto& meshShader = material.GetShader(EMaterialUsage::ParticleMesh); + + EXPECT_EQ(&spriteShader, &material.ParticleSpriteShader); + EXPECT_EQ(&ribbonShader, &material.ParticleRibbonShader); + EXPECT_EQ(&meshShader, &material.ParticleMeshShader); + EXPECT_FALSE(ribbonShader); + EXPECT_FALSE(meshShader); + EXPECT_NE(&ribbonShader, &material.SurfaceShader); + EXPECT_NE(&meshShader, &material.SurfaceShader); +} diff --git a/Elixir/Tests/Engine/Materials/MaterialGraphTest.cpp b/Elixir/Tests/Engine/Materials/MaterialGraphTest.cpp new file mode 100644 index 00000000..02f13201 --- /dev/null +++ b/Elixir/Tests/Engine/Materials/MaterialGraphTest.cpp @@ -0,0 +1,98 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include + +using namespace Elixir; +using namespace Elixir::Materials; +using namespace Elixir::Materials::Nodes; + +// BaseColor = Constant([1,0,0,1]) * Parameter(BaseColorFactor) +TEST(MaterialGraphTest, GeneratesMultiplyBaseColor) +{ + MaterialGraph graph; + + const auto constant = graph.AddNode( + glm::vec4{ 1.0f, 0.0f, 0.0f, 1.0f }, + EMaterialValueType::Float4 + ); + const auto parameter = graph.AddNode( + "BaseColorFactor", + EMaterialValueType::Float4 + ); + const auto multiply = graph.AddNode(); + graph.Connect(constant, multiply, 0); + graph.Connect(parameter, multiply, 1); + graph.SetChannel(EMaterialChannel::BaseColor, multiply); + + const auto hlsl = graph.GenerateHLSL(); + + EXPECT_NE(hlsl.find("mat.BaseColorFactor"), std::string::npos); + EXPECT_NE(hlsl.find("float4(1"), std::string::npos); + EXPECT_NE(hlsl.find(" * "), std::string::npos); + EXPECT_NE(hlsl.find("surface.BaseColor ="), std::string::npos); + EXPECT_NE(hlsl.find(").rgb"), std::string::npos); +} + +// Scalar channels coerce and a shared node is emitted once. +TEST(MaterialGraphTest, ScalarChannelsAndSharedNode) +{ + MaterialGraph graph; + + const auto metallic = graph.AddNode( + glm::vec4{ 0.5f, 0.0f, 0.0f, 0.0f }, + EMaterialValueType::Float + ); + graph.SetChannel(EMaterialChannel::Metallic, metallic); + graph.SetChannel(EMaterialChannel::Roughness, metallic); + + const auto hlsl = graph.GenerateHLSL(); + + EXPECT_NE(hlsl.find("surface.Metallic ="), std::string::npos); + EXPECT_NE(hlsl.find("surface.Roughness ="), std::string::npos); + + const auto first = hlsl.find("float n"); + ASSERT_NE(first, std::string::npos); + EXPECT_EQ(hlsl.find("float n", first + 1), std::string::npos); +} + +TEST(MaterialGraphTest, RoutesTextureAlphaToOpacity) +{ + MaterialGraph graph; + const auto texture = graph.AddNode("Albedo"); + const auto alpha = graph.AddNode(3); + graph.Connect(texture, alpha, 0); + graph.SetChannel(EMaterialChannel::BaseColor, texture); + graph.SetChannel(EMaterialChannel::Opacity, alpha); + + const auto hlsl = graph.GenerateHLSL({ .Textures = {{ "Albedo", "mat.TextureIndices[0]" }} }); + EXPECT_NE(hlsl.find("surface.BaseColor"), std::string::npos); + EXPECT_NE(hlsl.find("surface.Opacity"), std::string::npos); + EXPECT_NE(hlsl.find("SampleTex"), std::string::npos); + EXPECT_NE(hlsl.find(".w"), std::string::npos); +} + +TEST(MaterialGraphTest, GeneratesExponentialRadialGradientForOpacity) +{ + MaterialGraph graph; + + const auto gradient = graph.AddNode( + glm::vec2{ 0.25f, 0.75f }, + 0.4f, + 3.0f + ); + graph.SetChannel(EMaterialChannel::Opacity, gradient); + + const auto hlsl = graph.GenerateHLSL(); + + EXPECT_NE(hlsl.find("length((input.TexCoord - float2(0.250000, 0.750000)) / 0.400000)"), std::string::npos); + EXPECT_NE(hlsl.find("pow(saturate(1.0 -"), std::string::npos); + EXPECT_NE(hlsl.find(", 3.000000)"), std::string::npos); + EXPECT_NE(hlsl.find("surface.Opacity ="), std::string::npos); +} diff --git a/Elixir/Tests/Engine/Materials/MaterialProxyCacheTest.cpp b/Elixir/Tests/Engine/Materials/MaterialProxyCacheTest.cpp new file mode 100644 index 00000000..66d3a925 --- /dev/null +++ b/Elixir/Tests/Engine/Materials/MaterialProxyCacheTest.cpp @@ -0,0 +1,86 @@ +#include + +#include +#include + +using namespace Elixir; +using namespace Elixir::Materials; +using namespace Elixir::Materials::Compilation; +using namespace Elixir::Materials::Rendering; + +namespace +{ + class CountingMaterialResolver final : public MaterialResolver + { + public: + Ref Resolve( + const Ref& instance + ) override + { + ++ResolveCount; + + const auto compiled = Compiler::Build(*instance->GetParent()).Material; + return MaterialRenderProxy::Create(compiled, *instance); + } + + uint32_t ResolveCount = 0; + }; + + Ref CreateInstance() + { + const auto material = CreateRef("Cache test"); + EXPECT_TRUE(material->DefineParameter("Tint", { + .Kind = EMaterialParameterKind::Value, + .ValueType = EMaterialValueType::Float4, + .DefaultValue = SMaterialParameter::MakeVector(glm::vec4(1.0f)), + })); + + return material->CreateInstance(); + } +} + +TEST(MaterialProxyCacheTest, ReusesTheProxyWhileTheInstanceAndMaterialAreCurrent) +{ + CountingMaterialResolver resolver; + MaterialProxyCache cache(resolver); + const auto instance = CreateInstance(); + + const auto first = cache.Resolve(instance); + const auto second = cache.Resolve(instance); + + ASSERT_TRUE(first); + EXPECT_EQ(first, second); + EXPECT_EQ(resolver.ResolveCount, 1); +} + +TEST(MaterialProxyCacheTest, RebuildsTheProxyWhenTheInstanceRevisionChanges) +{ + CountingMaterialResolver resolver; + MaterialProxyCache cache(resolver); + const auto instance = CreateInstance(); + + const auto first = cache.Resolve(instance); + ASSERT_TRUE(instance->SetVector("Tint", { 0.2f, 0.4f, 0.6f, 1.0f })); + const auto rebuilt = cache.Resolve(instance); + + ASSERT_TRUE(first); + ASSERT_TRUE(rebuilt); + EXPECT_NE(first, rebuilt); + EXPECT_EQ(resolver.ResolveCount, 2); +} + +TEST(MaterialProxyCacheTest, RebuildsTheProxyWhenTheParentMaterialRevisionChanges) +{ + CountingMaterialResolver resolver; + MaterialProxyCache cache(resolver); + const auto instance = CreateInstance(); + + const auto first = cache.Resolve(instance); + ASSERT_TRUE(instance->GetParent()->SetUsage(EMaterialUsage::ParticleSprite, true)); + const auto rebuilt = cache.Resolve(instance); + + ASSERT_TRUE(first); + ASSERT_TRUE(rebuilt); + EXPECT_NE(first, rebuilt); + EXPECT_EQ(resolver.ResolveCount, 2); +} diff --git a/Elixir/Tests/Engine/Materials/MaterialRegistryTest.cpp b/Elixir/Tests/Engine/Materials/MaterialRegistryTest.cpp new file mode 100644 index 00000000..ae291d69 --- /dev/null +++ b/Elixir/Tests/Engine/Materials/MaterialRegistryTest.cpp @@ -0,0 +1,32 @@ +#include + +#include + +using namespace Elixir; +using namespace Elixir::Materials; + +TEST(MaterialRegistryTest, RegistersAndFindsDefaultMaterials) +{ + const MaterialRegistry registry; + + for (const auto usage : { + EMaterialUsage::ParticleSprite, + EMaterialUsage::ParticleRibbon, + EMaterialUsage::ParticleMesh + }) + { + const auto& material = registry.GetDefault(usage); + ASSERT_TRUE(material); + EXPECT_EQ(registry.Find(material->GetName()), material); + EXPECT_TRUE(material->SupportsUsage(usage)); + EXPECT_TRUE(material->ValidateGraph()); + EXPECT_TRUE(material->GetParameters().empty()); + } +} + +TEST(MaterialRegistryTest, RejectsDuplicateMaterialNames) +{ + MaterialRegistry registry; + EXPECT_TRUE(registry.Register(CreateRef("Game.Custom"))); + EXPECT_FALSE(registry.Register(CreateRef("Game.Custom"))); +} diff --git a/Elixir/Tests/Engine/Materials/MaterialTest.cpp b/Elixir/Tests/Engine/Materials/MaterialTest.cpp new file mode 100644 index 00000000..afc85ccc --- /dev/null +++ b/Elixir/Tests/Engine/Materials/MaterialTest.cpp @@ -0,0 +1,78 @@ +#include + +#include +#include +#include + +using namespace Elixir; +using namespace Elixir::Materials; +using namespace Elixir::Materials::Nodes; + +TEST(MaterialTest, ValidateGraphParametersAgainstMaterialSchema) +{ + MaterialGraph graph; + + graph.SetChannel( + EMaterialChannel::BaseColor, + graph.AddNode("Tint", EMaterialValueType::Float4) + ); + + auto material = CreateRef("Tinted"); + material->SetGraph(std::move(graph)); + + EXPECT_TRUE(material->DefineParameter("Tint", { + .Kind = EMaterialParameterKind::Value, + .ValueType = EMaterialValueType::Float4, + .DefaultValue = SMaterialParameter::MakeVector(glm::vec4{ 1.0f }), + })); + EXPECT_TRUE(material->ValidateGraph()); +} + +TEST(MaterialTest, RejectsOverridesThatDoNotMatchTheSchema) +{ + const auto material = CreateRef("Tinted"); + + ASSERT_TRUE(material->DefineParameter("Tint", { + .Kind = EMaterialParameterKind::Value, + .ValueType = EMaterialValueType::Float4, + .DefaultValue = SMaterialParameter::MakeVector(glm::vec4{ 1.0f }), + })); + + MaterialInstance instance(material); + const auto revision = instance.GetRevision(); + + EXPECT_FALSE(instance.SetScalar("Tint", 0.5f)); + EXPECT_TRUE(instance.SetVector("Tint", { 0.5f, 0.2f, 0.1f, 1.0f })); + EXPECT_EQ(instance.GetRevision(), revision + 1); +} + +TEST(MaterialTest, ValidatesTextureSampleAgainstTextureParameter) +{ + MaterialGraph graph; + + graph.SetChannel( + EMaterialChannel::BaseColor, + graph.AddNode("AlbedoTexture") + ); + + auto material = CreateRef("Textured"); + material->SetGraph(std::move(graph)); + + EXPECT_TRUE(material->DefineParameter("AlbedoTexture", { + .Kind = EMaterialParameterKind::Texture, + .DefaultValue = SMaterialParameter::MakeTexture(nullptr), + })); + EXPECT_TRUE(material->ValidateGraph()); +} + +TEST(MaterialTest, CreatesInstancesThatKeepTheirParentAlive) +{ + auto material = CreateRef("InstanceOwner"); + const auto instance = material->CreateInstance(); + const auto parent = instance->GetParent(); + material.reset(); + + ASSERT_TRUE(instance); + ASSERT_TRUE(parent); + EXPECT_EQ(instance->GetParent(), parent); +} diff --git a/Elixir/Tests/Engine/Materials/Rendering/FrameTableTest.cpp b/Elixir/Tests/Engine/Materials/Rendering/FrameTableTest.cpp new file mode 100644 index 00000000..e7f405f4 --- /dev/null +++ b/Elixir/Tests/Engine/Materials/Rendering/FrameTableTest.cpp @@ -0,0 +1,138 @@ +#include + +#include +#include +#include +#include + +using namespace Elixir; +using namespace Elixir::Materials; +using namespace Elixir::Materials::Compilation; +using namespace Elixir::Materials::Rendering; + +namespace +{ + class TestTexture final : public Texture + { + public: + TestTexture() + : Texture(nullptr, EImageFormat::R8G8B8A8_UNORM, 1) {} + + void Destroy() override {} + void Resize(const Ref& cmd, Extent3D extent) override {} + void Transition(const CommandBuffer* cmd, EImageLayout layout) override {} + + void Copy( + const CommandBuffer* cmd, + Image* dst, + const Extent3D& srcExtent, + const Extent3D& dstExtent + ) override {} + + void CopyFrom( + const CommandBuffer* cmd, + const Buffer* src, + std::span regions + ) override {} + + bool IsValid() const override { return true; } + + protected: + void UpdateSampler() override {} + }; +} + +TEST(FrameTableTest, DeduplicatesAProxyAndPreservesItsValues) +{ + auto material = CreateRef("Particle material"); + ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleSprite, true)); + ASSERT_TRUE(material->DefineParameter("Tint", { + .Kind = EMaterialParameterKind::Value, + .ValueType = EMaterialValueType::Float4, + .DefaultValue = SMaterialParameter::MakeVector({ 1.0f, 1.0f, 1.0f, 1.0f }), + })); + + auto instance = CreateRef(material); + ASSERT_TRUE(instance->SetVector("Tint", { 0.25f, 0.5f, 0.75f, 1.0f })); + + const auto compiled = Compiler::Build(*material); + ASSERT_TRUE(compiled); + + const auto proxy = MaterialRenderProxy::Create(compiled.Material, *instance); + ASSERT_TRUE(proxy); + + FrameTable table( + 1, + 17, + [](const Ref&) { return 23; } + ); + const auto first = table.Add(*proxy); + const auto second = table.Add(*proxy); + + ASSERT_TRUE(first); + ASSERT_TRUE(second); + EXPECT_EQ(*first, 0); + EXPECT_EQ(*second, 0); + ASSERT_EQ(table.GetCount(), 1); + + const auto& data = table.GetData()[0]; + EXPECT_EQ(data.Values[0], glm::vec4(0.25f, 0.5f, 0.75f, 1.0f)); + EXPECT_EQ(data.TextureIndices[0], 17); + EXPECT_EQ(data.TextureIndices.back(), 17); +} + +TEST(FrameTableTest, RejectsAUniqueProxyPastCapacity) +{ + FrameTable table( + 0, + 0, + [](const Ref&) { return 0; } + ); + + auto material = CreateRef("Particle material"); + auto instance = CreateRef(material); + const auto compiled = Compiler::Build(*material); + ASSERT_TRUE(compiled); + + const auto proxy = MaterialRenderProxy::Create(compiled.Material, *instance); + ASSERT_TRUE(proxy); + EXPECT_FALSE(table.Add(*proxy)); +} + +TEST(FrameTableTest, ResolvesAuthoredTextureSlots) +{ + const auto texture = CreateRef(); + + auto material = CreateRef("Particle material"); + ASSERT_TRUE(material->DefineParameter("Albedo", { + .Kind = EMaterialParameterKind::Texture, + .DefaultValue = SMaterialParameter::MakeTexture(texture), + })); + + auto instance = CreateRef(material); + const auto compiled = Compiler::Build(*material); + ASSERT_TRUE(compiled); + + const auto proxy = MaterialRenderProxy::Create(compiled.Material, *instance); + ASSERT_TRUE(proxy); + + uint32_t resolveCount = 0; + FrameTable table( + 1, + 5, + [&resolveCount, &texture](const Ref& resolved) + { + ++resolveCount; + EXPECT_EQ(resolved, texture); + return 37; + } + ); + + ASSERT_TRUE(table.Add(*proxy)); + ASSERT_EQ(table.GetCount(), 1); + + const auto& data = table.GetData()[0]; + EXPECT_EQ(resolveCount, 1); + EXPECT_EQ(data.TextureIndices[0], 37); + EXPECT_EQ(data.TextureIndices[1], 5); +} diff --git a/Elixir/Tests/Engine/Materials/Rendering/MaterialRenderProxyTest.cpp b/Elixir/Tests/Engine/Materials/Rendering/MaterialRenderProxyTest.cpp new file mode 100644 index 00000000..9edc7bf3 --- /dev/null +++ b/Elixir/Tests/Engine/Materials/Rendering/MaterialRenderProxyTest.cpp @@ -0,0 +1,52 @@ +#include + +#include +#include +#include + +using namespace Elixir; +using namespace Elixir::Materials; +using namespace Elixir::Materials::Compilation; +using namespace Elixir::Materials::Rendering; + +TEST(MaterialRenderProxyTest, ResolvesOverridesIntoAnImmutableSnapshot) +{ + const auto material = CreateRef("Tinted"); + ASSERT_TRUE(material->DefineParameter("Tint", { + .Kind = EMaterialParameterKind::Value, + .ValueType = EMaterialValueType::Float4, + .DefaultValue = SMaterialParameter::MakeVector(glm::vec4(1.0f)), + })); + + const auto compiled = Compiler::Build(*material).Material; + ASSERT_TRUE(compiled); + + MaterialInstance instance(material); + ASSERT_TRUE(instance.SetVector("Tint", { 0.2f, 0.4f, 0.6, 1.0f })); + + const auto proxy = MaterialRenderProxy::Create(compiled, instance); + ASSERT_TRUE(proxy); + ASSERT_EQ(proxy->GetValues().size(), 1); + EXPECT_EQ(proxy->GetValues()[0], glm::vec4(0.2f, 0.4f, 0.6f, 1.0f)); + EXPECT_EQ(proxy->GetInstanceRevision(), instance.GetRevision()); +} + +TEST(MaterialRenderProxyTest, RejectsACompiledMaterialForAnOldSchema) +{ + const auto material = CreateRef("Tinted"); + ASSERT_TRUE(material->DefineParameter("Tint", { + .Kind = EMaterialParameterKind::Value, + .ValueType = EMaterialValueType::Float4, + .DefaultValue = SMaterialParameter::MakeVector(glm::vec4(1.0f)), + })); + + const auto compiled = Compiler::Build(*material).Material; + ASSERT_TRUE(compiled); + ASSERT_TRUE(material->SetDefaultParameter( + "Tint", + SMaterialParameter::MakeVector(glm::vec4(0.5f)) + )); + + MaterialInstance instance(material); + EXPECT_FALSE(MaterialRenderProxy::Create(compiled, instance)); +} diff --git a/Elixir/Tests/Engine/Materials/Rendering/MaterialRenderSceneTest.cpp b/Elixir/Tests/Engine/Materials/Rendering/MaterialRenderSceneTest.cpp new file mode 100644 index 00000000..aec3ca07 --- /dev/null +++ b/Elixir/Tests/Engine/Materials/Rendering/MaterialRenderSceneTest.cpp @@ -0,0 +1,51 @@ +#include +#include + +#include + +using namespace Elixir; +using namespace Elixir::Materials; +using namespace Elixir::Materials::Rendering; + +TEST(MaterialRenderSceneTest, PreservesAnUnboundMaterialItem) +{ + MaterialRenderScene scene; + BufferLayout vertexLayout; + const auto geometry = scene.AddGeometry({ + .Pipeline = { + .VertexLayoutKey = 42, + .VertexLayout = &vertexLayout, + }, + }); + + scene.Add({ + .Pass = EMaterialPass::ParticleRibbon, + .GeometryIndex = geometry, + }); + + const auto items = scene.GetItems(); + + ASSERT_EQ(items.size(), 1); + EXPECT_EQ(items.front().Pass, EMaterialPass::ParticleRibbon); + EXPECT_FALSE(items.front().Material); +} + +TEST(MaterialRenderSceneTest, ResolvesLateMaterialIndex) +{ + struct SPushConstants + { + uint32_t MaterialIndex = UINT32_MAX; + }; + + const auto constants = SMaterialPushConstants::Create( + SPushConstants{}, + offsetof(SPushConstants, MaterialIndex) + ); + + const auto resolved = constants.Resolve(17); + + SPushConstants values{}; + Memory::Memcpy(&values, resolved.data(), sizeof(values)); + + EXPECT_EQ(values.MaterialIndex, 17); +} diff --git a/Elixir/Tests/Engine/Materials/Rendering/RendererTest.cpp b/Elixir/Tests/Engine/Materials/Rendering/RendererTest.cpp new file mode 100644 index 00000000..0f122821 --- /dev/null +++ b/Elixir/Tests/Engine/Materials/Rendering/RendererTest.cpp @@ -0,0 +1,87 @@ +#include + +#include +#include +#include + +using namespace Elixir; +using namespace Elixir::Materials; +using namespace Elixir::Materials::Compilation; +using namespace Elixir::Materials::Rendering; + +namespace +{ + template + concept HasMaterialIndex = requires(T value) + { + value.MaterialIndex; + }; + + static_assert(!HasMaterialIndex); + + Ref CreateInstance() + { + const auto material = CreateRef("Renderer test"); + EXPECT_TRUE(material->DefineParameter("Tint", { + .Kind = EMaterialParameterKind::Value, + .ValueType = EMaterialValueType::Float4, + .DefaultValue = SMaterialParameter::MakeVector(glm::vec4(1.0f)), + })); + + return material->CreateInstance(); + } +} + +TEST(RendererTest, MapsParticlePassesToMaterialUsages) +{ + EXPECT_EQ( + Renderer::GetUsage(EMaterialPass::ParticleSprite), + EMaterialUsage::ParticleSprite + ); + EXPECT_EQ( + Renderer::GetUsage(EMaterialPass::ParticleRibbon), + EMaterialUsage::ParticleRibbon + ); + EXPECT_EQ( + Renderer::GetUsage(EMaterialPass::ParticleMesh), + EMaterialUsage::ParticleMesh + ); +} + +TEST(RendererTest, PreparedSceneKeepsResolvedMaterialDataWithTheSourceItem) +{ + const auto instance = CreateInstance(); + const auto compiled = Compiler::Build(*instance->GetParent()).Material; + const auto proxy = MaterialRenderProxy::Create(compiled, *instance); + + ASSERT_TRUE(proxy); + + MaterialRenderScene scene; + BufferLayout vertexLayout; + const auto geometry = scene.AddGeometry({ + .Pipeline = { + .VertexLayoutKey = 42, + .VertexLayout = &vertexLayout, + }, + }); + scene.Add({ + .Material = instance, + .GeometryIndex = geometry, + }); + + const auto items = scene.GetItems(); + const SPreparedScene prepared{ + .Scene = &scene, + .Items = { + { + .Item = &items.front(), + .Proxy = proxy, + }, + }, + }; + + ASSERT_EQ(prepared.Items.size(), 1); + EXPECT_EQ(prepared.Scene, &scene); + EXPECT_EQ(prepared.Items.front().Item, &items.front()); + EXPECT_EQ(prepared.Items.front().Proxy, proxy); +} diff --git a/Elixir/Tests/Engine/Materials/Rendering/TextureRegistryTest.cpp b/Elixir/Tests/Engine/Materials/Rendering/TextureRegistryTest.cpp new file mode 100644 index 00000000..d1f79e59 --- /dev/null +++ b/Elixir/Tests/Engine/Materials/Rendering/TextureRegistryTest.cpp @@ -0,0 +1,20 @@ +#include + +#include + +using namespace Elixir; +using namespace Elixir::Materials::Rendering; + +TEST(TextureRegistryTest, UsesFallbackUntilDescriptorIsVisible) +{ + constexpr uint32_t fallbackIndex = 3; + + const STextureBinding binding{ + .Handle = SResourceHandle::Texture(17), + .ReadySubmission = 8, + }; + + EXPECT_EQ(binding.GetIndexForSubmission(7, fallbackIndex), fallbackIndex); + EXPECT_EQ(binding.GetIndexForSubmission(8, fallbackIndex), 17); + EXPECT_EQ(binding.GetIndexForSubmission(9, fallbackIndex), 17); +} diff --git a/Elixir/Tests/Graphics/Vulkan/VulkanBufferTest.cpp b/Elixir/Tests/Graphics/Vulkan/VulkanBufferTest.cpp index 2d3351a8..d3e008e2 100644 --- a/Elixir/Tests/Graphics/Vulkan/VulkanBufferTest.cpp +++ b/Elixir/Tests/Graphics/Vulkan/VulkanBufferTest.cpp @@ -4,6 +4,9 @@ using namespace testing; #include #include #include + +#include "VulkanTestContext.h" + using namespace Elixir::Vulkan; class VulkanBufferTest : public Test @@ -11,23 +14,13 @@ class VulkanBufferTest : public Test protected: static void SetUpTestSuite() { - Memory::s_Malloc = CreateScope(); - Window = Window::Create(); - Context = GraphicsContext::Create(EGraphicsAPI::Vulkan, &Elixir::Executor::Get(), Window.get()); - Context->Init(); - } - - static void TearDownTestSuite() - { - Context->Shutdown(); + Context = VulkanTestContext::Get().GetGraphicsContext(); } - static Scope Window; - static Scope Context; + static GraphicsContext* Context; }; -Scope VulkanBufferTest::Window = nullptr; -Scope VulkanBufferTest::Context = nullptr; +GraphicsContext* VulkanBufferTest::Context = nullptr; TEST_F(VulkanBufferTest, VulkanBaseBuffer_IsNotConstructibleAndAssignable) { @@ -66,7 +59,7 @@ TEST_F(VulkanBufferTest, VulkanBuffer_CreationAndDestruction) info.Usage = EBufferUsage::TransferDst; info.AllocationInfo = {}; - const auto buffer = Buffer::Create(Context.get(), info); + const auto buffer = Buffer::Create(Context, info); EXPECT_EQ(buffer->GetSize(), 256); EXPECT_EQ(buffer->GetUsage(), EBufferUsage::TransferDst); @@ -82,10 +75,10 @@ TEST_F(VulkanBufferTest, VulkanBuffer_DestroyAfterCopy) info.Usage = EBufferUsage::TransferDst; info.AllocationInfo = {}; - const auto staging = StagingBuffer::Create(Context.get(), info.Buffer.Size); - const auto target = Buffer::Create(Context.get(), info); + const auto staging = StagingBuffer::Create(Context, info.Buffer.Size); + const auto target = Buffer::Create(Context, info); - const auto cmd = Context->GetSecondaryCommandBuffer(); + const auto cmd = Context->GetUploadCommandBuffer(); cmd->Begin(); staging->Copy(cmd, target); cmd->End(); @@ -104,7 +97,7 @@ TEST_F(VulkanBufferTest, VulkanBuffer_DoubleDestroyIsSafe) info.Usage = EBufferUsage::TransferDst; info.AllocationInfo = {}; - const auto buffer = Buffer::Create(Context.get(), info); + const auto buffer = Buffer::Create(Context, info); // First destroy buffer->Destroy(); @@ -132,7 +125,7 @@ TEST_F(VulkanBufferTest, VulkanStagingBuffer_CreationAndMapping) constexpr size_t size = 128; const std::vector data(size, 0xFF); - const auto buffer = StagingBuffer::Create(Context.get(), size, data.data()); + const auto buffer = StagingBuffer::Create(Context, size, data.data()); EXPECT_EQ(buffer->GetSize(), size); EXPECT_EQ(buffer->GetUsage(), EBufferUsage::TransferSrc); @@ -166,7 +159,7 @@ TEST_F(VulkanBufferTest, VulkanVertexBuffer_CreationAndAddress) constexpr size_t size = 512; const std::vector data(size, (Byte)0xAA); - const auto buffer = VertexBuffer::Create(Context.get(), size, data.data()); + const auto buffer = VertexBuffer::Create(Context, size, data.data()); EXPECT_EQ(buffer->GetSize(), size); EXPECT_GT(buffer->GetAddress(), 0); @@ -194,7 +187,7 @@ TEST_F(VulkanBufferTest, VulkanIndexBuffer_CreationAndIndexType) const std::vector data(size, 0xBB); const auto buffer = IndexBuffer::Create( - Context.get(), + Context, size, data.data(), EIndexType::UInt32 @@ -225,7 +218,7 @@ TEST_F(VulkanBufferTest, VulkanUniformBuffer_CreationAndMapping) constexpr size_t size = 128; const std::vector data(size, 0xFF); - const auto buffer = UniformBuffer::Create(Context.get(), size, data.data()); + const auto buffer = UniformBuffer::Create(Context, size, data.data()); EXPECT_EQ(buffer->GetSize(), size); EXPECT_EQ(buffer->GetUsage(), EBufferUsage::UniformBuffer); @@ -240,4 +233,4 @@ TEST_F(VulkanBufferTest, VulkanUniformBuffer_CreationAndMapping) buffer->Destroy(); EXPECT_FALSE(buffer->IsValid()); -} \ No newline at end of file +} diff --git a/Elixir/Tests/Graphics/Vulkan/VulkanImageTest.cpp b/Elixir/Tests/Graphics/Vulkan/VulkanImageTest.cpp index 0db81b06..52aebb9d 100644 --- a/Elixir/Tests/Graphics/Vulkan/VulkanImageTest.cpp +++ b/Elixir/Tests/Graphics/Vulkan/VulkanImageTest.cpp @@ -6,6 +6,9 @@ using namespace testing; #include #include #include + +#include "VulkanTestContext.h" + using namespace Elixir; using namespace Elixir::Vulkan; @@ -14,23 +17,13 @@ class VulkanImageTest : public Test protected: static void SetUpTestSuite() { - Memory::s_Malloc = CreateScope(); - Window = Window::Create(); - Context = GraphicsContext::Create(EGraphicsAPI::Vulkan, &Elixir::Executor::Get(), Window.get()); - Context->Init(); - } - - static void TearDownTestSuite() - { - Context->Shutdown(); + Context = VulkanTestContext::Get().GetGraphicsContext(); } - static Scope Window; - static Scope Context; + static GraphicsContext* Context; }; -Scope VulkanImageTest::Window = nullptr; -Scope VulkanImageTest::Context = nullptr; +GraphicsContext* VulkanImageTest::Context = nullptr; TEST_F(VulkanImageTest, VulkanBaseImage_IsNotConstructibleAndAssignable) { @@ -55,7 +48,7 @@ TEST_F(VulkanImageTest, VulkanImage_MoveConstructorIsDeleted) TEST_F(VulkanImageTest, VulkanImage_CreationAndDestruction) { - const auto image = Image::Create(Context.get(), EImageFormat::R8G8B8A8_SRGB, 800); + const auto image = Image::Create(Context, EImageFormat::R8G8B8A8_SRGB, 800); const auto vk_Image = dynamic_cast*>(image.get()); ASSERT_TRUE(vk_Image != nullptr); @@ -78,7 +71,7 @@ TEST_F(VulkanImageTest, VulkanImage_CreationAndDestruction) TEST_F(VulkanImageTest, VulkanDepthStencilImage_DepthOnly) { const auto image = DepthStencilImage::Create( - Context.get(), + Context, EDepthStencilImageFormat::D32_SFLOAT, 800, 600 ); @@ -105,7 +98,7 @@ TEST_F(VulkanImageTest, VulkanDepthStencilImage_DepthOnly) TEST_F(VulkanImageTest, VulkanDepthStencilImage_DepthStencil) { const auto image = DepthStencilImage::Create( - Context.get(), + Context, EDepthStencilImageFormat::D32_SFLOAT_S8_UINT, 800, 600 ); @@ -135,10 +128,10 @@ TEST_F(VulkanImageTest, VulkanImage_LayoutTransition) { info.InitialLayout = EImageLayout::TransferDst; // Initially the image is transitioned to layout defined in "InitialLayout" - VulkanImage image(Context.get(), info); + VulkanImage image(Context, info); EXPECT_EQ(image.GetLayout(), EImageLayout::TransferDst); - const auto cmd = Context->GetSecondaryCommandBuffer(); + const auto cmd = Context->GetUploadCommandBuffer(); cmd->Begin(); // Now perform a manual transition @@ -149,7 +142,7 @@ TEST_F(VulkanImageTest, VulkanImage_LayoutTransition) { } TEST_F(VulkanImageTest, VulkanImage_ImageDestruction) { - const auto image = Image::Create(Context.get(), EImageFormat::R8G8B8A8_SRGB, 128); + const auto image = Image::Create(Context, EImageFormat::R8G8B8A8_SRGB, 128); EXPECT_TRUE(image->IsValid()); image->Destroy(); @@ -162,10 +155,10 @@ TEST_F(VulkanImageTest, VulkanImage_ImageDestruction) { TEST_F(VulkanImageTest, TryToGetVulkanImageHandle) { SImageCreateInfo info = Image::CreateImageInfo(EImageFormat::R8G8B8A8_UNORM, 32); - VulkanImage image(Context.get(), info); + VulkanImage image(Context, info); SImageCreateInfo dsInfo = DepthStencilImage::CreateImageInfo(EDepthStencilImageFormat::D16_UNORM, 64, 64); - VulkanDepthStencilImage dstImage(Context.get(), dsInfo); + VulkanDepthStencilImage dstImage(Context, dsInfo); VkImage imgHandle = TryToGetVulkanImageHandle(&image); VkImage dstImgHandle = TryToGetVulkanImageHandle(&dstImage); @@ -173,4 +166,4 @@ TEST_F(VulkanImageTest, TryToGetVulkanImageHandle) { EXPECT_EQ(imgHandle, image.GetVulkanImage()); EXPECT_EQ(dstImgHandle, dstImage.GetVulkanImage()); EXPECT_NE(imgHandle, VK_NULL_HANDLE); -} \ No newline at end of file +} diff --git a/Elixir/Tests/Graphics/Vulkan/VulkanTestContext.h b/Elixir/Tests/Graphics/Vulkan/VulkanTestContext.h new file mode 100644 index 00000000..d1b1c43c --- /dev/null +++ b/Elixir/Tests/Graphics/Vulkan/VulkanTestContext.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +using namespace Elixir; + +class VulkanTestContext final +{ +public: + static VulkanTestContext& Get() + { + static VulkanTestContext context; + return context; + } + + GraphicsContext* GetGraphicsContext() const { return m_Context.get(); } + +private: + VulkanTestContext() + { + Memory::s_Malloc = CreateScope(); + m_Window = Window::Create(); + m_Context = GraphicsContext::Create( + EGraphicsAPI::Vulkan, + &Executor::Get(), + m_Window.get() + ); + m_Context->Init(); + } + + Scope m_Window; + Scope m_Context; +}; diff --git a/Shaders/.gitignore b/Shaders/.gitignore new file mode 100644 index 00000000..ccef480a --- /dev/null +++ b/Shaders/.gitignore @@ -0,0 +1 @@ +/Generated \ No newline at end of file diff --git a/Shaders/Aether/Mesh.vs.hlsl b/Shaders/Aether/Mesh.vs.hlsl index 5d3cfa0e..0b0790c1 100644 --- a/Shaders/Aether/Mesh.vs.hlsl +++ b/Shaders/Aether/Mesh.vs.hlsl @@ -46,7 +46,8 @@ cbuffer cbFrame : register(b0) struct PushConstants { - float4x4 WorldTransform; + float4x4 WorldTransform; + uint MaterialIndex; }; [[vk::push_constant]] diff --git a/Shaders/Aether/Ribbon.vs.hlsl b/Shaders/Aether/Ribbon.vs.hlsl index ffdbda21..d32cc377 100644 --- a/Shaders/Aether/Ribbon.vs.hlsl +++ b/Shaders/Aether/Ribbon.vs.hlsl @@ -19,6 +19,7 @@ struct Emitter float4 MetaD; // x = emission index }; + [[vk::binding(2, 0)]] StructuredBuffer emitters; @@ -37,6 +38,7 @@ struct PushConstants float4x4 WorldTransform; uint EmitterIndex; uint ParticleBaseOffset; + uint MaterialIndex; }; [[vk::push_constant]] diff --git a/Shaders/Aether/Sprite.ps.hlsl b/Shaders/Aether/Sprite.ps.hlsl index 64ba3286..1a40bdee 100644 --- a/Shaders/Aether/Sprite.ps.hlsl +++ b/Shaders/Aether/Sprite.ps.hlsl @@ -8,6 +8,7 @@ SamplerState spriteSampler : register(s0); struct PushConstants { float4x4 WorldTransform; + uint MaterialIndex; uint SpriteIndex; }; [[vk::push_constant]] diff --git a/Shaders/Aether/Sprite.vs.hlsl b/Shaders/Aether/Sprite.vs.hlsl index 6019852e..8c463f04 100644 --- a/Shaders/Aether/Sprite.vs.hlsl +++ b/Shaders/Aether/Sprite.vs.hlsl @@ -13,6 +13,7 @@ cbuffer cbFrame : register(b0) struct PushConstants { float4x4 WorldTransform; + uint MaterialIndex; uint SpriteIndex; }; diff --git a/Shaders/Material/Material.ps.hlsl b/Shaders/Material/Material.ps.hlsl new file mode 100644 index 00000000..40a1f6b0 --- /dev/null +++ b/Shaders/Material/Material.ps.hlsl @@ -0,0 +1,146 @@ +// Template pixel shader for node-graph materials. The graph codegen fills the +// surface struct at the __GRAPH_BODY__ marker; the rest is fixed shading shared +// by every graph material (IBL diffuse + specular, ACES tonemap). + +[[vk::binding(0, 0)]] +cbuffer cbFrame : register(b0) +{ + float4x4 View; + float4x4 Proj; + float4x4 ViewProj; + float3 CameraPos; + float Time; + uint EnvIndex; + uint IrradianceIndex; + float EnvIntensity; + float EnvMaxLod; + uint PrefIndex; + uint SceneColorIndex; + float ScreenWidth; + float ScreenHeight; + float4 LightDirection; + float4 LightColor; +}; + +[[vk::binding(1, 0)]] +SamplerState texSampler : register(s0); + +struct CompiledMaterial +{ + float4 Values[32]; + uint TextureIndices[32]; +}; + +[[vk::binding(2, 0)]] +StructuredBuffer materials; + +[[vk::binding(1, 1)]] +Texture2D textures[] : register(t0); + +struct PushConstants +{ + float4x4 Model; + uint MaterialIndex; +}; + +[[vk::push_constant]] +PushConstants pc; + +struct PSInput +{ + float4 ClipPos : SV_Position; + float3 Normal : NORMAL0; + float4 Tangent : TANGENT0; + float2 TexCoord : TEXCOORD0; + float3 WorldPos : POSITION0; + bool FrontFace : SV_IsFrontFace; +}; + +struct Surface +{ + float3 BaseColor; + float3 Normal; + float Metallic; + float Roughness; + float Opacity; + float3 Emissive; +}; + +static const uint NO_TEXTURE = 0xFFFFFFFFu; + +float4 SampleTex(uint index, float2 uv) +{ + return textures[index].Sample(texSampler, uv); +} + +float2 DirToEquirect(float3 dir) +{ + float u = atan2(dir.z, dir.x) * 0.15915494f + 0.5f; + float v = acos(clamp(dir.y, -1.0f, 1.0f)) * 0.31830989f; + return float2(u, v); +} + +float3 SampleIrradiance(float3 dir) +{ + if (IrradianceIndex == NO_TEXTURE) + return float3(0.1f, 0.12f, 0.15f); + return textures[IrradianceIndex].SampleLevel(texSampler, DirToEquirect(dir), 0).rgb * EnvIntensity; +} + +float3 SampleEnv(float3 dir, float roughness) +{ + if (EnvIndex == NO_TEXTURE) + return float3(0.1f, 0.12f, 0.15f); + return textures[EnvIndex].SampleLevel(texSampler, DirToEquirect(dir), roughness * EnvMaxLod).rgb * EnvIntensity; +} + +float3 ACESFilm(float3 x) +{ + const float a = 2.51f, b = 0.03f, c = 2.43f, d = 0.59f, e = 0.14f; + return saturate((x * (a * x + b)) / (x * (c * x + d) + e)); +} + +float4 main(PSInput input) : SV_Target0 +{ + CompiledMaterial mat = materials[pc.MaterialIndex]; + + float3 N = normalize(input.Normal); + if (!input.FrontFace) + N = -N; + float3 V = normalize(CameraPos - input.WorldPos); + + // Defaults; the graph overrides whichever channels it drives. + Surface surface; + surface.BaseColor = float3(0.8f, 0.8f, 0.8f); + surface.Normal = float3(0.0f, 0.0f, 1.0f); + surface.Metallic = 0.0f; + surface.Roughness = 0.5f; + surface.Opacity = 1.0f; + surface.Emissive = float3(0.0f, 0.0f, 0.0f); + + // __GRAPH_BODY__ + + float roughness = clamp(surface.Roughness, 0.045f, 1.0f); + float3 F0 = lerp(0.04f.xxx, surface.BaseColor, surface.Metallic); + float NdotV = saturate(dot(N, V)) + 1e-4f; + + float3 diffuse = SampleIrradiance(N) * surface.BaseColor * (1.0f - surface.Metallic); + float3 R = reflect(-V, N); + + float3 fresnel = F0 + (max((1.0f - roughness).xxx, F0) - F0) * pow(saturate(1.0f - NdotV), 5.0f); + float3 specular = SampleEnv(R, roughness) * fresnel; + + float3 color = diffuse + specular + surface.Emissive; + + // Direcional light: Lambert diffuse + a simple spec. + float3 L = normalize(LightDirection.xyz); + float NdotL = saturate(dot(N, L)); + float3 H = normalize(V + L); + float spec = pow(saturate(dot(N, H)), max(2.0f, (1.0f - roughness) * 128.0f)); + color += (surface.BaseColor * (1.0f - surface.Metallic) + F0 * spec) * LightColor.rgb * LightColor.w * NdotL; + + // Tone mapping + color = ACESFilm(color); + + return float4(color, surface.Opacity); +} \ No newline at end of file diff --git a/Shaders/Material/ParticleMesh.ps.hlsl b/Shaders/Material/ParticleMesh.ps.hlsl new file mode 100644 index 00000000..0b3208de --- /dev/null +++ b/Shaders/Material/ParticleMesh.ps.hlsl @@ -0,0 +1,90 @@ +// Template for EMaterialUsage::ParticleMesh. The generated graph body writes +// Surface fields using mesh colors, planar UVs, time, material values and textures. + +[[vk::binding(0, 0)]] +cbuffer cbFrame : register(b0) +{ + float4x4 View; + float4x4 Proj; + float4x4 ViewProj; + float3 CameraPos; + float Time; +}; + +[[vk::binding(1, 0)]] +SamplerState spriteSampler : register(s0); + +[[vk::binding(1, 1)]] +Texture2D sprites[] : register(t0); + +struct CompiledMaterial +{ + float4 Values[32]; + uint TextureIndices[32]; +}; + +[[vk::binding(2, 0)]] +StructuredBuffer materials; + +struct MaterialPushConstants +{ + float4x4 WorldTransform; + uint MaterialIndex; +}; + +[[vk::push_constant]] +MaterialPushConstants pc; + +struct PSInput +{ + float4 ClipPos : SV_POSITION; + float3 WorldPos : POSITION0; + float3 Normal : NORMAL0; + float4 Color : COLOR0; + float2 TexCoord : TEXCOORD0; +}; + +static const float3 LIGHT_DIRECTION = float3(-0.45, 0.8, 0.55); +static const float3 RIM_COLOR = float3(0.35, 0.42, 0.52); + +struct Surface +{ + float3 BaseColor; + float3 Normal; + float Metallic; + float Roughness; + float Opacity; + float3 Emissive; +}; + +float4 SampleTex(uint index, float2 uv) +{ + return sprites[index].Sample(spriteSampler, uv); +} + +float4 main(PSInput input) : SV_Target0 +{ + CompiledMaterial mat = materials[pc.MaterialIndex]; + + Surface surface; + surface.BaseColor = float3(1.0f, 1.0f, 1.0f); + surface.Normal = float3(0.0f, 0.0f, 1.0f); + surface.Metallic = 0.0f; + surface.Roughness = 0.5f; + surface.Opacity = 1.0f; + surface.Emissive = float3(0.0f, 0.0f, 0.0f); + + // __GRAPH_BODY__ + + const float3 normal = normalize(input.Normal); + const float3 lightDirection = normalize(LIGHT_DIRECTION); + const float3 viewDirection = normalize(CameraPos - input.WorldPos); + + const float diffuse = max(dot(normal, lightDirection), 0.0f); + const float rim = pow(1.0f - max(dot(normal, viewDirection), 0.0f), 2.8f); + const float3 litColor = (surface.BaseColor * (0.24f + diffuse * 0.92f)) + + surface.Emissive + + (RIM_COLOR * rim * 0.35f); + + return float4(litColor, surface.Opacity); +} \ No newline at end of file diff --git a/Shaders/Material/ParticleMesh.vs.hlsl b/Shaders/Material/ParticleMesh.vs.hlsl new file mode 100644 index 00000000..1f6b77cd --- /dev/null +++ b/Shaders/Material/ParticleMesh.vs.hlsl @@ -0,0 +1,122 @@ +// Template for EMaterialUsage::ParticleMesh. It mirrors Aether/Mesh.vs.hlsl +// while exposing MaterialIndex and planar UVs to the generated fragment graph. + +float3x3 RotationX(float angle) +{ + float c = cos(angle); + float s = sin(angle); + + return float3x3( + 1.0, 0.0, 0.0, + 0.0, c, -s, + 0.0, s, c + ); +} + +float3x3 RotationY(float angle) +{ + float c = cos(angle); + float s = sin(angle); + + return float3x3( + c, 0.0, s, + 0.0, 1.0, 0.0, + -s, 0.0, c + ); +} + +float3x3 RotationZ(float angle) +{ + float c = cos(angle); + float s = sin(angle); + + return float3x3( + c, -s, 0.0, + s, c, 0.0, + 0.0, 0.0, 1.0 + ); +} + +[[vk::binding(0, 0)]] +cbuffer cbFrame : register(b0) +{ + float4x4 View; + float4x4 Proj; + float4x4 ViewProj; + float3 CameraPos; + float Time; +}; + +struct MaterialPushConstants +{ + float4x4 WorldTransform; + uint MaterialIndex; +}; + +[[vk::push_constant]] +MaterialPushConstants pc; + +struct VSInput +{ + float3 LocalPos : POSITION0; + float3 LocalNormal : NORMAL0; + float4 PositionSize : POSITION1; + float4 VelocityAge : TEXCOORD0; + float4 Transform : TEXCOORD1; + float4 TangentRibbonId : TANGENT; + float4 Color : COLOR; + float4 Metadata : TEXCOORD2; +}; + +struct VSOutput +{ + float4 ClipPos : SV_POSITION; + float3 WorldPos : POSITION0; + float3 Normal : NORMAL0; + float4 Color : COLOR0; + float2 TexCoord : TEXCOORD0; +}; + +float Hash01(uint x) +{ + x ^= x >> 16; + x *= 0x7feb352du; + x ^= x >> 15; + x *= 0x846ca68bu; + x ^= x >> 16; + return float(x) * (1.0 / 4294967296.0); // / 2^32 +} + +VSOutput main(VSInput input) +{ + VSOutput output; + + const uint id = asuint(input.Metadata.y); + const float alive = input.Metadata.w >= 0.5 ? 1.0 : 0.0; + const float seed = Hash01(id); + const float phase = Hash01(id ^ 0x9e3779b9u); + + float scale = lerp(0.045, 0.085, clamp(input.PositionSize.w / 18.0, 0.0, 1.0)); + scale *= max(input.Transform.y, 0.0); + + const float baseRotation = input.Transform.x; + const float rotX = baseRotation + (seed * 1.7); + const float rotY = baseRotation * 0.7 + (phase * 6.28318); + const float rotZ = baseRotation * 1.2 + (seed * 2.3); + const float3x3 rotation = mul(RotationY(rotY), mul(RotationX(rotX), RotationZ(rotZ))); + + const float3 localPos = input.PositionSize.xyz + + mul(mul(rotation, input.LocalPos * scale), alive); + const float3 localNormal = normalize(mul(rotation, input.LocalNormal)); + + float3 worldPos = mul(pc.WorldTransform, float4(localPos, 1.0f)).xyz; + float3 worldNormal = normalize(mul((float3x3)pc.WorldTransform, localNormal)); + + output.WorldPos = mul(pc.WorldTransform, float4(localPos, 1.0f)).xyz; + output.ClipPos = mul(ViewProj, float4(output.WorldPos, 1.0)); + output.Normal = normalize(mul((float3x3)pc.WorldTransform, localNormal)); + output.Color = float4(input.Color.rgb, input.Color.a * alive); + output.TexCoord = input.LocalPos.xy * 0.5f + 0.5f; + + return output; +} \ No newline at end of file diff --git a/Shaders/Material/ParticleRibbon.ps.hlsl b/Shaders/Material/ParticleRibbon.ps.hlsl new file mode 100644 index 00000000..d034e78f --- /dev/null +++ b/Shaders/Material/ParticleRibbon.ps.hlsl @@ -0,0 +1,88 @@ +// Template for EMaterialUsage::ParticleRibbon. The generated graph body writes +// Surface fields using ribbon vertex color, UV, material values and textures. + +[[vk::binding(0, 0)]] +cbuffer cbFrame : register(b0) +{ + float4x4 View; + float4x4 Proj; + float4x4 ViewProj; + float3 CameraPos; + float Time; +}; + +[[vk::binding(3, 0)]] +SamplerState spriteSampler : register(s0); + +[[vk::binding(1, 1)]] +Texture2D sprites[] : register(t0); + +struct CompiledMaterial +{ + float4 Values[32]; + uint TextureIndices[32]; +}; + +[[vk::binding(4, 0)]] +StructuredBuffer materials; + +struct MaterialPushConstants +{ + float4x4 WorldTransform; + uint EmitterIndex; + uint ParticleBaseOffset; + uint MaterialIndex; +}; + +[[vk::push_constant]] +MaterialPushConstants pc; + +struct PSInput +{ + float4 ClipPos : SV_POSITION; + float4 Color : COLOR0; + float2 TexCoord : TEXCOORD0; + nointerpolation float Valid : TEXCOORD1; +}; + +struct Surface +{ + float3 BaseColor; + float3 Normal; + float Metallic; + float Roughness; + float Opacity; + float3 Emissive; +}; + +float4 SampleTex(uint index, float2 uv) +{ + return sprites[index].Sample(spriteSampler, uv); +} + +float4 main(PSInput input) : SV_Target0 +{ + clip(input.Valid - 0.5); + + CompiledMaterial mat = materials[pc.MaterialIndex]; + + Surface surface; + surface.BaseColor = float3(1.0f, 1.0f, 1.0f); + surface.Normal = float3(0.0f, 0.0f, 1.0f); + surface.Metallic = 0.0f; + surface.Roughness = 0.5f; + surface.Opacity = 1.0f; + surface.Emissive = float3(0.0f, 0.0f, 0.0f); + + // __GRAPH_BODY__ + + //const float centeredAcrossRibbon = abs((input.TexCoord.x * 2.0f) - 1.0f); + //const float edgeFade = 1.0f - smoothstep(0.72f, 1.0f, centeredAcrossRibbon); + //const float coreGlow = 1.0f - smoothstep(0.0f, 0.52f, centeredAcrossRibbon); +// const float3 color = (input.Color.rgb * surface.BaseColor) + +// surface.Emissive + (coreGlow * 0.22f); + const float3 color = surface.BaseColor + surface.Emissive; + +// return float4(color, input.Color.a * edgeFade); + return float4(color, surface.Opacity); +} \ No newline at end of file diff --git a/Shaders/Material/ParticleRibbon.vs.hlsl b/Shaders/Material/ParticleRibbon.vs.hlsl new file mode 100644 index 00000000..a778db6c --- /dev/null +++ b/Shaders/Material/ParticleRibbon.vs.hlsl @@ -0,0 +1,276 @@ +// Template for EMaterialUsage::ParticleRibbon. + +struct ParticleState +{ + float4 PositionSize; // xyz = position, w = size + float4 VelocityAge; // xyz = velocity, w = age + float4 Transform; // x = rotation, y = scale + float4 TangentRibbonId; // xyz = tangent, w = ribbon id + float4 Color; + float4 Metadata; // x = emitter index, y = ribbon link order, z = lifetime, w = alive +}; + + +[[vk::binding(1, 0)]] +StructuredBuffer particles; + +struct Emitter +{ + float4 MetaA; // x = offset in particle buffer, y = max particles, z = module offset(spawn), w = module count(spawn) + float4 MetaB; // x = module offset(update), y = module count(update), z = buffer cursor, w = spawn count + float4 MetaC; // x = render mode, y = spawn rate seconds, z = gravity scale, w = next buffer cursor + float4 MetaD; // x = emission index +}; + + +[[vk::binding(2, 0)]] +StructuredBuffer emitters; + +[[vk::binding(0, 0)]] +cbuffer cbFrame : register(b0) +{ + float4x4 View; + float4x4 Proj; + float4x4 ViewProj; + float3 CameraPos; + float _Padding; +}; + +struct PushConstants +{ + float4x4 WorldTransform; + uint EmitterIndex; + uint ParticleBaseOffset; + uint MaterialIndex; +}; + +[[vk::push_constant]] +PushConstants pc; + +struct VSOutput +{ + float4 ClipPos : SV_POSITION; + float4 Color : COLOR0; + float2 TexCoord : TEXCOORD0; + nointerpolation float Valid : TEXCOORD1; +}; + +float3 SafeNormalize(float3 value, float3 fallback) +{ + float lengthSquared = dot(value, value); + if (lengthSquared < 0.000001) + return fallback; + + return value * rsqrt(lengthSquared); +} + +bool IsAlive(ParticleState particle) +{ + return particle.Metadata.w >= 0.5 && + particle.Color.a > 0.0001 && + particle.PositionSize.w > 0.0001; +} + +bool SameRibbon(float a, float b) +{ + return abs(a - b) < 0.5; +} + +uint LinkOrder(ParticleState particle) +{ + return asuint(particle.Metadata.y); +} + +// Maximum number of interleaved ribbons per emitter. Particles belonging to the +// same ribbon are spaced exactly (ribbonCount) slots apart in the circular buffer +// because emissionIndex increments by 1 per spawn and ribbons are assigned round-robin. +// Searching only this many slots forward reduces TryBuildSegment from O(N) to O(W), +// turning the overall O(N²) vertex shader cost into O(W·N). +// Raise this value only if an emitter needs more than 32 simultaneous ribbons. +#define RIBBON_SEARCH_WINDOW 32 + +bool TryBuildSegment( + uint localIndex, + uint particleBaseOffset, + Emitter emitter, + out ParticleState startParticle, + out ParticleState endParticle, + out uint endLocalIndex +) +{ + uint particleOffset = particleBaseOffset + (uint)emitter.MetaA.x; + uint particleCount = (uint)emitter.MetaA.y; + + startParticle = particles[particleOffset + localIndex]; + endParticle = startParticle; + endLocalIndex = localIndex; + + if (!IsAlive(startParticle)) + return false; + + float ribbonId = startParticle.TangentRibbonId.w; + uint startLinkOrder = LinkOrder(startParticle); + uint bestLinkDelta = 0xFFFFFFFFu; // UINT_MAX + bool found = false; + + // Ribbons are assigned round-robin at spawn time, so the successor particle + // in a ribbon is always within RIBBON_SEARCH_WINDOW slots ahead in the + // circular buffer. We search forward only, relying on the invariant that a + // valid successor has a positive and minimal linkOrder delta. + uint windowSize = min(RIBBON_SEARCH_WINDOW, particleCount - 1u); + for (uint step = 1u; step <= windowSize; ++step) + { + uint candidateLocalIndex = (localIndex + step) % particleCount; + ParticleState candidate = particles[particleOffset + candidateLocalIndex]; + uint candidateLinkOrder = LinkOrder(candidate); + + // Compare before subtracting: if candidateLinkOrder <= startLinkOrder the + // subtraction would wrap around to a near-UINT_MAX value and falsely pass + // the range check, connecting the ribbon start to stale/old-cycle particles. + if (!IsAlive(candidate) || + !SameRibbon(candidate.TangentRibbonId.w, ribbonId) || + candidateLinkOrder <= startLinkOrder) + continue; + + uint candidateLinkDelta = candidateLinkOrder - startLinkOrder; + if (candidateLinkDelta < bestLinkDelta) + { + endParticle = candidate; + endLocalIndex = candidateLocalIndex; + bestLinkDelta = candidateLinkDelta; + found = true; + } + } + + return found; +} + +float3 BuildSegmentSide( + float3 p0, + float3 p1, + ParticleState startParticle, + ParticleState endParticle +) +{ + float3 tangentFallback = SafeNormalize( + startParticle.TangentRibbonId.xyz + endParticle.TangentRibbonId.xyz, + float3(1.0, 0.0, 0.0) + ); + + float3 segmentDirection = SafeNormalize(p1 - p0, tangentFallback); + float3 segmentCenter = (p0 + p1) * 0.5; + float3 viewDirection = SafeNormalize(CameraPos - segmentCenter, float3(0.0, 0.0, 1.0)); + float3 side = cross(viewDirection, segmentDirection); + + float3 helper = abs(segmentDirection.y) < 0.99 ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0); + float3 fallbackSide = SafeNormalize(cross(helper, segmentDirection), float3(1.0, 0.0, 0.0)); + + return SafeNormalize(side, fallbackSide); +} + +float3 BuildDirectionSide(float3 viewDirection, float3 direction, float3 referenceSide) +{ + float3 side = SafeNormalize(cross(viewDirection, direction), referenceSide); + if (dot(side, referenceSide) < 0.0) + side = -side; + + return side; +} + +float3 BuildParticleSide(ParticleState particle, float3 referenceSide) +{ + float3 tangentFallback = SafeNormalize(particle.TangentRibbonId.xyz, float3(1.0, 0.0, 0.0)); + float3 viewDirection = SafeNormalize(CameraPos - particle.PositionSize.xyz, float3(0.0, 0.0, 1.0)); + return BuildDirectionSide(viewDirection, tangentFallback, referenceSide); +} + +static const float RIBBON_WORLD_SIZE_SCALE = 0.01; + +VSOutput EmptyVertex() +{ + VSOutput output; + output.ClipPos = float4(0.0, 0.0, 0.0, 1.0); + output.Color = float4(0.0, 0.0, 0.0, 0.0); + output.TexCoord = float2(0.5, 0.0); + output.Valid = 0.0; + return output; +} + +// TODO: TEMP approximation, replace by a better solution! +float MaxAxisScale(float4x4 transform) +{ + return max( + length(transform[0].xyz), + max(length(transform[1].xyz), length(transform[2].xyz)) + ); +} + +VSOutput main(uint vertexId : SV_VertexID) +{ + Emitter emitter = emitters[pc.EmitterIndex]; + uint particleCount = (uint)emitter.MetaA.y; + if (particleCount == 0u) + return EmptyVertex(); + + uint segmentIndex = vertexId / 6u; + uint vertexInSegment = vertexId % 6u; + if (segmentIndex >= particleCount) + return EmptyVertex(); + + ParticleState startParticle; + ParticleState endParticle; + uint endLocalIndex; + + if (!TryBuildSegment( + segmentIndex, + pc.ParticleBaseOffset, + emitter, + startParticle, + endParticle, + endLocalIndex + )) + return EmptyVertex(); + + float3x3 worldLinearTransform = (float3x3)pc.WorldTransform; + + startParticle.PositionSize.xyz = + mul(pc.WorldTransform, float4(startParticle.PositionSize.xyz, 1.0f)).xyz; + startParticle.VelocityAge.xyz = + mul(worldLinearTransform, startParticle.VelocityAge.xyz); + startParticle.TangentRibbonId.xyz = + mul(worldLinearTransform, startParticle.TangentRibbonId.xyz); + + endParticle.PositionSize.xyz = + mul(pc.WorldTransform, float4(endParticle.PositionSize.xyz, 1.0f)).xyz; + endParticle.VelocityAge.xyz = + mul(worldLinearTransform, endParticle.VelocityAge.xyz); + endParticle.TangentRibbonId.xyz = + mul(worldLinearTransform, endParticle.TangentRibbonId.xyz); + + float3 p0 = startParticle.PositionSize.xyz; + float3 p1 = endParticle.PositionSize.xyz; + float scale0 = max(startParticle.Transform.y, 0.0); + float scale1 = max(endParticle.Transform.y, 0.0); + float transformScale = MaxAxisScale(pc.WorldTransform); + float width0 = max(startParticle.PositionSize.w * scale0 * transformScale * RIBBON_WORLD_SIZE_SCALE, 0.0001); + float width1 = max(endParticle.PositionSize.w * scale1 * transformScale * RIBBON_WORLD_SIZE_SCALE, 0.0001); + float3 segmentSide = BuildSegmentSide(p0, p1, startParticle, endParticle); + float3 startSide = BuildParticleSide(startParticle, segmentSide); + float3 endSide = BuildParticleSide(endParticle, segmentSide); + + bool useEndParticle = vertexInSegment == 1u || vertexInSegment == 2u || vertexInSegment == 4u; + bool usePositiveSide = vertexInSegment == 2u || vertexInSegment == 4u || vertexInSegment == 5u; + + float3 center = useEndParticle ? p1 : p0; + float width = useEndParticle ? width1 : width0; + float3 side = useEndParticle ? endSide : startSide; + float sideSign = usePositiveSide ? 1.0 : -1.0; + + VSOutput output; + output.ClipPos = mul(ViewProj, float4(center + side * (width * 0.5 * sideSign), 1.0)); + output.Color = useEndParticle ? endParticle.Color : startParticle.Color; + output.TexCoord = float2(usePositiveSide ? 1.0 : 0.0, useEndParticle ? 1.0 : 0.0); + output.Valid = 1.0; + + return output; +} \ No newline at end of file diff --git a/Shaders/Material/ParticleSprite.ps.hlsl b/Shaders/Material/ParticleSprite.ps.hlsl new file mode 100644 index 00000000..569063a8 --- /dev/null +++ b/Shaders/Material/ParticleSprite.ps.hlsl @@ -0,0 +1,75 @@ +// Template for EMaterialUsage::ParticleSprite. The generated graph body writes +// Surface fields using particle color, UV, time, material values and textures. + +[[vk::binding(0, 0)]] +cbuffer cbFrame : register(b0) +{ + float4x4 View; + float4x4 Proj; + float4x4 ViewProj; + float3 CameraPos; + float Time; +}; + +[[vk::binding(1, 0)]] +SamplerState spriteSampler : register(s0); + +[[vk::binding(1, 1)]] +Texture2D sprites[] : register(t0); + +struct CompiledMaterial +{ + float4 Values[32]; + uint TextureIndices[32]; +}; + +[[vk::binding(2, 0)]] +StructuredBuffer materials; + +struct MaterialPushConstants +{ + float4x4 WorldTransform; + uint MaterialIndex; +}; + +[[vk::push_constant]] +MaterialPushConstants pc; + +struct PSInput +{ + float4 ClipPos : SV_POSITION; + float4 Color : COLOR; + float2 TexCoord : TEXCOORD0; +}; + +struct Surface +{ + float3 BaseColor; + float3 Normal; + float Metallic; + float Roughness; + float Opacity; + float3 Emissive; +}; + +float4 SampleTex(uint index, float2 uv) +{ + return sprites[index].Sample(spriteSampler, uv); +} + +float4 main(PSInput input) : SV_Target0 +{ + CompiledMaterial mat = materials[pc.MaterialIndex]; + + Surface surface; + surface.BaseColor = float3(1.0f, 1.0f, 1.0f); + surface.Normal = float3(0.0f, 0.0f, 1.0f); + surface.Metallic = 0.0f; + surface.Roughness = 0.5f; + surface.Opacity = 1.0f; + surface.Emissive = float3(0.0f, 0.0f, 0.0f); + + // __GRAPH_BODY__ + + return float4(surface.BaseColor + surface.Emissive, surface.Opacity); +} \ No newline at end of file diff --git a/Shaders/Shaders.cmake b/Shaders/Shaders.cmake index 718d0e45..4697f713 100644 --- a/Shaders/Shaders.cmake +++ b/Shaders/Shaders.cmake @@ -201,6 +201,11 @@ function(copy_shaders_for_targets) COMMAND "${CMAKE_COMMAND}" -E copy_directory "${SHADER_STAGING_DIR}" "${TARGET_SHADER_DIR}" + # The node-graph material templates are compiled at runtime, so its HLSL + # sources must be available next to the compiled shaders. + COMMAND "${CMAKE_COMMAND}" -E copy_directory + "${SHADER_SOURCE_DIR}/Material" + "${TARGET_SHADER_DIR}/Material" COMMAND "${CMAKE_COMMAND}" -E touch "${CMAKE_CURRENT_BINARY_DIR}/${target}_copy_shaders.stamp" DEPENDS ${ALL_SPIRV_OUTPUTS}