From 3cfc1eab376d22b9802d01fcc9364e19032111d0 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Sat, 25 Jul 2026 16:33:08 -0300 Subject: [PATCH 01/89] feat(material): add material graph system Add material, material instance, and node-graph primitives with HLSL generation for scalar and vector channels. Cover graph generation with unit tests, tighten clang-tidy, and ignore generated shaders plus imported mesh/HDR assets from source control. --- Assets/.gitignore | 3 + Elixir/.clang-tidy | 1 + Elixir/Source/Engine/Core/Application.cpp | 3 +- Elixir/Source/Engine/Material/Material.cpp | 16 ++ Elixir/Source/Engine/Material/Material.h | 65 ++++++ .../Source/Engine/Material/MaterialGraph.cpp | 197 ++++++++++++++++++ Elixir/Source/Engine/Material/MaterialGraph.h | 77 +++++++ .../Engine/Material/MaterialInstance.cpp | 46 ++++ .../Source/Engine/Material/MaterialInstance.h | 30 +++ .../Engine/Material/MaterialGraphTest.cpp | 74 +++++++ Shaders/.gitignore | 1 + 11 files changed, 511 insertions(+), 2 deletions(-) create mode 100644 Assets/.gitignore create mode 100644 Elixir/Source/Engine/Material/Material.cpp create mode 100644 Elixir/Source/Engine/Material/Material.h create mode 100644 Elixir/Source/Engine/Material/MaterialGraph.cpp create mode 100644 Elixir/Source/Engine/Material/MaterialGraph.h create mode 100644 Elixir/Source/Engine/Material/MaterialInstance.cpp create mode 100644 Elixir/Source/Engine/Material/MaterialInstance.h create mode 100644 Elixir/Tests/Engine/Material/MaterialGraphTest.cpp create mode 100644 Shaders/.gitignore 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/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/Core/Application.cpp b/Elixir/Source/Engine/Core/Application.cpp index 28b65ceb..b743077b 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" @@ -122,7 +121,7 @@ namespace Elixir .SetPosition({ 10, 10 }) .SetSize({ 280, 24 }); - m_GUIManager->SetRoot(panel); + //m_GUIManager->SetRoot(panel); } Application::~Application() diff --git a/Elixir/Source/Engine/Material/Material.cpp b/Elixir/Source/Engine/Material/Material.cpp new file mode 100644 index 00000000..3a80581a --- /dev/null +++ b/Elixir/Source/Engine/Material/Material.cpp @@ -0,0 +1,16 @@ +#include "epch.h" +#include "Material.h" + +namespace Elixir +{ + void Material::SetDefaultParam(const std::string& name, const SMaterialParam& value) + { + m_DefaultParams[name] = value; + } + + const SMaterialParam* Material::GetDefaultParam(const std::string& name) const + { + const auto it = m_DefaultParams.find(name); + return it != m_DefaultParams.end() ? &it->second : nullptr; + } +} diff --git a/Elixir/Source/Engine/Material/Material.h b/Elixir/Source/Engine/Material/Material.h new file mode 100644 index 00000000..a455b6a2 --- /dev/null +++ b/Elixir/Source/Engine/Material/Material.h @@ -0,0 +1,65 @@ +#pragma once + +#include + +namespace Elixir +{ + enum class EMaterialParamType : uint8_t + { + Scalar, Vector, Texture + }; + + // A single named material parameter value. A parameter is one of a scalar, a + // vector, or a texture; the active kind is given by Type. + struct SMaterialParam + { + EMaterialParamType Type = EMaterialParamType::Scalar; + float Scalar = 0.0f; + glm::vec4 Vector{ 0.0f }; + Ref Texture; + + static SMaterialParam MakeScalar(const float value) + { + SMaterialParam param; + param.Type = EMaterialParamType::Scalar; + param.Scalar = value; + return param; + } + + static SMaterialParam MakeVector(const glm::vec4& value) + { + SMaterialParam param; + param.Type = EMaterialParamType::Vector; + param.Vector = value; + return param; + } + + static SMaterialParam MakeTexture(const Ref& texture) + { + SMaterialParam param; + param.Type = EMaterialParamType::Texture; + param.Texture = texture; + return param; + } + }; + + // A material template: a named set of parameters with default values (the schema + // shared by all of its instances). The shading itself is provided by the renderer's shader; + // a Material describes the parameters that feed it. + class ELIXIR_API Material + { + public: + explicit Material(std::string name) : m_Name(std::move(name)) {} + + void SetDefaultParam(const std::string& name, const SMaterialParam& value); + + const SMaterialParam* GetDefaultParam(const std::string& name) const; + + const std::string& GetName() const { return m_Name; } + const std::unordered_map& GetDefaultParams() const { return m_DefaultParams; } + + private: + std::string m_Name; + std::unordered_map m_DefaultParams; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/MaterialGraph.cpp b/Elixir/Source/Engine/Material/MaterialGraph.cpp new file mode 100644 index 00000000..a486a9fa --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialGraph.cpp @@ -0,0 +1,197 @@ +#include "epch.h" +#include "MaterialGraph.h" + +namespace Elixir +{ + namespace + { + const char* TypeName(const EMaterialGraphValueType type) + { + switch (type) + { + case EMaterialGraphValueType::Float: return "float"; + case EMaterialGraphValueType::Float2: return "float2"; + case EMaterialGraphValueType::Float3: return "float3"; + case EMaterialGraphValueType::Float4: return "float4"; + } + + return "float4"; + } + + const char* ChannelName(const EMaterialChannel channel) + { + switch (channel) + { + case EMaterialChannel::BaseColor: return "BaseColor"; + case EMaterialChannel::Metallic: return "Metallic"; + case EMaterialChannel::Roughness: return "Roughness"; + case EMaterialChannel::Emissive: return "Emissive"; + case EMaterialChannel::Normal: return "Normal"; + } + + return "BaseColor"; + } + + std::string Num(const float value) + { + std::string str = std::to_string(value); + return str; + } + + // Coerce an expression of 'from' type to the channel's expected type. + std::string Coerce( + const std::string& expr, + const EMaterialGraphValueType from, + const EMaterialChannel channel + ) + { + const bool scalarChannel = channel == EMaterialChannel::Metallic || + channel == EMaterialChannel::Roughness; + + if (scalarChannel) + return from == EMaterialGraphValueType::Float ? expr : "(" + expr + ").x"; + + // float3 channel (BaseColor/Emissive/Normal). + switch (from) + { + case EMaterialGraphValueType::Float: return expr + ".xxx"; + case EMaterialGraphValueType::Float2: return "float3(" + expr + ", 0.0)"; + case EMaterialGraphValueType::Float3: return expr; + case EMaterialGraphValueType::Float4: return "(" + expr + ").rgb"; + } + + return expr; + } + + std::string ConstantExpr(const SMaterialNode& node) + { + const glm::vec4& v = node.ConstantValue; + + switch (node.OutputType) + { + case EMaterialGraphValueType::Float: + return Num(v.x); + case EMaterialGraphValueType::Float2: + return "float2(" + Num(v.x) + ", " + Num(v.y) + ")"; + case EMaterialGraphValueType::Float3: + return "float3(" + Num(v.x) + ", " + Num(v.y) + ", " + Num(v.z) + ")"; + case EMaterialGraphValueType::Float4: + return "float4(" + Num(v.x) + ", " + Num(v.y) + ", " + Num(v.z) + ", " + Num(v.w) + ")"; + } + + return "0.0"; + } + } + + uint32_t MaterialGraph::AddNode(const SMaterialNode& node) + { + SMaterialNode copy = node; + copy.Id = m_NextId++; + m_Nodes[copy.Id] = copy; + return copy.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()) + return; + + if (it->second.Inputs.size() <= toSlot) + it->second.Inputs.resize(toSlot + 1, -1); + + it->second.Inputs[toSlot] = (int32_t)fromNode; + } + + void MaterialGraph::SetChannel(EMaterialChannel channel, uint32_t nodeId) + { + m_Channels[(uint8_t)channel] = nodeId; + } + + std::string MaterialGraph::GenerateHLSL() const + { + std::string body; + std::unordered_map emitted; + + for (const auto& [channelIndex, nodeId] : m_Channels) + { + const std::string var = EmitNode(nodeId, emitted, body); + const auto it = m_Nodes.find(nodeId); + + const EMaterialGraphValueType from = it != m_Nodes.end() + ? it->second.OutputType + : EMaterialGraphValueType::Float4; + + const auto channel = (EMaterialChannel)channelIndex; + const auto channelName = ChannelName(channel); + body += " surface." + std::string(channelName) + " = " + Coerce(var, from, channel) + ";\n"; + } + + return body; + } + + std::string MaterialGraph::EmitNode( + const uint32_t id, + std::unordered_map& emitted, + std::string& body + ) 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()) + return "0.0"; + + const SMaterialNode& node = it->second; + + // Resolve each input to a variable name(recursing) or a default literal. + std::vector in; + for (size_t i = 0; i < node.Inputs.size(); ++i) + { + const auto& input = node.Inputs[i]; + + if (input >= 0) + in.push_back(EmitNode((uint32_t)input, emitted, body)); + else if (i < node.DefaultInputs.size()) + in.push_back(node.DefaultInputs[i]); + else + in.emplace_back("0.0"); + } + + std::string expr; + switch (node.Type) + { + case EMaterialNodeType::Constant: + expr = ConstantExpr(node); + break; + case EMaterialNodeType::Parameter: + expr = "mat." + node.ParameterName; + break; + case EMaterialNodeType::TextureSample: + expr = node.TextureExpression; + break; + case EMaterialNodeType::Multiply: + expr = "(" + in[0] + " * " + in[1] + ")"; + break; + case EMaterialNodeType::Add: + expr = "(" + in[0] + " + " + in[1] + ")"; + break; + case EMaterialNodeType::Lerp: + expr = "lerp(" + in[0] + ", " + in[1] + ", " + in[2] + ")"; + break; + case EMaterialNodeType::Fresnel: + expr = "pow(saturate(1.0 - dot(N, V)), 5.0)"; + break; + } + + const std::string var = "n" + std::to_string(id); + body += " " + std::string(TypeName(node.OutputType)) + " " + var + " = " + expr + ";\n"; + emitted[id] = var; + return var; + } +} diff --git a/Elixir/Source/Engine/Material/MaterialGraph.h b/Elixir/Source/Engine/Material/MaterialGraph.h new file mode 100644 index 00000000..b7ae0686 --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialGraph.h @@ -0,0 +1,77 @@ +#pragma once + +namespace Elixir +{ + // The HLSL value type a node output carries. + enum class EMaterialGraphValueType : uint8_t + { + Float, Float2, Float3, Float4, + }; + + // The kind of computation a node performs. The codegen switches on this. + enum class EMaterialNodeType : uint8_t + { + Constant, // a literal value + Parameter, // a named material-instance parameters (mat.) + TextureSample, // sample a bound texture at the mesh UV + Multiply, // a * b + Add, // a + b + Lerp, // lerp(a, b, t) + Fresnel, // schlick fresnel from N,V + }; + + // The surface output a channel drives. + enum class EMaterialChannel : uint8_t + { + BaseColor, Metallic, Roughness, Emissive, Normal + }; + + // One node in a material graph. Nodes are plain data (no lambdas) so the graph + // can be serialized and edited; the codegen interprets Type. + struct SMaterialNode + { + uint32_t Id = 0; + EMaterialNodeType Type = EMaterialNodeType::Constant; + EMaterialGraphValueType OutputType = EMaterialGraphValueType::Float4; + + // For each input slot: the id of the source node, or -1 to use the matching + // DefaultInputs literal. + std::vector Inputs; + std::vector DefaultInputs; + + // Per-type payload. + glm::vec4 ConstantValue{ 0.0f }; // Constant + std::string ParameterName; // Parameter -> mat. + std::string TextureExpression; // TextureSample -> the HLSL sample expression + }; + + // A node graph describing a material's surface. Compiles to an HLSL body that + // fills a surface struct, plugged into a template pixel shader. + class ELIXIR_API MaterialGraph + { + public: + uint32_t AddNode(const SMaterialNode& node); + + // Wire the output of 'fromNode' into input 'toSlot' of 'toNode'. + void Connect(uint32_t fromNode, uint32_t toNode, uint32_t toSlot); + + // Drive a surface channel from a node's output. + void SetChannel(EMaterialChannel channel, uint32_t nodeId); + + // Generate the HLSL statements that fill 'surface. = ...;'. + std::string GenerateHLSL() const; + + const std::unordered_map& GetNodes() const { return m_Nodes; } + + private: + std::string EmitNode( + uint32_t id, + std::unordered_map& emitted, + std::string& body + ) const; + + std::unordered_map m_Nodes; + std::unordered_map m_Channels; // EMaterialChannel -> node id + uint32_t m_NextId = 1; + }; +} diff --git a/Elixir/Source/Engine/Material/MaterialInstance.cpp b/Elixir/Source/Engine/Material/MaterialInstance.cpp new file mode 100644 index 00000000..48c00024 --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialInstance.cpp @@ -0,0 +1,46 @@ +#include "epch.h" +#include "MaterialInstance.h" + +namespace Elixir +{ + void MaterialInstance::SetScalar(const std::string& name, const float value) + { + m_Overrides[name] = SMaterialParam::MakeScalar(value); + } + + float MaterialInstance::GetScalar(const std::string& name) const + { + const auto* param = Resolve(name); + return param ? param->Scalar : 0.0f; + } + + void MaterialInstance::SetVector(const std::string& name, const glm::vec4& value) + { + m_Overrides[name] = SMaterialParam::MakeVector(value); + } + + glm::vec4 MaterialInstance::GetVector(const std::string& name) const + { + const auto* param = Resolve(name); + return param ? param->Vector : glm::vec4(0.0f); + } + + void MaterialInstance::SetTexture(const std::string& name, const Ref& texture) + { + m_Overrides[name] = SMaterialParam::MakeTexture(texture); + } + + Ref MaterialInstance::GetTexture(const std::string& name) const + { + const auto* param = Resolve(name); + return param ? param->Texture : nullptr; + } + + const SMaterialParam* 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->GetDefaultParam(name) : nullptr; + } +} diff --git a/Elixir/Source/Engine/Material/MaterialInstance.h b/Elixir/Source/Engine/Material/MaterialInstance.h new file mode 100644 index 00000000..3ee5d9ce --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialInstance.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +namespace Elixir +{ + class ELIXIR_API MaterialInstance + { + public: + explicit MaterialInstance(const Ref& parent) : m_Parent(parent) {} + + void SetScalar(const std::string& name, float value); + float GetScalar(const std::string& name) const; + + void SetVector(const std::string& name, const glm::vec4& value); + glm::vec4 GetVector(const std::string& name) const; + + void SetTexture(const std::string& name, const Ref& texture); + Ref GetTexture(const std::string& name) const; + + const Ref& GetParent() const { return m_Parent; } + + private: + // Override if present, else the parent's default (or null). + const SMaterialParam* Resolve(const std::string& name) const; + + Ref m_Parent; + std::unordered_map m_Overrides; + }; +} diff --git a/Elixir/Tests/Engine/Material/MaterialGraphTest.cpp b/Elixir/Tests/Engine/Material/MaterialGraphTest.cpp new file mode 100644 index 00000000..5e65734b --- /dev/null +++ b/Elixir/Tests/Engine/Material/MaterialGraphTest.cpp @@ -0,0 +1,74 @@ +#include + +#include + +using namespace Elixir; + +// BaseColor = Constant([1,0,0,1]) * Parameter(BaseColorFactor) +TEST(MaterialGraphTest, GeneratesMultiplyBaseColor) +{ + MaterialGraph graph; + + SMaterialNode constant; + constant.Type = EMaterialNodeType::Constant; + constant.OutputType = EMaterialGraphValueType::Float4; + constant.ConstantValue = { 1.0f, 0.0f, 0.0f, 1.0f }; + const uint32_t constantNodeId = graph.AddNode(constant); + + SMaterialNode param; + param.Type = EMaterialNodeType::Parameter; + param.OutputType = EMaterialGraphValueType::Float4; + param.ParameterName = "BaseColorFactor"; + const uint32_t paramNodeId = graph.AddNode(param); + + SMaterialNode mul; + mul.Type = EMaterialNodeType::Multiply; + mul.OutputType = EMaterialGraphValueType::Float4; + mul.Inputs = { -1, -1 }; + const uint32_t mulNodeId = graph.AddNode(mul); + + graph.Connect(constantNodeId, mulNodeId, 0); + graph.Connect(paramNodeId, mulNodeId, 1); + graph.SetChannel(EMaterialChannel::BaseColor, mulNodeId); + + const std::string hlsl = graph.GenerateHLSL(); + std::cout + << "\n--- Generated HLSL (BaseColor) ---\n" + << hlsl + << "----------------------------------\n"; + + 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); // float4 coerced to the float3 channel +} + +// Scalar channels coerce and a shared node is emitted once. +TEST(MaterialGraphTest, ScalarChannelsAndSharedNode) +{ + MaterialGraph graph; + + SMaterialNode metallic; + metallic.Type = EMaterialNodeType::Constant; + metallic.OutputType = EMaterialGraphValueType::Float; + metallic.ConstantValue = { 0.5f, 0.0f, 0.0f, 0.0f }; + const uint32_t metallicNodeId = graph.AddNode(metallic); + + graph.SetChannel(EMaterialChannel::Metallic, metallicNodeId); + graph.SetChannel(EMaterialChannel::Roughness, metallicNodeId); + + const std::string hlsl = graph.GenerateHLSL(); + std::cout + << "\n--- Generated HLSL (scalars) ---\n" + << hlsl + << "----------------------------------\n"; + + EXPECT_NE(hlsl.find("surface.Metallic ="), std::string::npos); + EXPECT_NE(hlsl.find("surface.Roughness ="), std::string::npos); + + // The shared constant node should be declared exactly once. + const auto first = hlsl.find("float n"); + ASSERT_NE(first, std::string::npos); + EXPECT_EQ(hlsl.find("float n", first + 1), std::string::npos); +} \ No newline at end of file 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 From 547742a0b20412ce710131c236dff5f93788ad91 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Sat, 25 Jul 2026 22:25:59 -0300 Subject: [PATCH 02/89] feat(material): add PBR graph shader template Add a graph-injectable pixel shader with image-based lighting, directional lighting, and ACES tone mapping. Generated graph code fills the surface channels while shared frame, material, texture, and environment bindings stay centralized. --- Shaders/Material/Material.ps.hlsl | 157 ++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 Shaders/Material/Material.ps.hlsl diff --git a/Shaders/Material/Material.ps.hlsl b/Shaders/Material/Material.ps.hlsl new file mode 100644 index 00000000..42d44949 --- /dev/null +++ b/Shaders/Material/Material.ps.hlsl @@ -0,0 +1,157 @@ +// 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 _Padding; + 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 BaseColorFactor; + float Metallic; + float Roughness; + float4 Specular; + float Occlusion; + float4 Clearcoat; + float3 Emissive; + float NormalScale; + float AlphaCutoff; + uint4 TexIndex0; + uint4 TexIndex1; + float4 BaseColorTransform; + float4 NormalTransform; + float4 EmissiveTransform; + float4 OcclusionTransform; +}; + +[[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; // tangent-space perturbation + float Metallic; + float Roughness; + float3 Emissive; +}; + +static const uint NO_TEXTURE = 0xFFFFFFFFu; + +float3 SampleTex(uint index, float2 uv) +{ + return textures[index].Sample(texSampler, uv).rgb; +} + +float2 DirToEquirect(float3 dir) +{ + float u = atan2(d.z, d.x) * 0.15915494f + 0.5f; + float v = acos(clamp(d.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; + float 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.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 (color, 1.0f); +} \ No newline at end of file From 055cfac816b01e5c4e44b0502df5908621efdb64 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Sat, 25 Jul 2026 23:46:12 -0300 Subject: [PATCH 03/89] feat(material): compile graphs at runtime Compile generated material-graph HLSL into SPIR-V at runtime and load it as a bindable shader. Add the material compiler, wire a sample graph into Dissolve, copy the template during shader staging, and fix the template's vector and float4 output expressions. --- Dissolve/Source/Dissolve.cpp | 108 +++++------------- .../Engine/Material/MaterialCompiler.cpp | 93 +++++++++++++++ .../Source/Engine/Material/MaterialCompiler.h | 21 ++++ Shaders/Material/Material.ps.hlsl | 8 +- Shaders/Shaders.cmake | 5 + 5 files changed, 150 insertions(+), 85 deletions(-) create mode 100644 Elixir/Source/Engine/Material/MaterialCompiler.cpp create mode 100644 Elixir/Source/Engine/Material/MaterialCompiler.h diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index 4993bfc6..fc615358 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -3,8 +3,10 @@ #include #include #include +#include -#include "Engine/Aether/Effect.h" +#include +#include Ref pipeline; Scope m_ParticlesRenderer; @@ -12,6 +14,8 @@ Aether::FrameSubmission m_ParticleFrameSubmission; std::array, 2> m_ParticleSystems; std::array, 2> m_ParticleSystemInstances; +Ref graphShader; + Dissolve::Dissolve() { EE_PROFILE_ZONE_SCOPED() @@ -61,90 +65,32 @@ Dissolve::Dissolve() 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())); + { + MaterialGraph graph; + + SMaterialNode baseColor; + baseColor.Type = EMaterialNodeType::Constant; + baseColor.OutputType = EMaterialGraphValueType::Float4; + baseColor.ConstantValue = { 0.9f, 0.3f, 0.1f, 1.0f }; + graph.SetChannel(EMaterialChannel::BaseColor, graph.AddNode(baseColor)); + + SMaterialNode metallic; + metallic.Type = EMaterialNodeType::Constant; + metallic.OutputType = EMaterialGraphValueType::Float; + metallic.ConstantValue = { 0.9f, 0.0f, 0.0f, 0.0f }; + graph.SetChannel(EMaterialChannel::Metallic, graph.AddNode(metallic)); + + graphShader = MaterialCompiler::Compile(m_ShaderLoader.get(), graph); + if (graphShader) + EE_CORE_INFO("Node-graph material compiled and loaded successfully.") + else + EE_CORE_ERROR("Node-graph material compilation FAILED.") + } + m_GraphicsContext->SetClearColor({ 0.015f, 0.025f, 0.06f, 1.0f }); } diff --git a/Elixir/Source/Engine/Material/MaterialCompiler.cpp b/Elixir/Source/Engine/Material/MaterialCompiler.cpp new file mode 100644 index 00000000..be6880d4 --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialCompiler.cpp @@ -0,0 +1,93 @@ +#include "epch.h" +#include "MaterialCompiler.h" + +namespace Elixir +{ + namespace fs = std::filesystem; + + 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(); + } + } + + Ref MaterialCompiler::Compile(const ShaderLoader* loader, const MaterialGraph& graph) + { + const fs::path shadersDir = "./Shaders"; + const fs::path generatedDir = shadersDir / "Generated"; + + const auto hlsl = ReadFile(shadersDir / "Material" / "Material.ps.hlsl"); + + if (hlsl.empty()) + { + EE_CORE_ERROR("Material graph: template Material.ps.hlsl not found.") + return nullptr; + } + + // 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 = generatedDir / name; + std::error_code error; + fs::create_directories(loadDir, error); + + const fs::path hlslPath = generatedDir / (name + ".src.ps.hlsl"); + { + std::ofstream out(hlslPath, std::ios::binary); + out << InjectBody(hlsl, graph); + } + + // 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) + return nullptr; + } + + return loader->LoadShader(loadDir, name); + } + + std::string MaterialCompiler::InjectBody(const std::string& hlsl, const MaterialGraph& graph) + { + std::string out = hlsl; + + constexpr std::string marker = "// __GRAPH_BODY__"; + if (const auto pos = out.find(marker); pos != std::string::npos) + out.replace(pos, marker.size(), graph.GenerateHLSL()); + + return out; + } +} diff --git a/Elixir/Source/Engine/Material/MaterialCompiler.h b/Elixir/Source/Engine/Material/MaterialCompiler.h new file mode 100644 index 00000000..5f85c66f --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialCompiler.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +namespace Elixir +{ + // Turns a MaterialGraph into a usable shader: injects the graph's generated + // body into the Material template, compiles it to SPIR-V with DXC at + // runtime, and loads it (with the shared model vertex shader) into a Shader. + class ELIXIR_API MaterialCompiler + { + public: + // Compile the graph into a ready-to-bind shader. Returns nullptr on failure. + static Ref Compile(const ShaderLoader* loader, const MaterialGraph& graph); + + private: + // Splice the graph body into a template string (pure; testable). + static std::string InjectBody(const std::string& hlsl, const MaterialGraph& graph); + }; +} diff --git a/Shaders/Material/Material.ps.hlsl b/Shaders/Material/Material.ps.hlsl index 42d44949..21de712a 100644 --- a/Shaders/Material/Material.ps.hlsl +++ b/Shaders/Material/Material.ps.hlsl @@ -87,8 +87,8 @@ float3 SampleTex(uint index, float2 uv) float2 DirToEquirect(float3 dir) { - float u = atan2(d.z, d.x) * 0.15915494f + 0.5f; - float v = acos(clamp(d.y, -1.0f, 1.0f)) * 0.31830989f; + 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); } @@ -119,7 +119,7 @@ float4 main(PSInput input) : SV_Target0 float3 N = normalize(input.Normal); if (!input.FrontFace) N = -N; - float V = normalize(CameraPos - input.WorldPos); + float3 V = normalize(CameraPos - input.WorldPos); // Defaults; the graph overrides whichever channels it drives. Surface surface; @@ -153,5 +153,5 @@ float4 main(PSInput input) : SV_Target0 // Tone mapping color = ACESFilm(color); - return (color, 1.0f); + return float4(color, 1.0f); } \ No newline at end of file diff --git a/Shaders/Shaders.cmake b/Shaders/Shaders.cmake index 718d0e45..407054f3 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 template is compiled at runtime, so its HLSL + # source must be available next to the compiled shaders. + COMMAND "${CMAKE_COMMAND}" -E copy + "${SHADER_SOURCE_DIR}/Material/Material.ps.hlsl" + "${TARGET_SHADER_DIR}/Material/Material.ps.hlsl" COMMAND "${CMAKE_COMMAND}" -E touch "${CMAKE_CURRENT_BINARY_DIR}/${target}_copy_shaders.stamp" DEPENDS ${ALL_SPIRV_OUTPUTS} From 00845605b58f1fa20e3df8a8885d89839d8a0434 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Sun, 26 Jul 2026 23:44:27 -0300 Subject: [PATCH 04/89] refactor(vulkan): clarify frame usage reset Rename WaitForAllFrames to ResetFrameUsageState to reflect that it only clears per-frame usage flags after the device is idle. Expose WaitDeviceIdle through GraphicsContext so generic graphics clients can synchronize safely. --- Elixir/Source/Engine/Graphics/GraphicsContext.h | 5 +++++ .../Graphics/Vulkan/VulkanGraphicsContext.cpp | 16 ++++++++-------- .../Graphics/Vulkan/VulkanGraphicsContext.h | 5 +++-- 3 files changed, 16 insertions(+), 10 deletions(-) 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/Graphics/Vulkan/VulkanGraphicsContext.cpp b/Elixir/Source/Graphics/Vulkan/VulkanGraphicsContext.cpp index aa5567b0..8402761f 100644 --- a/Elixir/Source/Graphics/Vulkan/VulkanGraphicsContext.cpp +++ b/Elixir/Source/Graphics/Vulkan/VulkanGraphicsContext.cpp @@ -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() @@ -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(); From b704110417a5b57c9d71f3660017a336e9d0b039 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Sun, 26 Jul 2026 23:44:56 -0300 Subject: [PATCH 05/89] feat(material): expand graph node support Add texture coordinates, texture sampling, time, panner, math, and utility nodes. Coerce mixed pin widths while generating HLSL, and expose Time in the material frame buffer so animated graphs can evaluate correctly. --- .../Source/Engine/Material/MaterialGraph.cpp | 178 ++++++++++++++++-- Elixir/Source/Engine/Material/MaterialGraph.h | 13 +- Shaders/Material/Material.ps.hlsl | 2 +- 3 files changed, 179 insertions(+), 14 deletions(-) diff --git a/Elixir/Source/Engine/Material/MaterialGraph.cpp b/Elixir/Source/Engine/Material/MaterialGraph.cpp index a486a9fa..edb780b2 100644 --- a/Elixir/Source/Engine/Material/MaterialGraph.cpp +++ b/Elixir/Source/Engine/Material/MaterialGraph.cpp @@ -38,6 +38,66 @@ namespace Elixir return str; } + int Components(EMaterialGraphValueType type) + { + switch (type) + { + case EMaterialGraphValueType::Float: return 1; + case EMaterialGraphValueType::Float2: return 2; + case EMaterialGraphValueType::Float3: return 3; + case EMaterialGraphValueType::Float4: return 4; + } + + return 4; + } + + // The wider of two value types (more components wins). Use to pick a common + // type for component-wise ops so mismatched pin widths still compile. + EMaterialGraphValueType Wider( + const EMaterialGraphValueType a, + const EMaterialGraphValueType b + ) + { + return Components(a) >= Components(b) ? a : b; + } + + // Coerce an expression from one value type to a wider (or equal) one: scalars + // splat across all lanes; shorter vectors pad. Keeps generated HLSL well-typed + // regardless of how the user wired the graph. + std::string Widen( + const std::string& expr, + const EMaterialGraphValueType from, + const EMaterialGraphValueType to + ) + { + if (from == to) + return expr; + + if (from == EMaterialGraphValueType::Float) + { + const char* s = to == EMaterialGraphValueType::Float2 + ? ".xx" + : to == EMaterialGraphValueType::Float3 + ? ".xxx" + : ".xxxx"; + return "(" + expr + ")" + s; + } + + if (from == EMaterialGraphValueType::Float2 && to == EMaterialGraphValueType::Float3) + return "float3(" + expr + ", 0.0)"; + if (from == EMaterialGraphValueType::Float2 && to == EMaterialGraphValueType::Float4) + return "float4(" + expr + ", 0.0, 0.0)"; + if (from == EMaterialGraphValueType::Float3 && to == EMaterialGraphValueType::Float4) + return "float4(" + expr + ", 1.0)"; + + // Narrowing (only if a wider value flows into a narrower slot): swizzle down. + if (to == EMaterialGraphValueType::Float) return "(" + expr + ").x"; + if (to == EMaterialGraphValueType::Float2) return "(" + expr + ").xy"; + if (to == EMaterialGraphValueType::Float3) return "(" + expr + ").xyz"; + + return expr; + } + // Coerce an expression of 'from' type to the channel's expected type. std::string Coerce( const std::string& expr, @@ -116,14 +176,13 @@ namespace Elixir { std::string body; std::unordered_map emitted; + std::unordered_map types; for (const auto& [channelIndex, nodeId] : m_Channels) { - const std::string var = EmitNode(nodeId, emitted, body); - const auto it = m_Nodes.find(nodeId); - - const EMaterialGraphValueType from = it != m_Nodes.end() - ? it->second.OutputType + const std::string var = EmitNode(nodeId, emitted, types, body); + const EMaterialGraphValueType from = types.contains(nodeId) + ? types[nodeId] : EMaterialGraphValueType::Float4; const auto channel = (EMaterialChannel)channelIndex; @@ -137,6 +196,7 @@ namespace Elixir std::string MaterialGraph::EmitNode( const uint32_t id, std::unordered_map& emitted, + std::unordered_map& types, std::string& body ) const { @@ -145,53 +205,147 @@ namespace Elixir const auto it = m_Nodes.find(id); if (it == m_Nodes.end()) + { + types[id] = EMaterialGraphValueType::Float4; return "0.0"; + } const SMaterialNode& node = it->second; - // Resolve each input to a variable name(recursing) or a default literal. + // Resolve each input to a variable name (recursing) or a default literal, + // and remember the value type flowing out of each so ops can pick a common width. std::vector in; + std::vector inTypes; for (size_t i = 0; i < node.Inputs.size(); ++i) { const auto& input = node.Inputs[i]; if (input >= 0) - in.push_back(EmitNode((uint32_t)input, emitted, body)); + { + in.push_back(EmitNode((uint32_t)input, emitted, types, body)); + inTypes.push_back(types[(uint32_t)input]); + } else if (i < node.DefaultInputs.size()) + { in.push_back(node.DefaultInputs[i]); + inTypes.push_back(EMaterialGraphValueType::Float); + } else + { in.emplace_back("0.0"); + inTypes.push_back(EMaterialGraphValueType::Float); + } } + auto A = [&](size_t i) { return i < in.size() ? in[i] : std::string("0.0"); }; + auto AT = [&](size_t i) { return i < inTypes.size() ? inTypes[i] : EMaterialGraphValueType::Float; }; + std::string expr; + EMaterialGraphValueType type = node.OutputType; + + // Component-wise binary op: coerce both operands to their common width. + auto binOp = [&](const char* op) + { + const EMaterialGraphValueType to = Wider(AT(0), AT(1)); + expr = "(" + Widen(A(0), AT(0), to) + " " + op + " " + Widen(A(1), AT(1), to) + ")"; + type = to; + }; + switch (node.Type) { case EMaterialNodeType::Constant: expr = ConstantExpr(node); + type = node.OutputType; break; case EMaterialNodeType::Parameter: expr = "mat." + node.ParameterName; + type = node.OutputType; + break; + case EMaterialNodeType::TexCoord: + expr = "input.TexCoord"; + type = EMaterialGraphValueType::Float2; break; case EMaterialNodeType::TextureSample: - expr = node.TextureExpression; + { + // node.TextureExpression holds the index accessor (e.g. mat.TexIndex0.x). + const std::string idx = node.TextureExpression; + const std::string uv = node.Inputs.empty() || node.Inputs[0] < 0 + ? "input.TexCoord" + : Widen(A(0), AT(0), EMaterialGraphValueType::Float2); + expr = "(" + idx + " == 0xFFFFFFFFu ? float3(1.0, 1.0, 1.0) : SampleTex(" + idx + ", " + uv + "))";; + type = EMaterialGraphValueType::Float3; + break; + } + case EMaterialNodeType::Time: + expr = "Time"; + type = EMaterialGraphValueType::Float; + break; + case EMaterialNodeType::Sine: + expr = "sin(" + A(0) + ")"; + type = AT(0); + break; + case EMaterialNodeType::Panner: + { + const std::string uv = node.Inputs.empty() || node.Inputs[0] < 0 + ? "input.TexCoord" + : Widen(A(0), AT(0), EMaterialGraphValueType::Float2); + const std::string speed = "float2(" + Num(node.ConstantValue.x) + ", " + Num(node.ConstantValue.y) + ")"; + expr = "(" + uv + " + Time * " + speed + ")"; + type = EMaterialGraphValueType::Float2; break; + } case EMaterialNodeType::Multiply: - expr = "(" + in[0] + " * " + in[1] + ")"; + binOp("*"); break; case EMaterialNodeType::Add: - expr = "(" + in[0] + " + " + in[1] + ")"; + binOp("+"); + break; + case EMaterialNodeType::Subtract: + binOp("-"); + break; + case EMaterialNodeType::Divide: + binOp("/"); + break; + case EMaterialNodeType::Power: + { + const EMaterialGraphValueType to = Wider(AT(0), AT(1)); + expr = "pow(" + Widen(A(0), AT(0), to) + ", " + Widen(A(1), AT(1), to) + ")"; + type = to; + break; + } + case EMaterialNodeType::Dot: + { + const EMaterialGraphValueType to = Wider(AT(0), AT(1)); + expr = "dot(" + Widen(A(0), AT(0), to) + ", " + Widen(A(1), AT(1), to) + ")"; + type = EMaterialGraphValueType::Float; break; + } case EMaterialNodeType::Lerp: - expr = "lerp(" + in[0] + ", " + in[1] + ", " + in[2] + ")"; + { + const EMaterialGraphValueType to = Wider(AT(0), AT(1)); + expr = "lerp(" + Widen(A(0), AT(0), to) + ", " + Widen(A(1), AT(1), to) + ", " + + Widen(A(2), AT(2), to) + ")"; + type = to; + break; + } + case EMaterialNodeType::OneMinus: + expr = "(1.0 - " + in[0] + ")"; + type = AT(0); + break; + case EMaterialNodeType::Saturate: + expr = "saturate(" + in[0] + ")"; + type = AT(0); break; case EMaterialNodeType::Fresnel: expr = "pow(saturate(1.0 - dot(N, V)), 5.0)"; + type = EMaterialGraphValueType::Float; break; } const std::string var = "n" + std::to_string(id); - body += " " + std::string(TypeName(node.OutputType)) + " " + var + " = " + expr + ";\n"; + body += " " + std::string(TypeName(type)) + " " + var + " = " + expr + ";\n"; emitted[id] = var; + types[id] = type; return var; } } diff --git a/Elixir/Source/Engine/Material/MaterialGraph.h b/Elixir/Source/Engine/Material/MaterialGraph.h index b7ae0686..883e4b6d 100644 --- a/Elixir/Source/Engine/Material/MaterialGraph.h +++ b/Elixir/Source/Engine/Material/MaterialGraph.h @@ -13,10 +13,20 @@ namespace Elixir { Constant, // a literal value Parameter, // a named material-instance parameters (mat.) - TextureSample, // sample a bound texture at the mesh UV + TexCoord, // input.TexCoord + TextureSample, // sample a bound texture at a UV (input 0) + Time, // seconds since start (cbFrame.start) + Sine, // sin(a) + Panner, // uv + Time * speed (speed from ConstantValue.xy) Multiply, // a * b Add, // a + b + Subtract, // a - b + Divide, // a / b + Power, // pow(a, b) + Dot, // dot(a, b) -> scalar Lerp, // lerp(a, b, t) + OneMinus, // 1 - a + Saturate, // saturate(a Fresnel, // schlick fresnel from N,V }; @@ -67,6 +77,7 @@ namespace Elixir std::string EmitNode( uint32_t id, std::unordered_map& emitted, + std::unordered_map& types, std::string& body ) const; diff --git a/Shaders/Material/Material.ps.hlsl b/Shaders/Material/Material.ps.hlsl index 21de712a..6c4b9d51 100644 --- a/Shaders/Material/Material.ps.hlsl +++ b/Shaders/Material/Material.ps.hlsl @@ -9,7 +9,7 @@ cbuffer cbFrame : register(b0) float4x4 Proj; float4x4 ViewProj; float3 CameraPos; - float _Padding; + float Time; uint EnvIndex; uint IrradianceIndex; float EnvIntensity; From d94e24118b11bab87961941c74a819e4a7ae5ac1 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Mon, 27 Jul 2026 13:19:09 -0300 Subject: [PATCH 06/89] refactor(material): validate authored parameter schemas Make materials own their graphs and typed parameter definitions. Reject invalid graph references and incompatible instance overrides before shader compilation or resource binding. --- Elixir/Source/Engine/Material/Material.cpp | 98 ++++++++++++++++++- Elixir/Source/Engine/Material/Material.h | 51 ++++++++-- .../Source/Engine/Material/MaterialGraph.cpp | 2 +- Elixir/Source/Engine/Material/MaterialGraph.h | 2 +- .../Engine/Material/MaterialInstance.cpp | 23 +++-- .../Source/Engine/Material/MaterialInstance.h | 10 +- Elixir/Tests/Engine/Material/MaterialTest.cpp | 64 ++++++++++++ 7 files changed, 227 insertions(+), 23 deletions(-) create mode 100644 Elixir/Tests/Engine/Material/MaterialTest.cpp diff --git a/Elixir/Source/Engine/Material/Material.cpp b/Elixir/Source/Engine/Material/Material.cpp index 3a80581a..09197942 100644 --- a/Elixir/Source/Engine/Material/Material.cpp +++ b/Elixir/Source/Engine/Material/Material.cpp @@ -3,14 +3,104 @@ namespace Elixir { - void Material::SetDefaultParam(const std::string& name, const SMaterialParam& value) + void Material::SetGraph(MaterialGraph graph) { - m_DefaultParams[name] = value; + m_Graph = std::move(graph); + ++m_Revision; + } + + bool Material::SetDefaultParam(const std::string& name, const SMaterialParam& 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 SMaterialParam* Material::GetDefaultParam(const std::string& name) const { - const auto it = m_DefaultParams.find(name); - return it != m_DefaultParams.end() ? &it->second : nullptr; + 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 SMaterialParam& value + ) + { + const auto* parameter = FindParameter(name); + return parameter && IsValueCompatible(*parameter, value); + } + + bool Material::ValidateGraph(std::string* error) const + { + for (const auto& [_, node] : m_Graph.GetNodes()) + { + if (node.Type == EMaterialNodeType::Parameter) + { + const auto* parameter = FindParameter(node.ParameterName); + if (parameter && + parameter->Kind == EMaterialParameterKind::Value && + parameter->ValueType == node.OutputType) + continue; + + if (error) + *error = "Invalid value parameter: " + node.ParameterName; + + return false; + } + + if (node.Type == EMaterialNodeType::TextureSample) + { + const auto* parameter = FindParameter(node.TextureParameterName); + if (parameter && + parameter->Kind == EMaterialParameterKind::Texture) + continue; + + if (error) + *error = "Invalid texture parameter: " + node.TextureParameterName; + + return false; + } + } + + return true; + } + + bool Material::IsValueCompatible( + const SMaterialParameterDefinition& definition, + const SMaterialParam& value + ) + { + if (definition.Kind == EMaterialParameterKind::Texture) + return value.Type == EMaterialParameterType::Texture; + + if (definition.ValueType == EMaterialGraphValueType::Float) + return value.Type == EMaterialParameterType::Scalar; + + return value.Type == EMaterialParameterType::Vector; } } diff --git a/Elixir/Source/Engine/Material/Material.h b/Elixir/Source/Engine/Material/Material.h index a455b6a2..d8e6cda2 100644 --- a/Elixir/Source/Engine/Material/Material.h +++ b/Elixir/Source/Engine/Material/Material.h @@ -1,10 +1,16 @@ #pragma once +#include #include namespace Elixir { - enum class EMaterialParamType : uint8_t + enum class EMaterialParameterKind : uint8_t + { + Value, Texture + }; + + enum class EMaterialParameterType : uint8_t { Scalar, Vector, Texture }; @@ -13,7 +19,7 @@ namespace Elixir // vector, or a texture; the active kind is given by Type. struct SMaterialParam { - EMaterialParamType Type = EMaterialParamType::Scalar; + EMaterialParameterType Type = EMaterialParameterType::Scalar; float Scalar = 0.0f; glm::vec4 Vector{ 0.0f }; Ref Texture; @@ -21,7 +27,7 @@ namespace Elixir static SMaterialParam MakeScalar(const float value) { SMaterialParam param; - param.Type = EMaterialParamType::Scalar; + param.Type = EMaterialParameterType::Scalar; param.Scalar = value; return param; } @@ -29,7 +35,7 @@ namespace Elixir static SMaterialParam MakeVector(const glm::vec4& value) { SMaterialParam param; - param.Type = EMaterialParamType::Vector; + param.Type = EMaterialParameterType::Vector; param.Vector = value; return param; } @@ -37,12 +43,19 @@ namespace Elixir static SMaterialParam MakeTexture(const Ref& texture) { SMaterialParam param; - param.Type = EMaterialParamType::Texture; + param.Type = EMaterialParameterType::Texture; param.Texture = texture; return param; } }; + struct SMaterialParameterDefinition + { + EMaterialParameterKind Kind = EMaterialParameterKind::Value; + EMaterialGraphValueType ValueType = EMaterialGraphValueType::Float4; + SMaterialParam DefaultValue; + }; + // A material template: a named set of parameters with default values (the schema // shared by all of its instances). The shading itself is provided by the renderer's shader; // a Material describes the parameters that feed it. @@ -51,15 +64,37 @@ namespace Elixir public: explicit Material(std::string name) : m_Name(std::move(name)) {} - void SetDefaultParam(const std::string& name, const SMaterialParam& value); + void SetGraph(MaterialGraph graph); + const MaterialGraph& GetGraph() const { return m_Graph; } + bool SetDefaultParam(const std::string& name, const SMaterialParam& value); const SMaterialParam* GetDefaultParam(const std::string& name) const; + bool DefineParameter( + std::string name, + const SMaterialParameterDefinition& definition + ); + + const SMaterialParameterDefinition* FindParameter(const std::string& name) const; + + bool IsParameterValueCompatible( + const std::string& name, + const SMaterialParam& value + ); + bool ValidateGraph(std::string* error = nullptr) const; + const std::string& GetName() const { return m_Name; } - const std::unordered_map& GetDefaultParams() const { return m_DefaultParams; } + uint32_t GetRevision() const { return m_Revision; } private: + static bool IsValueCompatible( + const SMaterialParameterDefinition& definition, + const SMaterialParam& value + ); + std::string m_Name; - std::unordered_map m_DefaultParams; + MaterialGraph m_Graph; + std::unordered_map m_Parameters; + uint32_t m_Revision = 1; }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/MaterialGraph.cpp b/Elixir/Source/Engine/Material/MaterialGraph.cpp index edb780b2..647af906 100644 --- a/Elixir/Source/Engine/Material/MaterialGraph.cpp +++ b/Elixir/Source/Engine/Material/MaterialGraph.cpp @@ -268,7 +268,7 @@ namespace Elixir case EMaterialNodeType::TextureSample: { // node.TextureExpression holds the index accessor (e.g. mat.TexIndex0.x). - const std::string idx = node.TextureExpression; + const std::string idx = "mat." + node.TextureParameterName + ".x"; const std::string uv = node.Inputs.empty() || node.Inputs[0] < 0 ? "input.TexCoord" : Widen(A(0), AT(0), EMaterialGraphValueType::Float2); diff --git a/Elixir/Source/Engine/Material/MaterialGraph.h b/Elixir/Source/Engine/Material/MaterialGraph.h index 883e4b6d..3a852414 100644 --- a/Elixir/Source/Engine/Material/MaterialGraph.h +++ b/Elixir/Source/Engine/Material/MaterialGraph.h @@ -52,7 +52,7 @@ namespace Elixir // Per-type payload. glm::vec4 ConstantValue{ 0.0f }; // Constant std::string ParameterName; // Parameter -> mat. - std::string TextureExpression; // TextureSample -> the HLSL sample expression + std::string TextureParameterName; // TextureSample -> material texture parameter }; // A node graph describing a material's surface. Compiles to an HLSL body that diff --git a/Elixir/Source/Engine/Material/MaterialInstance.cpp b/Elixir/Source/Engine/Material/MaterialInstance.cpp index 48c00024..5b8d835a 100644 --- a/Elixir/Source/Engine/Material/MaterialInstance.cpp +++ b/Elixir/Source/Engine/Material/MaterialInstance.cpp @@ -3,9 +3,9 @@ namespace Elixir { - void MaterialInstance::SetScalar(const std::string& name, const float value) + bool MaterialInstance::SetScalar(const std::string& name, const float value) { - m_Overrides[name] = SMaterialParam::MakeScalar(value); + return SetOverride(name, SMaterialParam::MakeScalar(value)); } float MaterialInstance::GetScalar(const std::string& name) const @@ -14,9 +14,9 @@ namespace Elixir return param ? param->Scalar : 0.0f; } - void MaterialInstance::SetVector(const std::string& name, const glm::vec4& value) + bool MaterialInstance::SetVector(const std::string& name, const glm::vec4& value) { - m_Overrides[name] = SMaterialParam::MakeVector(value); + return SetOverride(name, SMaterialParam::MakeVector(value)); } glm::vec4 MaterialInstance::GetVector(const std::string& name) const @@ -25,9 +25,9 @@ namespace Elixir return param ? param->Vector : glm::vec4(0.0f); } - void MaterialInstance::SetTexture(const std::string& name, const Ref& texture) + bool MaterialInstance::SetTexture(const std::string& name, const Ref& texture) { - m_Overrides[name] = SMaterialParam::MakeTexture(texture); + return SetOverride(name, SMaterialParam::MakeTexture(texture)); } Ref MaterialInstance::GetTexture(const std::string& name) const @@ -36,6 +36,17 @@ namespace Elixir return param ? param->Texture : nullptr; } + bool MaterialInstance::SetOverride(const std::string& name, const SMaterialParam& value) + { + if (!m_Parent || !m_Parent->IsParameterValueCompatible(name, value)) + return false; + + m_Overrides[name] = value; + ++m_Revision; + + return true; + } + const SMaterialParam* MaterialInstance::Resolve(const std::string& name) const { const auto it = m_Overrides.find(name); diff --git a/Elixir/Source/Engine/Material/MaterialInstance.h b/Elixir/Source/Engine/Material/MaterialInstance.h index 3ee5d9ce..f3edaaa0 100644 --- a/Elixir/Source/Engine/Material/MaterialInstance.h +++ b/Elixir/Source/Engine/Material/MaterialInstance.h @@ -9,22 +9,26 @@ namespace Elixir public: explicit MaterialInstance(const Ref& parent) : m_Parent(parent) {} - void SetScalar(const std::string& name, float value); + bool SetScalar(const std::string& name, float value); float GetScalar(const std::string& name) const; - void SetVector(const std::string& name, const glm::vec4& value); + bool SetVector(const std::string& name, const glm::vec4& value); glm::vec4 GetVector(const std::string& name) const; - void SetTexture(const std::string& name, const Ref& texture); + bool SetTexture(const std::string& name, const Ref& texture); Ref GetTexture(const std::string& name) const; const Ref& GetParent() const { return m_Parent; } + uint32_t GetRevision() const { return m_Revision; } private: + bool SetOverride(const std::string& name, const SMaterialParam& value); + // Override if present, else the parent's default (or null). const SMaterialParam* Resolve(const std::string& name) const; Ref m_Parent; std::unordered_map m_Overrides; + uint32_t m_Revision = 1; }; } diff --git a/Elixir/Tests/Engine/Material/MaterialTest.cpp b/Elixir/Tests/Engine/Material/MaterialTest.cpp new file mode 100644 index 00000000..20847e06 --- /dev/null +++ b/Elixir/Tests/Engine/Material/MaterialTest.cpp @@ -0,0 +1,64 @@ +#include + +#include + +using namespace Elixir; + +TEST(MaterialTest, ValidateGraphParametersAgainstMaterialSchema) +{ + MaterialGraph graph; + + SMaterialNode tint; + tint.Type = EMaterialNodeType::Parameter; + tint.OutputType = EMaterialGraphValueType::Float4; + tint.ParameterName = "Tint"; + graph.SetChannel(EMaterialChannel::BaseColor, graph.AddNode(tint)); + + auto material = CreateRef("Tinted"); + material->SetGraph(std::move(graph)); + + EXPECT_TRUE(material->DefineParameter("Tint", { + .Kind = EMaterialParameterKind::Value, + .ValueType = EMaterialGraphValueType::Float4, + .DefaultValue = SMaterialParam::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 = EMaterialGraphValueType::Float4, + .DefaultValue = SMaterialParam::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; + + SMaterialNode sample; + sample.Type = EMaterialNodeType::TextureSample; + sample.TextureParameterName = "AlbedoTexture"; + sample.ParameterName = "Tint"; + graph.SetChannel(EMaterialChannel::BaseColor, graph.AddNode(sample)); + + auto material = CreateRef("Textured"); + material->SetGraph(std::move(graph)); + + EXPECT_TRUE(material->DefineParameter("AlbedoTexture", { + .Kind = EMaterialParameterKind::Texture, + .DefaultValue = SMaterialParam::MakeTexture(nullptr), + })); + EXPECT_TRUE(material->ValidateGraph()); +} \ No newline at end of file From 0644489038fe9d093abfb62c1c7705efe408bc59 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Mon, 27 Jul 2026 14:45:13 -0300 Subject: [PATCH 07/89] refactor(material): compile stable material layouts Compile authored material schemas into stable value and texture slots. Keep generated HLSL and toolchain diagnostics with the compiled material artifact. --- Elixir/Source/Engine/Material/Material.h | 1 + .../Engine/Material/MaterialCompiler.cpp | 90 +++++++++++++++++-- .../Source/Engine/Material/MaterialCompiler.h | 35 ++++++-- .../Source/Engine/Material/MaterialGraph.cpp | 21 +++-- Elixir/Source/Engine/Material/MaterialGraph.h | 12 ++- .../Engine/Material/MaterialInstance.cpp | 4 +- .../Engine/Material/MaterialCompilerTest.cpp | 31 +++++++ Shaders/Material/Material.ps.hlsl | 17 +--- 8 files changed, 175 insertions(+), 36 deletions(-) create mode 100644 Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp diff --git a/Elixir/Source/Engine/Material/Material.h b/Elixir/Source/Engine/Material/Material.h index d8e6cda2..11fb47ec 100644 --- a/Elixir/Source/Engine/Material/Material.h +++ b/Elixir/Source/Engine/Material/Material.h @@ -84,6 +84,7 @@ namespace Elixir bool ValidateGraph(std::string* error = nullptr) const; const std::string& GetName() const { return m_Name; } + const auto& GetParameters() const { return m_Parameters; } uint32_t GetRevision() const { return m_Revision; } private: diff --git a/Elixir/Source/Engine/Material/MaterialCompiler.cpp b/Elixir/Source/Engine/Material/MaterialCompiler.cpp index be6880d4..c535f18c 100644 --- a/Elixir/Source/Engine/Material/MaterialCompiler.cpp +++ b/Elixir/Source/Engine/Material/MaterialCompiler.cpp @@ -33,8 +33,54 @@ namespace Elixir } } - Ref MaterialCompiler::Compile(const ShaderLoader* loader, const MaterialGraph& graph) + SMaterialCompileResult MaterialCompiler::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->MaterialRevision = material.GetRevision(); + compiled->Parameters = std::move(layout); + return { .Material = compiled }; + } + + SMaterialCompileResult MaterialCompiler::Compile( + const ShaderLoader* loader, + const Material& material + ) + { + auto result = Build(material); + if (!result) return result; + const fs::path shadersDir = "./Shaders"; const fs::path generatedDir = shadersDir / "Generated"; @@ -43,7 +89,9 @@ namespace Elixir if (hlsl.empty()) { EE_CORE_ERROR("Material graph: template Material.ps.hlsl not found.") - return nullptr; + 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. @@ -60,7 +108,23 @@ namespace Elixir const fs::path hlslPath = generatedDir / (name + ".src.ps.hlsl"); { std::ofstream out(hlslPath, std::ios::binary); - out << InjectBody(hlsl, graph); + + SMaterialGraphBindings bindings; + for (const auto& parameter : result.Material->Parameters) + { + const auto expr = parameter.Kind == EMaterialParameterKind::Texture + ? "mat.TextureIndices[" + std::to_string(parameter.Slot) + "]" + : "mat.Values[" + std::to_string(parameter.Slot) + "]"; + + auto& binding = parameter.Kind == EMaterialParameterKind::Texture + ? bindings.Textures + : bindings.Values; + + binding[parameter.Name] = expr; + } + + const auto graphHlsl = material.GetGraph().GenerateHLSL(bindings); + out << InjectBody(hlsl, graphHlsl); } // Compile the generated pixel shader to SPIR-V with DXC. @@ -74,19 +138,31 @@ namespace Elixir if (rc != 0 || !fs::exists(spvPath)) { EE_CORE_ERROR("Material graph: DXC compilation failed (rc={0}) for {1}.", rc, name) - return nullptr; + result.Diagnostics = "DXC failed while compiling material."; + result.Material.reset(); + return result; + } + + result.Material->Shader = loader->LoadShader(loadDir, name); + if (!result.Material->Shader) + { + result.Diagnostics = "Shader loader could not load the compiled material."; + result.Material.reset(); } - return loader->LoadShader(loadDir, name); + return result; } - std::string MaterialCompiler::InjectBody(const std::string& hlsl, const MaterialGraph& graph) + std::string MaterialCompiler::InjectBody( + const std::string& hlsl, + const std::string& graphBody + ) { std::string out = hlsl; constexpr std::string marker = "// __GRAPH_BODY__"; if (const auto pos = out.find(marker); pos != std::string::npos) - out.replace(pos, marker.size(), graph.GenerateHLSL()); + out.replace(pos, marker.size(), graphBody); return out; } diff --git a/Elixir/Source/Engine/Material/MaterialCompiler.h b/Elixir/Source/Engine/Material/MaterialCompiler.h index 5f85c66f..44b490da 100644 --- a/Elixir/Source/Engine/Material/MaterialCompiler.h +++ b/Elixir/Source/Engine/Material/MaterialCompiler.h @@ -1,21 +1,46 @@ #pragma once -#include +#include #include namespace Elixir { + struct SCompiledMaterialParameter + { + std::string Name; + EMaterialParameterKind Kind = EMaterialParameterKind::Value; + EMaterialGraphValueType ValueType = EMaterialGraphValueType::Float4; + uint32_t Slot = 0; + }; + + struct SCompiledMaterial + { + uint32_t MaterialRevision = 0; + Ref Shader; + std::vector Parameters; + }; + + struct SMaterialCompileResult + { + Ref Material; + std::string Diagnostics; + + explicit operator bool() const { return Material != nullptr; } + }; + // Turns a MaterialGraph into a usable shader: injects the graph's generated // body into the Material template, compiles it to SPIR-V with DXC at // runtime, and loads it (with the shared model vertex shader) into a Shader. class ELIXIR_API MaterialCompiler { public: - // Compile the graph into a ready-to-bind shader. Returns nullptr on failure. - static Ref Compile(const ShaderLoader* loader, const MaterialGraph& graph); + // Pure phase: validates schema, assigns stable slots and lowers HLSL. + static SMaterialCompileResult Build(const Material& material); + + // Toolchain phase: compiles the prepared material to a ready-to-bind shader. + static SMaterialCompileResult Compile(const ShaderLoader* loader, const Material& material); private: - // Splice the graph body into a template string (pure; testable). - static std::string InjectBody(const std::string& hlsl, const MaterialGraph& graph); + static std::string InjectBody(const std::string& hlsl, const std::string& graphBody); }; } diff --git a/Elixir/Source/Engine/Material/MaterialGraph.cpp b/Elixir/Source/Engine/Material/MaterialGraph.cpp index 647af906..18df70b1 100644 --- a/Elixir/Source/Engine/Material/MaterialGraph.cpp +++ b/Elixir/Source/Engine/Material/MaterialGraph.cpp @@ -173,6 +173,11 @@ namespace Elixir } std::string MaterialGraph::GenerateHLSL() const + { + return GenerateHLSL({}); + } + + std::string MaterialGraph::GenerateHLSL(const SMaterialGraphBindings& bindings) const { std::string body; std::unordered_map emitted; @@ -180,7 +185,7 @@ namespace Elixir for (const auto& [channelIndex, nodeId] : m_Channels) { - const std::string var = EmitNode(nodeId, emitted, types, body); + const std::string var = EmitNode(nodeId, emitted, types, body, &bindings); const EMaterialGraphValueType from = types.contains(nodeId) ? types[nodeId] : EMaterialGraphValueType::Float4; @@ -197,7 +202,8 @@ namespace Elixir const uint32_t id, std::unordered_map& emitted, std::unordered_map& types, - std::string& body + std::string& body, + const SMaterialGraphBindings* bindings ) const { if (const auto it = emitted.find(id); it != emitted.end()) @@ -222,7 +228,7 @@ namespace Elixir if (input >= 0) { - in.push_back(EmitNode((uint32_t)input, emitted, types, body)); + in.push_back(EmitNode((uint32_t)input, emitted, types, body, bindings)); inTypes.push_back(types[(uint32_t)input]); } else if (i < node.DefaultInputs.size()) @@ -258,7 +264,9 @@ namespace Elixir type = node.OutputType; break; case EMaterialNodeType::Parameter: - expr = "mat." + node.ParameterName; + expr = bindings && bindings->Values.contains(node.ParameterName) + ? bindings->Values.at(node.ParameterName) + : "mat." + node.ParameterName; type = node.OutputType; break; case EMaterialNodeType::TexCoord: @@ -267,8 +275,9 @@ namespace Elixir break; case EMaterialNodeType::TextureSample: { - // node.TextureExpression holds the index accessor (e.g. mat.TexIndex0.x). - const std::string idx = "mat." + node.TextureParameterName + ".x"; + const std::string idx = bindings && bindings->Textures.contains(node.TextureParameterName) + ? bindings->Textures.at(node.TextureParameterName) + : "mat." + node.TextureParameterName + ".x"; const std::string uv = node.Inputs.empty() || node.Inputs[0] < 0 ? "input.TexCoord" : Widen(A(0), AT(0), EMaterialGraphValueType::Float2); diff --git a/Elixir/Source/Engine/Material/MaterialGraph.h b/Elixir/Source/Engine/Material/MaterialGraph.h index 3a852414..da7ffd00 100644 --- a/Elixir/Source/Engine/Material/MaterialGraph.h +++ b/Elixir/Source/Engine/Material/MaterialGraph.h @@ -36,6 +36,12 @@ namespace Elixir BaseColor, Metallic, Roughness, Emissive, Normal }; + struct SMaterialGraphBindings + { + std::unordered_map Values; + std::unordered_map Textures; + }; + // One node in a material graph. Nodes are plain data (no lambdas) so the graph // can be serialized and edited; the codegen interprets Type. struct SMaterialNode @@ -71,6 +77,9 @@ namespace Elixir // Generate the HLSL statements that fill 'surface. = ...;'. std::string GenerateHLSL() const; + // Generate the HLSL statements that fill 'surface. = ...;'. + std::string GenerateHLSL(const SMaterialGraphBindings& bindings) const; + const std::unordered_map& GetNodes() const { return m_Nodes; } private: @@ -78,7 +87,8 @@ namespace Elixir uint32_t id, std::unordered_map& emitted, std::unordered_map& types, - std::string& body + std::string& body, + const SMaterialGraphBindings* bindings ) const; std::unordered_map m_Nodes; diff --git a/Elixir/Source/Engine/Material/MaterialInstance.cpp b/Elixir/Source/Engine/Material/MaterialInstance.cpp index 5b8d835a..91fe32f2 100644 --- a/Elixir/Source/Engine/Material/MaterialInstance.cpp +++ b/Elixir/Source/Engine/Material/MaterialInstance.cpp @@ -43,7 +43,7 @@ namespace Elixir m_Overrides[name] = value; ++m_Revision; - + return true; } @@ -54,4 +54,4 @@ namespace Elixir return &it->second; return m_Parent ? m_Parent->GetDefaultParam(name) : nullptr; } -} +} \ No newline at end of file diff --git a/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp b/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp new file mode 100644 index 00000000..bb8cf001 --- /dev/null +++ b/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp @@ -0,0 +1,31 @@ +#include + +#include + +using namespace Elixir; + +TEST(MaterialCompilerTest, AssignsStableSlotsByParameterKindAndName) +{ + MaterialGraph graph; + auto material = CreateRef("Test"); + material->SetGraph(std::move(graph)); + + ASSERT_TRUE(material->DefineParameter("Tint", { + .Kind = EMaterialParameterKind::Value, + .ValueType = EMaterialGraphValueType::Float4, + .DefaultValue = SMaterialParam::MakeVector(glm::vec4(1.0f)), + })); + ASSERT_TRUE(material->DefineParameter("Albedo", { + .Kind = EMaterialParameterKind::Texture, + .DefaultValue = SMaterialParam::MakeTexture(nullptr), + })); + + const auto result = MaterialCompiler::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); +} \ No newline at end of file diff --git a/Shaders/Material/Material.ps.hlsl b/Shaders/Material/Material.ps.hlsl index 6c4b9d51..144a9081 100644 --- a/Shaders/Material/Material.ps.hlsl +++ b/Shaders/Material/Material.ps.hlsl @@ -27,21 +27,8 @@ SamplerState texSampler : register(s0); struct CompiledMaterial { - float4 BaseColorFactor; - float Metallic; - float Roughness; - float4 Specular; - float Occlusion; - float4 Clearcoat; - float3 Emissive; - float NormalScale; - float AlphaCutoff; - uint4 TexIndex0; - uint4 TexIndex1; - float4 BaseColorTransform; - float4 NormalTransform; - float4 EmissiveTransform; - float4 OcclusionTransform; + float4 Values[32]; + uint TextureIndices[32]; }; [[vk::binding(2, 0)]] From 2947ac604ab87e8c99f0c4c364da62ad44e588d4 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Mon, 27 Jul 2026 15:08:38 -0300 Subject: [PATCH 08/89] fix(material): migrate dissolve graph compilation Compile Dissolve's graph through Material so the demo retains the compiled material artifact and reports compiler diagnostics. --- Dissolve/Source/Dissolve.cpp | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index fc615358..5204a817 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -14,7 +14,8 @@ Aether::FrameSubmission m_ParticleFrameSubmission; std::array, 2> m_ParticleSystems; std::array, 2> m_ParticleSystemInstances; -Ref graphShader; +Ref graphMaterial; +Ref compiledGraphMaterial; Dissolve::Dissolve() { @@ -84,11 +85,18 @@ Dissolve::Dissolve() metallic.ConstantValue = { 0.9f, 0.0f, 0.0f, 0.0f }; graph.SetChannel(EMaterialChannel::Metallic, graph.AddNode(metallic)); - graphShader = MaterialCompiler::Compile(m_ShaderLoader.get(), graph); - if (graphShader) + graphMaterial = CreateRef("DissolveGraph"); + graphMaterial->SetGraph(std::move(graph)); + + const auto result = MaterialCompiler::Compile(m_ShaderLoader.get(), *graphMaterial); + + if (result) + { + compiledGraphMaterial = result.Material; EE_CORE_INFO("Node-graph material compiled and loaded successfully.") + } else - EE_CORE_ERROR("Node-graph material compilation FAILED.") + EE_CORE_ERROR("Node-graph material compilation failed: {}", result.Diagnostics) } m_GraphicsContext->SetClearColor({ 0.015f, 0.025f, 0.06f, 1.0f }); From 83ecde23df8573f10506faeb7a846ed00d1a62fd Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Mon, 27 Jul 2026 20:40:37 -0300 Subject: [PATCH 09/89] refactor(material): connect Aether sprite render data --- Elixir/Source/Engine/Aether/Emitter.cpp | 43 +++++++++++++++++ Elixir/Source/Engine/Aether/Emitter.h | 9 ++++ Elixir/Source/Engine/Aether/Renderer.cpp | 1 + Elixir/Source/Engine/Aether/Renderer.h | 1 + Elixir/Source/Engine/Material/Material.cpp | 20 ++++++++ Elixir/Source/Engine/Material/Material.h | 19 ++++++++ .../Engine/Material/MaterialCompiler.cpp | 18 ++++++- .../Source/Engine/Material/MaterialCompiler.h | 6 +++ .../Engine/Material/MaterialInstance.cpp | 16 ++++++- .../Source/Engine/Material/MaterialInstance.h | 9 ++++ .../Engine/Material/MaterialRenderProxy.cpp | 42 +++++++++++++++++ .../Engine/Material/MaterialRenderProxy.h | 27 +++++++++++ Elixir/Tests/Engine/Aether/SystemTest.cpp | 36 ++++++++++++++ .../Engine/Material/MaterialCompilerTest.cpp | 15 +++++- .../Material/MaterialRenderProxyTest.cpp | 47 +++++++++++++++++++ 15 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 Elixir/Source/Engine/Material/MaterialRenderProxy.cpp create mode 100644 Elixir/Source/Engine/Material/MaterialRenderProxy.h create mode 100644 Elixir/Tests/Engine/Material/MaterialRenderProxyTest.cpp diff --git a/Elixir/Source/Engine/Aether/Emitter.cpp b/Elixir/Source/Engine/Aether/Emitter.cpp index b0ee7200..01126e8f 100644 --- a/Elixir/Source/Engine/Aether/Emitter.cpp +++ b/Elixir/Source/Engine/Aether/Emitter.cpp @@ -50,6 +50,49 @@ namespace Elixir::Aether if (spawnRateParamIndex != UINT32_MAX) emitter.SpawnRatePerSecond = params[spawnRateParamIndex].Value.x; + if (m_Material) + { + const auto& material = m_Material->GetParent(); + + if (!material) + { + EE_CORE_ERROR( + "Aether emitter '{}' has a material instance without a parent material.", + m_Name + ) + } + else if (m_RenderMode != EParticleRenderMode::Sprite) + { + EE_CORE_ERROR( + "Aether emitter '{}' only supports materials for Sprite rendering.", + m_Name + ) + } + else if (!material->SupportsUsage(EMaterialUsage::ParticleSprite)) + { + EE_CORE_ERROR( + "Aether emitter '{}' requires a material enabled for ParticleSprite usage.", + m_Name + ) + } + else + { + const auto compiled = MaterialCompiler::Build(*material); + if (!compiled) + { + EE_CORE_ERROR( + "Aether emitter '{}' could not compile its material layout: {}.", + m_Name, + compiled.Diagnostics + ) + } + else + { + emitter.Material = m_Material->CreateRenderProxy(compiled.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..34914989 100644 --- a/Elixir/Source/Engine/Aether/Emitter.h +++ b/Elixir/Source/Engine/Aether/Emitter.h @@ -5,6 +5,7 @@ #include #include #include +#include namespace Elixir::Aether { @@ -18,6 +19,10 @@ namespace Elixir::Aether EParticleSimulationSpace SimulationSpace = EParticleSimulationSpace::World; Ref SpriteTexture; + // Immutable material state captured while the system is compiled. + // It is safe to read for the full render submission. + Ref Material; + float SpawnRatePerSecond = 1.0f; uint32_t BurstCount = 0u; float BurstIntervalSeconds = 0.0f; @@ -111,6 +116,9 @@ namespace Elixir::Aether const Ref& GetSpriteTexture() const { return m_SpriteTexture; } void SetSpriteTexture(const Ref& texture) { m_SpriteTexture = texture; } + const Ref& GetMaterial() const { return m_Material; } + void SetMaterial(const Ref& material) { m_Material = material; } + uint32_t GetBurstCount() const { return m_BurstCount; } float GetBurstIntervalSeconds() const { return m_BurstIntervalSeconds; } @@ -133,6 +141,7 @@ namespace Elixir::Aether EParticleRenderMode m_RenderMode = EParticleRenderMode::Sprite; EParticleSimulationSpace m_SimulationSpace = EParticleSimulationSpace::World; Ref m_SpriteTexture; + Ref m_Material; uint32_t m_MaxParticles; std::vector> m_SpawnModules; diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index e6f560a3..ff72518e 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -1140,6 +1140,7 @@ namespace Elixir::Aether batch->Items.push_back({ .Instance = &instance, .Emitter = &emitter, + .Material = emitter.Material.get(), .LocalEmitterIndex = emitterIndex, }); } diff --git a/Elixir/Source/Engine/Aether/Renderer.h b/Elixir/Source/Engine/Aether/Renderer.h index 439d4f49..ba809db9 100644 --- a/Elixir/Source/Engine/Aether/Renderer.h +++ b/Elixir/Source/Engine/Aether/Renderer.h @@ -237,6 +237,7 @@ namespace Elixir::Aether { const SSubmittedSystemInstance* Instance = nullptr; const SCompiledEmitter* Emitter = nullptr; + const MaterialRenderProxy* Material = nullptr; uint32_t LocalEmitterIndex = 0; }; diff --git a/Elixir/Source/Engine/Material/Material.cpp b/Elixir/Source/Engine/Material/Material.cpp index 09197942..fdc3cdf0 100644 --- a/Elixir/Source/Engine/Material/Material.cpp +++ b/Elixir/Source/Engine/Material/Material.cpp @@ -9,6 +9,26 @@ namespace Elixir ++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::SetDefaultParam(const std::string& name, const SMaterialParam& value) { const auto it = m_Parameters.find(name); diff --git a/Elixir/Source/Engine/Material/Material.h b/Elixir/Source/Engine/Material/Material.h index 11fb47ec..2e27803f 100644 --- a/Elixir/Source/Engine/Material/Material.h +++ b/Elixir/Source/Engine/Material/Material.h @@ -5,6 +5,15 @@ namespace Elixir { + // A renderer-specific shader permutation supported by a Surface material. + // It does not change the material domain or graph outputs. + enum class EMaterialUsage : uint8_t + { + ParticleSprite = 0, + ParticleRibbon, + ParticleMesh + }; + enum class EMaterialParameterKind : uint8_t { Value, Texture @@ -67,6 +76,9 @@ namespace Elixir void SetGraph(MaterialGraph graph); const MaterialGraph& GetGraph() const { return m_Graph; } + bool SetUsage(EMaterialUsage usage, bool enabled); + bool SupportsUsage(EMaterialUsage usage) const; + bool SetDefaultParam(const std::string& name, const SMaterialParam& value); const SMaterialParam* GetDefaultParam(const std::string& name) const; @@ -85,6 +97,7 @@ namespace Elixir const std::string& GetName() const { return m_Name; } const auto& GetParameters() const { return m_Parameters; } + uint32_t GetUsageMask() const { return m_UsageMask; } uint32_t GetRevision() const { return m_Revision; } private: @@ -96,6 +109,12 @@ namespace Elixir std::string m_Name; MaterialGraph m_Graph; std::unordered_map m_Parameters; + uint32_t m_UsageMask = 0; uint32_t m_Revision = 1; }; + + constexpr uint32_t GetMaterialUsageMask(const EMaterialUsage usage) + { + return 1u << static_cast(usage); + } } \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/MaterialCompiler.cpp b/Elixir/Source/Engine/Material/MaterialCompiler.cpp index c535f18c..8790a919 100644 --- a/Elixir/Source/Engine/Material/MaterialCompiler.cpp +++ b/Elixir/Source/Engine/Material/MaterialCompiler.cpp @@ -31,6 +31,21 @@ namespace Elixir return ss.str(); } + + std::string ValueExpression(const SCompiledMaterialParameter& parameter) + { + const std::string value = "mat.Values[" + std::to_string(parameter.Slot) + "]"; + + switch (parameter.ValueType) + { + case EMaterialGraphValueType::Float: return value + ".x"; + case EMaterialGraphValueType::Float2: return value + ".xy"; + case EMaterialGraphValueType::Float3: return value + ".xyz"; + case EMaterialGraphValueType::Float4: return value; + } + + return value; + } } SMaterialCompileResult MaterialCompiler::Build(const Material& material) @@ -68,6 +83,7 @@ namespace Elixir } const auto compiled = CreateRef(); + compiled->UsageMask = material.GetUsageMask(); compiled->MaterialRevision = material.GetRevision(); compiled->Parameters = std::move(layout); return { .Material = compiled }; @@ -114,7 +130,7 @@ namespace Elixir { const auto expr = parameter.Kind == EMaterialParameterKind::Texture ? "mat.TextureIndices[" + std::to_string(parameter.Slot) + "]" - : "mat.Values[" + std::to_string(parameter.Slot) + "]"; + : ValueExpression(parameter); auto& binding = parameter.Kind == EMaterialParameterKind::Texture ? bindings.Textures diff --git a/Elixir/Source/Engine/Material/MaterialCompiler.h b/Elixir/Source/Engine/Material/MaterialCompiler.h index 44b490da..d0bd1e19 100644 --- a/Elixir/Source/Engine/Material/MaterialCompiler.h +++ b/Elixir/Source/Engine/Material/MaterialCompiler.h @@ -16,8 +16,14 @@ namespace Elixir struct SCompiledMaterial { uint32_t MaterialRevision = 0; + uint32_t UsageMask = 0; Ref Shader; std::vector Parameters; + + bool SupportsUsage(const EMaterialUsage usage) const + { + return (UsageMask & GetMaterialUsageMask(usage)) != 0; + } }; struct SMaterialCompileResult diff --git a/Elixir/Source/Engine/Material/MaterialInstance.cpp b/Elixir/Source/Engine/Material/MaterialInstance.cpp index 91fe32f2..5fac58a9 100644 --- a/Elixir/Source/Engine/Material/MaterialInstance.cpp +++ b/Elixir/Source/Engine/Material/MaterialInstance.cpp @@ -1,6 +1,8 @@ #include "epch.h" #include "MaterialInstance.h" +#include "MaterialRenderProxy.h" + namespace Elixir { bool MaterialInstance::SetScalar(const std::string& name, const float value) @@ -36,6 +38,18 @@ namespace Elixir return param ? param->Texture : nullptr; } + Ref MaterialInstance::CreateRenderProxy( + Ref material + ) const + { + return MaterialRenderProxy::Create(std::move(material), *this); + } + + const SMaterialParam* MaterialInstance::GetResolvedParameter(const std::string& name) const + { + return Resolve(name); + } + bool MaterialInstance::SetOverride(const std::string& name, const SMaterialParam& value) { if (!m_Parent || !m_Parent->IsParameterValueCompatible(name, value)) @@ -54,4 +68,4 @@ namespace Elixir return &it->second; return m_Parent ? m_Parent->GetDefaultParam(name) : nullptr; } -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/Material/MaterialInstance.h b/Elixir/Source/Engine/Material/MaterialInstance.h index f3edaaa0..dfbbaa2a 100644 --- a/Elixir/Source/Engine/Material/MaterialInstance.h +++ b/Elixir/Source/Engine/Material/MaterialInstance.h @@ -4,6 +4,9 @@ namespace Elixir { + struct SCompiledMaterial; + class MaterialRenderProxy; + class ELIXIR_API MaterialInstance { public: @@ -21,6 +24,12 @@ namespace Elixir const Ref& GetParent() const { return m_Parent; } uint32_t GetRevision() const { return m_Revision; } + Ref CreateRenderProxy( + Ref material + ) const; + + const SMaterialParam* GetResolvedParameter(const std::string& name) const; + private: bool SetOverride(const std::string& name, const SMaterialParam& value); diff --git a/Elixir/Source/Engine/Material/MaterialRenderProxy.cpp b/Elixir/Source/Engine/Material/MaterialRenderProxy.cpp new file mode 100644 index 00000000..704b20e1 --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialRenderProxy.cpp @@ -0,0 +1,42 @@ +#include "epch.h" +#include "MaterialRenderProxy.h" + +namespace Elixir +{ + 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/Material/MaterialRenderProxy.h b/Elixir/Source/Engine/Material/MaterialRenderProxy.h new file mode 100644 index 00000000..9619e741 --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialRenderProxy.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +namespace Elixir +{ + class ELIXIR_API MaterialRenderProxy final + { + public: + static Ref Create( + Ref material, + const MaterialInstance& instance + ); + + const Ref CompiledMaterial() const { return m_CompiledMaterial; } + uint32_t GetInstanceRevision() const { return m_InstanceRevision; } + const std::vector& GetValues() const { return m_Values; } + 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/Tests/Engine/Aether/SystemTest.cpp b/Elixir/Tests/Engine/Aether/SystemTest.cpp index 8117619f..1d537992 100644 --- a/Elixir/Tests/Engine/Aether/SystemTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemTest.cpp @@ -1,6 +1,7 @@ #include #include +#include using namespace Elixir; using namespace Elixir::Aether; @@ -79,4 +80,39 @@ 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"); +} + +TEST(AetherSystemTest, CompileSnapshotsParticleSpriteMaterialForRenderData) +{ + const auto material = CreateRef("Particle tint"); + ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleSprite, true)); + ASSERT_TRUE(material->DefineParameter("Tint", { + .Kind = EMaterialParameterKind::Value, + .ValueType = EMaterialGraphValueType::Float4, + .DefaultValue = SMaterialParam::MakeVector({ 1.0f, 1.0f, 1.0f, 1.0f }), + })); + + const auto instance = CreateRef(material); + ASSERT_TRUE(instance->SetVector("Tint", { 0.25f, 0.5f, 0.75f, 1.0f })); + + System system{ "Material snapshot contract" }; + auto& emitter = system.AddEmitter("Smoke", 8, 0.0f); + emitter.SetMaterial(instance); + + const auto first = system.Compile(); + + ASSERT_EQ(first.Emitters.size(), 1); + ASSERT_TRUE(first.Emitters[0].Material); + EXPECT_TRUE(first.Emitters[0].Material->CompiledMaterial()->SupportsUsage( + EMaterialUsage::ParticleSprite + )); + EXPECT_EQ(first.Emitters[0].Material->GetInstanceRevision(), instance->GetRevision()); + EXPECT_FLOAT_EQ(first.Emitters[0].Material->GetValues()[0].x, 0.25f); + + ASSERT_TRUE(instance->SetVector("Tint", { 0.75f, 0.5f, 0.25f, 1.0f })); + const auto second = system.Compile(); + + ASSERT_TRUE(second.Emitters[0].Material); + EXPECT_FLOAT_EQ(first.Emitters[0].Material->GetValues()[0].x, 0.25f); + EXPECT_FLOAT_EQ(second.Emitters[0].Material->GetValues()[0].x, 0.75f); } \ No newline at end of file diff --git a/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp b/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp index bb8cf001..a61c2ed0 100644 --- a/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp @@ -7,7 +7,7 @@ using namespace Elixir; TEST(MaterialCompilerTest, AssignsStableSlotsByParameterKindAndName) { MaterialGraph graph; - auto material = CreateRef("Test"); + const auto material = CreateRef("Test"); material->SetGraph(std::move(graph)); ASSERT_TRUE(material->DefineParameter("Tint", { @@ -28,4 +28,17 @@ TEST(MaterialCompilerTest, AssignsStableSlotsByParameterKindAndName) 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(MaterialCompilerTest, PreservesEnabledRendererUsages) +{ + const auto material = CreateRef("Particle material"); + ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleSprite, true)); + + const auto result = MaterialCompiler::Build(*material); + + ASSERT_TRUE(result); + EXPECT_TRUE(result.Material->SupportsUsage(EMaterialUsage::ParticleSprite)); + EXPECT_FALSE(result.Material->SupportsUsage(EMaterialUsage::ParticleRibbon)); + EXPECT_FALSE(result.Material->SupportsUsage(EMaterialUsage::ParticleMesh)); } \ No newline at end of file diff --git a/Elixir/Tests/Engine/Material/MaterialRenderProxyTest.cpp b/Elixir/Tests/Engine/Material/MaterialRenderProxyTest.cpp new file mode 100644 index 00000000..44bbc1e0 --- /dev/null +++ b/Elixir/Tests/Engine/Material/MaterialRenderProxyTest.cpp @@ -0,0 +1,47 @@ +#include + +#include + +using namespace Elixir; + +TEST(MaterialRenderProxyTest, ResolvesOverridesIntoAnImmutableSnapshot) +{ + const auto material = CreateRef("Tinted"); + ASSERT_TRUE(material->DefineParameter("Tint", { + .Kind = EMaterialParameterKind::Value, + .ValueType = EMaterialGraphValueType::Float4, + .DefaultValue = SMaterialParam::MakeVector(glm::vec4(1.0f)), + })); + + const auto compiled = MaterialCompiler::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 = instance.CreateRenderProxy(compiled); + 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 = EMaterialGraphValueType::Float4, + .DefaultValue = SMaterialParam::MakeVector(glm::vec4(1.0f)), + })); + + const auto compiled = MaterialCompiler::Build(*material).Material; + ASSERT_TRUE(compiled); + ASSERT_TRUE(material->SetDefaultParam( + "Tint", + SMaterialParam::MakeVector(glm::vec4(0.5f)) + )); + + MaterialInstance instance(material); + EXPECT_FALSE(instance.CreateRenderProxy(compiled)); +} \ No newline at end of file From 0088e768a7fe1e2d668bada872f86c0ef32e2766 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Mon, 27 Jul 2026 21:04:53 -0300 Subject: [PATCH 10/89] test(aether): remove unused world emitter binding --- Elixir/Tests/Engine/Aether/SystemTest.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Elixir/Tests/Engine/Aether/SystemTest.cpp b/Elixir/Tests/Engine/Aether/SystemTest.cpp index 1d537992..40752549 100644 --- a/Elixir/Tests/Engine/Aether/SystemTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemTest.cpp @@ -9,7 +9,7 @@ using namespace Elixir::Aether; TEST(AetherSystemTest, CompilePreservesEmitterSimulationSpace) { System system{ "Simulation space contract" }; - auto& worldEmitter = system.AddEmitter("World", 8, 0.0f); + system.AddEmitter("World", 8, 0.0f); // world emitter auto& localEmitter = system.AddEmitter("Local", 8, 0.0f); localEmitter.SetSimulationSpace(EParticleSimulationSpace::Local); From b46d2d53b970c1cd85003f14c339dd089a0e8fb1 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Wed, 29 Jul 2026 17:26:55 -0300 Subject: [PATCH 11/89] refactor(aether): render particle sprite materials Compile the particle sprite material permutation and publish immutable proxy data into a per-frame GPU material table. Batch sprite draws by compiled material shader while preserving the static sprite fallback and avoiding descriptor writes during draw submission. --- Elixir/Source/Engine/Aether/Emitter.cpp | 27 +-- Elixir/Source/Engine/Aether/Emitter.h | 6 +- .../Engine/Aether/ParticleMaterialTable.cpp | 35 ++++ .../Engine/Aether/ParticleMaterialTable.h | 35 ++++ .../Engine/Aether/ParticleResourcePool.h | 1 + Elixir/Source/Engine/Aether/Renderer.cpp | 170 ++++++++++++++++-- Elixir/Source/Engine/Aether/Renderer.h | 19 +- .../Engine/Material/MaterialCompiler.cpp | 161 +++++++++++++---- .../Source/Engine/Material/MaterialCompiler.h | 22 ++- .../Engine/Material/MaterialRenderProxy.h | 2 +- .../AetherParticleMaterialTableTest.cpp | 55 ++++++ Elixir/Tests/Engine/Aether/SystemTest.cpp | 18 +- Shaders/Material/ParticleSprite.ps.hlsl | 78 ++++++++ Shaders/Shaders.cmake | 10 +- 14 files changed, 557 insertions(+), 82 deletions(-) create mode 100644 Elixir/Source/Engine/Aether/ParticleMaterialTable.cpp create mode 100644 Elixir/Source/Engine/Aether/ParticleMaterialTable.h create mode 100644 Elixir/Tests/Engine/Aether/AetherParticleMaterialTableTest.cpp create mode 100644 Shaders/Material/ParticleSprite.ps.hlsl diff --git a/Elixir/Source/Engine/Aether/Emitter.cpp b/Elixir/Source/Engine/Aether/Emitter.cpp index 01126e8f..deacc34f 100644 --- a/Elixir/Source/Engine/Aether/Emitter.cpp +++ b/Elixir/Source/Engine/Aether/Emitter.cpp @@ -52,23 +52,14 @@ namespace Elixir::Aether if (m_Material) { - const auto& material = m_Material->GetParent(); - - if (!material) - { - EE_CORE_ERROR( - "Aether emitter '{}' has a material instance without a parent material.", - m_Name - ) - } - else if (m_RenderMode != EParticleRenderMode::Sprite) + if (m_RenderMode != EParticleRenderMode::Sprite) { EE_CORE_ERROR( "Aether emitter '{}' only supports materials for Sprite rendering.", m_Name ) } - else if (!material->SupportsUsage(EMaterialUsage::ParticleSprite)) + else if (!m_Material->GetCompiledMaterial()->SupportsUsage(EMaterialUsage::ParticleSprite)) { EE_CORE_ERROR( "Aether emitter '{}' requires a material enabled for ParticleSprite usage.", @@ -77,19 +68,7 @@ namespace Elixir::Aether } else { - const auto compiled = MaterialCompiler::Build(*material); - if (!compiled) - { - EE_CORE_ERROR( - "Aether emitter '{}' could not compile its material layout: {}.", - m_Name, - compiled.Diagnostics - ) - } - else - { - emitter.Material = m_Material->CreateRenderProxy(compiled.Material); - } + emitter.Material = m_Material; } } diff --git a/Elixir/Source/Engine/Aether/Emitter.h b/Elixir/Source/Engine/Aether/Emitter.h index 34914989..1400c142 100644 --- a/Elixir/Source/Engine/Aether/Emitter.h +++ b/Elixir/Source/Engine/Aether/Emitter.h @@ -116,8 +116,8 @@ namespace Elixir::Aether const Ref& GetSpriteTexture() const { return m_SpriteTexture; } void SetSpriteTexture(const Ref& texture) { m_SpriteTexture = texture; } - const Ref& GetMaterial() const { return m_Material; } - void SetMaterial(const Ref& material) { m_Material = material; } + const Ref& GetMaterial() const { return m_Material; } + void SetMaterial(Ref material) { m_Material = std::move(material); } uint32_t GetBurstCount() const { return m_BurstCount; } float GetBurstIntervalSeconds() const { return m_BurstIntervalSeconds; } @@ -141,7 +141,7 @@ namespace Elixir::Aether EParticleRenderMode m_RenderMode = EParticleRenderMode::Sprite; EParticleSimulationSpace m_SimulationSpace = EParticleSimulationSpace::World; Ref m_SpriteTexture; - Ref m_Material; + Ref m_Material; uint32_t m_MaxParticles; std::vector> m_SpawnModules; diff --git a/Elixir/Source/Engine/Aether/ParticleMaterialTable.cpp b/Elixir/Source/Engine/Aether/ParticleMaterialTable.cpp new file mode 100644 index 00000000..ea9c0400 --- /dev/null +++ b/Elixir/Source/Engine/Aether/ParticleMaterialTable.cpp @@ -0,0 +1,35 @@ +#include "epch.h" +#include "ParticleMaterialTable.h" + +namespace Elixir::Aether +{ + std::optional ParticleMaterialTable::Add(const MaterialRenderProxy& material) + { + const auto found = m_Indices.find(&material); + if (found != m_Indices.end()) + return found->second; + + 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; + } + + SParticleMaterialData ParticleMaterialTable::BuildData(const MaterialRenderProxy& material) + { + SParticleMaterialData data{}; + std::ranges::fill(data.TextureIndices, UINT32_MAX); + + const auto& values = material.GetValues(); + std::copy_n( + values.begin(), + std::min(values.size(), data.Values.size()), + data.Values.begin() + ); + + return data; + } +} diff --git a/Elixir/Source/Engine/Aether/ParticleMaterialTable.h b/Elixir/Source/Engine/Aether/ParticleMaterialTable.h new file mode 100644 index 00000000..14ecae53 --- /dev/null +++ b/Elixir/Source/Engine/Aether/ParticleMaterialTable.h @@ -0,0 +1,35 @@ +#pragma once + +#include + +namespace Elixir::Aether +{ + // ABI shared with Shaders/Material/ParticleSprite.ps.hlsl. + struct alignas(16) SParticleMaterialData + { + std::array Values{}; + std::array TextureIndices{}; + }; + + // Renderer-owned, frame-local staging data. It does not contain mutable + // particle simulation state. + class ELIXIR_API ParticleMaterialTable final + { + public: + explicit ParticleMaterialTable(const uint32_t capacity) : m_Capacity(capacity) {} + + // Returns the stable index for this render submission, or nullopt when + // the per-frame capacity is exhausted. + std::optional Add(const MaterialRenderProxy& material); + + const std::vector& GetData() const { return m_Data; } + uint32_t GetCount() const { return static_cast(m_Data.size()); } + + private: + static SParticleMaterialData BuildData(const MaterialRenderProxy& material); + + uint32_t m_Capacity = 0; + std::unordered_map m_Indices; + std::vector m_Data; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Aether/ParticleResourcePool.h b/Elixir/Source/Engine/Aether/ParticleResourcePool.h index b4a1018a..4f348af9 100644 --- a/Elixir/Source/Engine/Aether/ParticleResourcePool.h +++ b/Elixir/Source/Engine/Aether/ParticleResourcePool.h @@ -21,6 +21,7 @@ namespace Elixir::Aether uint32_t OpCapacity = 65'536; uint32_t ParameterCapacity = 16'384; uint32_t TriggerTargetCapacity = 4'096; + uint32_t MaterialCapacity = 4'096; uint32_t TriggerEventCapacityPerEmitter = 64; }; diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index ff72518e..7009cadf 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -32,6 +32,13 @@ namespace Elixir::Aether uint32_t ParticleBaseOffset = 0; }; + struct SMaterialPushConstants + { + glm::mat4 WorldTransform{ 1.0f }; + uint32_t SpriteIndex = 0; + uint32_t MaterialIndex = UINT32_MAX; + }; + SEmitterData ToEmitterDescription( const SCompiledEmitter& emitter, uint32_t opBaseOffset, @@ -179,6 +186,7 @@ namespace Elixir::Aether m_FrameData.Proj = camera.GetProjectionMatrix(); m_FrameData.ViewProj = camera.GetViewProjectionMatrix(); m_FrameData.CameraPos = camera.GetPosition(); + m_FrameData.Time = m_ElapsedTimeSeconds; m_FrameConstantBuffer->UpdateData(&m_FrameData, sizeof(SFrameData)); std::vector submittedInstances; @@ -229,11 +237,33 @@ namespace Elixir::Aether if (submittedInstances.empty()) return; + ParticleMaterialTable materials{ m_ParticlePoolLimits.MaterialCapacity }; + const auto simulationBatches = BuildSimulationBatches(submittedInstances); - const auto renderBatches = BuildRenderBatches(submittedInstances); + const auto renderBatches = BuildRenderBatches(submittedInstances, materials); + + if (!materials.GetData().empty()) + { + m_MaterialBuffer->UpdateData( + materials.GetData().data(), + materials.GetData().size() * sizeof(SParticleMaterialData) + ); + } + + // + for (const auto& batch : renderBatches) + { + if (!batch.Key.MaterialShader || batch.Items.empty()) + continue; + + const auto& shader = batch.Items.front().Material + ->GetCompiledMaterial()->GetShader(EMaterialUsage::ParticleSprite); + PrepareParticleSpriteMaterialShader(shader); + } m_LastSubmissionMetrics.SimulationBatchCount = simulationBatches.size(); m_LastSubmissionMetrics.RenderBatchCount = renderBatches.size(); + m_LastSubmissionMetrics.SubmittedMaterialCount = materials.GetCount(); for (const auto& batch : renderBatches) m_LastSubmissionMetrics.SubmittedRenderItemCount += batch.Items.size(); @@ -561,6 +591,11 @@ namespace Elixir::Aether sizeof(SParameterData) * m_ParticlePoolLimits.ParameterCapacity ); + m_MaterialBuffer = DynamicStorageBuffer::Create( + m_GraphicsContext, + sizeof(SParticleMaterialData) * m_ParticlePoolLimits.MaterialCapacity + ); + m_ParamsBuffer = UniformBuffer::Create( m_GraphicsContext, sizeof(SParamsData) @@ -834,6 +869,47 @@ namespace Elixir::Aether return handle.Index; } + void Renderer::PrepareParticleSpriteMaterialShader(const Ref& shader) + { + if (!shader || !m_MaterialShaderCache.insert(shader.get()).second) + return; + + const SMaterialPushConstants pc{ + .SpriteIndex = m_WhiteTextureHandle.Index, + }; + + shader->SetPushConstant("pc", (void*)&pc, sizeof(pc)); + shader->BindConstantBuffer("cbFrame", m_FrameConstantBuffer); + shader->BindStorageBuffer("materials", m_MaterialBuffer); + shader->BindTextureSet("sprites", m_Sprites); + shader->BindSampler("spriteSampler", m_SpriteSampler); + } + + Ref Renderer::GetParticleSpritePipeline( + SParticleStateLayoutRuntime& runtime, + const Ref& shader + ) const + { + const auto found = runtime.MaterialPipelines.find(shader.get()); + if (found != runtime.MaterialPipelines.end()) + return found->second; + + PipelineBuilder builder; + builder.SetShader(shader); + builder.SetInputTopology(EPrimitiveTopology::TriangleList); + builder.SetPolygonMode(EPolygonMode::Fill); + builder.SetCullMode(ECullMode::None, EFrontFace::CounterClockwise); + builder.EnableAlphaBlending(); + builder.DisableDepthTest(); + builder.SetColorAttachmentFormat(EImageFormat::R8G8B8A8_SRGB); + builder.SetDepthAttachmentFormat(EDepthStencilImageFormat::D32_SFLOAT); + builder.SetBufferLayout(runtime.SpritePipeline->GetBufferLayout()); + + const auto pipeline = builder.Build(m_GraphicsContext); + runtime.MaterialPipelines.emplace(shader.get(), pipeline); + return pipeline; + } + void Renderer::BeginRendering(const Ref& cmd) const { const auto renderingInfo = SRenderingInfo @@ -1097,7 +1173,8 @@ namespace Elixir::Aether } std::vector Renderer::BuildRenderBatches( - const std::vector& instances + const std::vector& instances, + ParticleMaterialTable& materials ) const { std::vector batches; @@ -1113,9 +1190,36 @@ namespace Elixir::Aether if (emitter.MaxParticles == 0) continue; + const MaterialRenderProxy* material = emitter.Material.get(); + const Shader* materialShader = nullptr; + uint32_t materialIndex = UINT32_MAX; + + if (material && emitter.RenderMode == EParticleRenderMode::Sprite) + { + const auto& shader = material->GetCompiledMaterial() + ->GetShader(EMaterialUsage::ParticleSprite); + + if (shader) + { + if (const auto index = materials.Add(*material)) + { + materialShader = shader.get(); + materialIndex = *index; + } + else + { + EE_CORE_ERROR( + "Aether particle material capacity ({}) exceeded.", + m_ParticlePoolLimits.MaterialCapacity + ) + } + } + } + const SRenderBatchKey key{ .ParticleStateLayout = instance.ParticleStateLayout, .RenderMode = emitter.RenderMode, + .MaterialShader = materialShader, }; SRenderBatch* batch = nullptr; @@ -1140,7 +1244,8 @@ namespace Elixir::Aether batch->Items.push_back({ .Instance = &instance, .Emitter = &emitter, - .Material = emitter.Material.get(), + .Material = material, + .MaterialIndex = materialIndex, .LocalEmitterIndex = emitterIndex, }); } @@ -1154,10 +1259,17 @@ namespace Elixir::Aether uint32_t(right.Key.ParticleStateLayout); } - return GetRenderModeOrder(left.Key.RenderMode) < - GetRenderModeOrder(right.Key.RenderMode); + if (left.Key.RenderMode != right.Key.RenderMode) + { + return GetRenderModeOrder(left.Key.RenderMode) < + GetRenderModeOrder(right.Key.RenderMode); } - ); + + return std::less{}( + left.Key.MaterialShader, + right.Key.MaterialShader + ); + }); return batches; } @@ -1293,7 +1405,7 @@ namespace Elixir::Aether void Renderer::RenderBatch(const Ref& cmd, const SRenderBatch& batch) { - const auto* runtime = FindParticleStateLayoutRuntime(batch.Key.ParticleStateLayout); + auto* runtime = FindParticleStateLayoutRuntime(batch.Key.ParticleStateLayout); EE_CORE_ASSERT(runtime, "Aether particle state layout runtime is missing.") if (!runtime) return; @@ -1347,17 +1459,49 @@ namespace Elixir::Aether case EParticleRenderMode::Sprite: { - runtime->SpritePipeline->Bind(cmd); + Ref shader = runtime->SpriteShader; + Ref pipeline = runtime->SpritePipeline; + + if (batch.Key.MaterialShader) + { + EE_CORE_ASSERT( + !batch.Items.empty() && batch.Items.front().Material, + "Aether material sprite batch requires a material proxy." + ) + + shader = batch.Items.front().Material->GetCompiledMaterial() + ->GetShader(EMaterialUsage::ParticleSprite); + pipeline = GetParticleSpritePipeline(*runtime, shader); + } + + pipeline->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) - }; + const auto worldTransform = GetParticleRenderTransform( + *item.Emitter, + *item.Instance->Instance + ); + + if (batch.Key.MaterialShader) + { + const SMaterialPushConstants pc{ + .WorldTransform = worldTransform, + .SpriteIndex = ResolveSpriteIndex(item.Emitter->SpriteTexture), + .MaterialIndex = item.MaterialIndex, + }; + shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); + } + else + { + const SSpritePushConstants pc{ + .WorldTransform = worldTransform, + .SpriteIndex = ResolveSpriteIndex(item.Emitter->SpriteTexture), + }; + shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); + } - runtime->SpriteShader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); cmd->Draw( 6, item.Emitter->MaxParticles, diff --git a/Elixir/Source/Engine/Aether/Renderer.h b/Elixir/Source/Engine/Aether/Renderer.h index ba809db9..d0a149f7 100644 --- a/Elixir/Source/Engine/Aether/Renderer.h +++ b/Elixir/Source/Engine/Aether/Renderer.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -17,6 +18,7 @@ namespace Elixir::Aether glm::mat4 Proj; glm::mat4 ViewProj; glm::vec3 CameraPos; + float Time = 0.0f; }; struct alignas(16) SParamsData @@ -135,6 +137,7 @@ namespace Elixir::Aether size_t SimulationBatchCount = 0; size_t RenderBatchCount = 0; size_t SubmittedRenderItemCount = 0; + size_t SubmittedMaterialCount = 0; }; class ELIXIR_API Renderer final @@ -169,6 +172,8 @@ namespace Elixir::Aether Ref UpdateShader; Ref UpdatePipeline; + std::unordered_map> MaterialPipelines; + Ref SpriteShader; Ref SpritePipeline; Ref RibbonShader; @@ -196,6 +201,11 @@ namespace Elixir::Aether void BindParticleStateLayoutShaderParameters(const SParticleStateLayoutRuntime& runtime) const; uint32_t ResolveSpriteIndex(const Ref& texture); + void PrepareParticleSpriteMaterialShader(const Ref& shader); + Ref GetParticleSpritePipeline( + SParticleStateLayoutRuntime& runtime, + const Ref& shader + ) const; void BeginRendering(const Ref& cmd) const; void EndRendering(const Ref& cmd) const; @@ -229,6 +239,7 @@ namespace Elixir::Aether { EParticleStateLayout ParticleStateLayout = EParticleStateLayout::CoreV1; EParticleRenderMode RenderMode = EParticleRenderMode::Sprite; + const Shader* MaterialShader = nullptr; bool operator==(const SRenderBatchKey&) const = default; }; @@ -238,6 +249,7 @@ namespace Elixir::Aether const SSubmittedSystemInstance* Instance = nullptr; const SCompiledEmitter* Emitter = nullptr; const MaterialRenderProxy* Material = nullptr; + uint32_t MaterialIndex = UINT32_MAX; uint32_t LocalEmitterIndex = 0; }; @@ -271,7 +283,10 @@ namespace Elixir::Aether BuildSimulationBatches(const std::vector& instances) const; std::vector - BuildRenderBatches(const std::vector& instances) const; + BuildRenderBatches( + const std::vector& instances, + ParticleMaterialTable& materials + ) const; void SimulateBatch( const Ref& cmd, @@ -344,11 +359,13 @@ namespace Elixir::Aether Ref m_EmitterBuffer; Ref m_OpBuffer; Ref m_ParameterBuffer; + Ref m_MaterialBuffer; Ref m_ParamsBuffer; Ref m_Sprites; Ref m_SpriteSampler; std::unordered_map, SResourceHandle> m_SpriteTextures; + std::unordered_set m_MaterialShaderCache; SResourceHandle m_WhiteTextureHandle{}; diff --git a/Elixir/Source/Engine/Material/MaterialCompiler.cpp b/Elixir/Source/Engine/Material/MaterialCompiler.cpp index 8790a919..23006550 100644 --- a/Elixir/Source/Engine/Material/MaterialCompiler.cpp +++ b/Elixir/Source/Engine/Material/MaterialCompiler.cpp @@ -5,6 +5,9 @@ namespace Elixir { namespace fs = std::filesystem; + static const fs::path s_ShadersDir = "./Shaders"; + static const fs::path s_GeneratedDir = s_ShadersDir / "Generated"; + namespace { fs::path FindDXC() @@ -34,7 +37,7 @@ namespace Elixir std::string ValueExpression(const SCompiledMaterialParameter& parameter) { - const std::string value = "mat.Values[" + std::to_string(parameter.Slot) + "]"; + std::string value = "mat.Values[" + std::to_string(parameter.Slot) + "]"; switch (parameter.ValueType) { @@ -46,6 +49,29 @@ namespace Elixir 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); + } } SMaterialCompileResult MaterialCompiler::Build(const Material& material) @@ -97,10 +123,33 @@ namespace Elixir auto result = Build(material); if (!result) return result; - const fs::path shadersDir = "./Shaders"; - const fs::path generatedDir = shadersDir / "Generated"; + if (material.SupportsUsage(EMaterialUsage::ParticleSprite)) + return CompileParticleSprite(loader, material, std::move(result)); - const auto hlsl = ReadFile(shadersDir / "Material" / "Material.ps.hlsl"); + return CompileSurface(loader, material, std::move(result)); + } + + std::string MaterialCompiler::InjectBody( + const std::string& hlsl, + const std::string& graphBody + ) + { + std::string out = hlsl; + + constexpr std::string marker = "// __GRAPH_BODY__"; + if (const auto pos = out.find(marker); pos != std::string::npos) + out.replace(pos, marker.size(), graphBody); + + return out; + } + + SMaterialCompileResult MaterialCompiler::CompileSurface( + const ShaderLoader* loader, + const Material& material, + SMaterialCompileResult result + ) + { + const auto hlsl = ReadFile(s_ShadersDir / "Material" / "Material.ps.hlsl"); if (hlsl.empty()) { @@ -117,29 +166,15 @@ namespace Elixir // 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 = generatedDir / name; + const fs::path loadDir = s_GeneratedDir / name; std::error_code error; fs::create_directories(loadDir, error); - const fs::path hlslPath = generatedDir / (name + ".src.ps.hlsl"); + const fs::path hlslPath = s_GeneratedDir / (name + ".src.ps.hlsl"); { std::ofstream out(hlslPath, std::ios::binary); - SMaterialGraphBindings bindings; - for (const auto& parameter : result.Material->Parameters) - { - const auto expr = parameter.Kind == EMaterialParameterKind::Texture - ? "mat.TextureIndices[" + std::to_string(parameter.Slot) + "]" - : ValueExpression(parameter); - - auto& binding = parameter.Kind == EMaterialParameterKind::Texture - ? bindings.Textures - : bindings.Values; - - binding[parameter.Name] = expr; - } - - const auto graphHlsl = material.GetGraph().GenerateHLSL(bindings); + const auto graphHlsl = GenerateGraphHLSL(material.GetGraph(), *result.Material); out << InjectBody(hlsl, graphHlsl); } @@ -159,8 +194,8 @@ namespace Elixir return result; } - result.Material->Shader = loader->LoadShader(loadDir, name); - if (!result.Material->Shader) + result.Material->SurfaceShader = loader->LoadShader(loadDir, name); + if (!result.Material->SurfaceShader) { result.Diagnostics = "Shader loader could not load the compiled material."; result.Material.reset(); @@ -169,17 +204,81 @@ namespace Elixir return result; } - std::string MaterialCompiler::InjectBody( - const std::string& hlsl, - const std::string& graphBody + SMaterialCompileResult MaterialCompiler::CompileParticleSprite( + const ShaderLoader* loader, + const Material& material, + SMaterialCompileResult result ) { - std::string out = hlsl; + const auto hlsl = ReadFile(s_ShadersDir / "Material" / "ParticleSprite.ps.hlsl"); - constexpr std::string marker = "// __GRAPH_BODY__"; - if (const auto pos = out.find(marker); pos != std::string::npos) - out.replace(pos, marker.size(), graphBody); + if (hlsl.empty()) + { + result.Diagnostics = "Material template ParticleSprite.ps.hlsl was not found."; + result.Material.reset(); + return result; + } - return out; + // 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; } } diff --git a/Elixir/Source/Engine/Material/MaterialCompiler.h b/Elixir/Source/Engine/Material/MaterialCompiler.h index d0bd1e19..f02e72ad 100644 --- a/Elixir/Source/Engine/Material/MaterialCompiler.h +++ b/Elixir/Source/Engine/Material/MaterialCompiler.h @@ -17,13 +17,21 @@ namespace Elixir { uint32_t MaterialRevision = 0; uint32_t UsageMask = 0; - Ref Shader; + Ref SurfaceShader; + Ref ParticleSpriteShader; std::vector Parameters; bool SupportsUsage(const EMaterialUsage usage) const { return (UsageMask & GetMaterialUsageMask(usage)) != 0; } + + const Ref& GetShader(const EMaterialUsage usage) const + { + return usage == EMaterialUsage::ParticleSprite + ? ParticleSpriteShader + : SurfaceShader; + } }; struct SMaterialCompileResult @@ -48,5 +56,17 @@ namespace Elixir private: static std::string InjectBody(const std::string& hlsl, const std::string& graphBody); + + static SMaterialCompileResult CompileSurface( + const ShaderLoader* loader, + const Material& material, + SMaterialCompileResult result + ); + + static SMaterialCompileResult CompileParticleSprite( + const ShaderLoader* loader, + const Material& material, + SMaterialCompileResult result + ); }; } diff --git a/Elixir/Source/Engine/Material/MaterialRenderProxy.h b/Elixir/Source/Engine/Material/MaterialRenderProxy.h index 9619e741..184b9ef0 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderProxy.h +++ b/Elixir/Source/Engine/Material/MaterialRenderProxy.h @@ -13,7 +13,7 @@ namespace Elixir const MaterialInstance& instance ); - const Ref CompiledMaterial() const { return m_CompiledMaterial; } + const Ref GetCompiledMaterial() const { return m_CompiledMaterial; } uint32_t GetInstanceRevision() const { return m_InstanceRevision; } const std::vector& GetValues() const { return m_Values; } const std::vector>& GetTextures() const { return m_Textures; } diff --git a/Elixir/Tests/Engine/Aether/AetherParticleMaterialTableTest.cpp b/Elixir/Tests/Engine/Aether/AetherParticleMaterialTableTest.cpp new file mode 100644 index 00000000..f192a5ce --- /dev/null +++ b/Elixir/Tests/Engine/Aether/AetherParticleMaterialTableTest.cpp @@ -0,0 +1,55 @@ +#include + +#include +#include + +using namespace Elixir; +using namespace Elixir::Aether; + +TEST(AetherParticleMaterialTableTest, DeduplicatesAProxyAndPreserveItsValues) +{ + auto material = CreateRef("Particle material"); + ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleSprite, true)); + ASSERT_TRUE(material->DefineParameter("Tint", { + .Kind = EMaterialParameterKind::Value, + .ValueType = EMaterialGraphValueType::Float4, + .DefaultValue = SMaterialParam::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 = MaterialCompiler::Build(*material); + ASSERT_TRUE(compiled); + + const auto proxy = instance->CreateRenderProxy(compiled.Material); + ASSERT_TRUE(proxy); + + ParticleMaterialTable table{ 1 }; + 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], UINT32_MAX); +} + +TEST(AetherParticleMaterialTableTest, RejectsAUniqueProxyPastCapacity) +{ + ParticleMaterialTable table{ 0 }; + + auto material = CreateRef("Particle material"); + auto instance = CreateRef(material); + const auto compiled = MaterialCompiler::Build(*material); + ASSERT_TRUE(compiled); + + const auto proxy = instance->CreateRenderProxy(compiled.Material); + ASSERT_TRUE(proxy); + EXPECT_FALSE(table.Add(*proxy)); +} \ No newline at end of file diff --git a/Elixir/Tests/Engine/Aether/SystemTest.cpp b/Elixir/Tests/Engine/Aether/SystemTest.cpp index 40752549..1cbeeb2e 100644 --- a/Elixir/Tests/Engine/Aether/SystemTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemTest.cpp @@ -2,6 +2,7 @@ #include #include +#include using namespace Elixir; using namespace Elixir::Aether; @@ -97,22 +98,33 @@ TEST(AetherSystemTest, CompileSnapshotsParticleSpriteMaterialForRenderData) System system{ "Material snapshot contract" }; auto& emitter = system.AddEmitter("Smoke", 8, 0.0f); - emitter.SetMaterial(instance); + const auto compiledMaterial = MaterialCompiler::Build(*material); + ASSERT_TRUE(compiledMaterial); + + const auto firstProxy = instance->CreateRenderProxy(compiledMaterial.Material); + ASSERT_TRUE(firstProxy); + + emitter.SetMaterial(firstProxy); const auto first = system.Compile(); ASSERT_EQ(first.Emitters.size(), 1); ASSERT_TRUE(first.Emitters[0].Material); - EXPECT_TRUE(first.Emitters[0].Material->CompiledMaterial()->SupportsUsage( + EXPECT_TRUE(first.Emitters[0].Material->GetCompiledMaterial()->SupportsUsage( EMaterialUsage::ParticleSprite )); EXPECT_EQ(first.Emitters[0].Material->GetInstanceRevision(), instance->GetRevision()); EXPECT_FLOAT_EQ(first.Emitters[0].Material->GetValues()[0].x, 0.25f); ASSERT_TRUE(instance->SetVector("Tint", { 0.75f, 0.5f, 0.25f, 1.0f })); + + const auto secondProxy = instance->CreateRenderProxy(compiledMaterial.Material); + ASSERT_TRUE(secondProxy); + + emitter.SetMaterial(secondProxy); const auto second = system.Compile(); ASSERT_TRUE(second.Emitters[0].Material); EXPECT_FLOAT_EQ(first.Emitters[0].Material->GetValues()[0].x, 0.25f); EXPECT_FLOAT_EQ(second.Emitters[0].Material->GetValues()[0].x, 0.75f); -} \ 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..135724a7 --- /dev/null +++ b/Shaders/Material/ParticleSprite.ps.hlsl @@ -0,0 +1,78 @@ +// 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 SpriteIndex; + 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; + float3 Emissive; +}; + +float3 SampleTex(uint index, float2 uv) +{ + return sprites[index].Sample(spriteSampler, uv).rgb; +} + +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.Emissive = float3(0.0f, 0.0f, 0.0f); + + // __GRAPH_BODY__ + + const float4 sprite = sprites[pc.SpriteIndex].Sample(spriteSampler, input.TexCoord); + const float3 color = input.Color.rgb * sprite.rgb * surface.BaseColor + surface.Emissive; + const float alpha = input.Color.a * sprite.a; + + return float4(color, alpha); +} \ No newline at end of file diff --git a/Shaders/Shaders.cmake b/Shaders/Shaders.cmake index 407054f3..4697f713 100644 --- a/Shaders/Shaders.cmake +++ b/Shaders/Shaders.cmake @@ -201,11 +201,11 @@ function(copy_shaders_for_targets) COMMAND "${CMAKE_COMMAND}" -E copy_directory "${SHADER_STAGING_DIR}" "${TARGET_SHADER_DIR}" - # The node-graph material template is compiled at runtime, so its HLSL - # source must be available next to the compiled shaders. - COMMAND "${CMAKE_COMMAND}" -E copy - "${SHADER_SOURCE_DIR}/Material/Material.ps.hlsl" - "${TARGET_SHADER_DIR}/Material/Material.ps.hlsl" + # 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} From 174d7ee2690b3b0dec3f4f0f61bff8f98a235efb Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Thu, 30 Jul 2026 09:55:13 -0300 Subject: [PATCH 12/89] refactor(aether): publish particle material textures safely Resolve immutable material texture slots through the bindless set before command recording. New texture registrations use the white fallback until the next frame publishes their descriptors, avoiding descriptor mutations during particle draws. --- .../Engine/Aether/ParticleMaterialTable.cpp | 15 +- .../Engine/Aether/ParticleMaterialTable.h | 16 ++- Elixir/Source/Engine/Aether/Renderer.cpp | 41 ++++-- Elixir/Source/Engine/Aether/Renderer.h | 14 +- .../AetherParticleMaterialTableTest.cpp | 55 ------- .../Aether/ParticleMaterialTableTest.cpp | 135 ++++++++++++++++++ 6 files changed, 205 insertions(+), 71 deletions(-) delete mode 100644 Elixir/Tests/Engine/Aether/AetherParticleMaterialTableTest.cpp create mode 100644 Elixir/Tests/Engine/Aether/ParticleMaterialTableTest.cpp diff --git a/Elixir/Source/Engine/Aether/ParticleMaterialTable.cpp b/Elixir/Source/Engine/Aether/ParticleMaterialTable.cpp index ea9c0400..32f3fc74 100644 --- a/Elixir/Source/Engine/Aether/ParticleMaterialTable.cpp +++ b/Elixir/Source/Engine/Aether/ParticleMaterialTable.cpp @@ -18,10 +18,12 @@ namespace Elixir::Aether return index; } - SParticleMaterialData ParticleMaterialTable::BuildData(const MaterialRenderProxy& material) + SParticleMaterialData ParticleMaterialTable::BuildData( + const MaterialRenderProxy& material + ) const { SParticleMaterialData data{}; - std::ranges::fill(data.TextureIndices, UINT32_MAX); + std::ranges::fill(data.TextureIndices, m_FallbackTextureIndex); const auto& values = material.GetValues(); std::copy_n( @@ -30,6 +32,15 @@ namespace Elixir::Aether 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/Aether/ParticleMaterialTable.h b/Elixir/Source/Engine/Aether/ParticleMaterialTable.h index 14ecae53..d9500b74 100644 --- a/Elixir/Source/Engine/Aether/ParticleMaterialTable.h +++ b/Elixir/Source/Engine/Aether/ParticleMaterialTable.h @@ -2,6 +2,8 @@ #include +#include + namespace Elixir::Aether { // ABI shared with Shaders/Material/ParticleSprite.ps.hlsl. @@ -16,7 +18,15 @@ namespace Elixir::Aether class ELIXIR_API ParticleMaterialTable final { public: - explicit ParticleMaterialTable(const uint32_t capacity) : m_Capacity(capacity) {} + using TextureIndexResolver = std::function&)>; + + ParticleMaterialTable( + const uint32_t capacity, + uint32_t fallbackTextureIndex, + TextureIndexResolver resolver + ) : m_Capacity(capacity), + m_FallbackTextureIndex(fallbackTextureIndex), + m_TextureIndexResolver(std::move(resolver)) {} // Returns the stable index for this render submission, or nullopt when // the per-frame capacity is exhausted. @@ -26,9 +36,11 @@ namespace Elixir::Aether uint32_t GetCount() const { return static_cast(m_Data.size()); } private: - static SParticleMaterialData BuildData(const MaterialRenderProxy& material); + SParticleMaterialData 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; }; diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index 7009cadf..072b9456 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -237,7 +237,14 @@ namespace Elixir::Aether if (submittedInstances.empty()) return; - ParticleMaterialTable materials{ m_ParticlePoolLimits.MaterialCapacity }; + ParticleMaterialTable materials( + m_ParticlePoolLimits.MaterialCapacity, + m_WhiteTextureHandle.Index, + [this](const Ref& texture) + { + return ResolveTextureIndex(texture); + } + ); const auto simulationBatches = BuildSimulationBatches(submittedInstances); const auto renderBatches = BuildRenderBatches(submittedInstances, materials); @@ -855,18 +862,29 @@ namespace Elixir::Aether runtime.MeshShader->BindConstantBuffer("cbFrame", m_FrameConstantBuffer); } - uint32_t Renderer::ResolveSpriteIndex(const Ref& texture) + uint32_t Renderer::ResolveTextureIndex(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 binding = m_TextureBindings.find(texture); + if (binding != m_TextureBindings.end()) + { + if (binding->second.ReadySubmission <= m_SubmissionSerial) + return binding->second.Handle.Index; + return m_WhiteTextureHandle.Index; + } + + // RegisterTexture only marks the bindless slot dirty. Vulkan flushes it + // in GraphicsContext::Prepare() before the next render callback. const auto handle = m_Sprites->AddTexture(texture); - m_SpriteTextures[texture] = handle; + m_TextureBindings.emplace(texture, STextureBinding{ + .Handle = handle, + .ReadySubmission = m_SubmissionSerial + 1, + }); - return handle.Index; + return m_WhiteTextureHandle.Index; } void Renderer::PrepareParticleSpriteMaterialShader(const Ref& shader) @@ -1175,7 +1193,7 @@ namespace Elixir::Aether std::vector Renderer::BuildRenderBatches( const std::vector& instances, ParticleMaterialTable& materials - ) const + ) { std::vector batches; @@ -1193,6 +1211,10 @@ namespace Elixir::Aether const MaterialRenderProxy* material = emitter.Material.get(); const Shader* materialShader = nullptr; uint32_t materialIndex = UINT32_MAX; + uint32_t spriteIndex = m_WhiteTextureHandle.Index; + + if (emitter.RenderMode == EParticleRenderMode::Sprite) + spriteIndex = ResolveTextureIndex(emitter.SpriteTexture); if (material && emitter.RenderMode == EParticleRenderMode::Sprite) { @@ -1246,6 +1268,7 @@ namespace Elixir::Aether .Emitter = &emitter, .Material = material, .MaterialIndex = materialIndex, + .SpriteIndex = spriteIndex, .LocalEmitterIndex = emitterIndex, }); } @@ -1488,7 +1511,7 @@ namespace Elixir::Aether { const SMaterialPushConstants pc{ .WorldTransform = worldTransform, - .SpriteIndex = ResolveSpriteIndex(item.Emitter->SpriteTexture), + .SpriteIndex = item.SpriteIndex, .MaterialIndex = item.MaterialIndex, }; shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); @@ -1497,7 +1520,7 @@ namespace Elixir::Aether { const SSpritePushConstants pc{ .WorldTransform = worldTransform, - .SpriteIndex = ResolveSpriteIndex(item.Emitter->SpriteTexture), + .SpriteIndex = item.SpriteIndex, }; shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); } diff --git a/Elixir/Source/Engine/Aether/Renderer.h b/Elixir/Source/Engine/Aether/Renderer.h index d0a149f7..b3acb53d 100644 --- a/Elixir/Source/Engine/Aether/Renderer.h +++ b/Elixir/Source/Engine/Aether/Renderer.h @@ -200,7 +200,7 @@ namespace Elixir::Aether void BindShaderParameters(); void BindParticleStateLayoutShaderParameters(const SParticleStateLayoutRuntime& runtime) const; - uint32_t ResolveSpriteIndex(const Ref& texture); + uint32_t ResolveTextureIndex(const Ref& texture); void PrepareParticleSpriteMaterialShader(const Ref& shader); Ref GetParticleSpritePipeline( SParticleStateLayoutRuntime& runtime, @@ -250,6 +250,7 @@ namespace Elixir::Aether const SCompiledEmitter* Emitter = nullptr; const MaterialRenderProxy* Material = nullptr; uint32_t MaterialIndex = UINT32_MAX; + uint32_t SpriteIndex = UINT32_MAX; uint32_t LocalEmitterIndex = 0; }; @@ -286,7 +287,7 @@ namespace Elixir::Aether BuildRenderBatches( const std::vector& instances, ParticleMaterialTable& materials - ) const; + ); void SimulateBatch( const Ref& cmd, @@ -364,9 +365,16 @@ namespace Elixir::Aether Ref m_Sprites; Ref m_SpriteSampler; - std::unordered_map, SResourceHandle> m_SpriteTextures; std::unordered_set m_MaterialShaderCache; + struct STextureBinding + { + SResourceHandle Handle; + uint64_t ReadySubmission = 0; + }; + + std::unordered_map, STextureBinding> m_TextureBindings; + SResourceHandle m_WhiteTextureHandle{}; uint32_t m_MeshVertexCount = 0; diff --git a/Elixir/Tests/Engine/Aether/AetherParticleMaterialTableTest.cpp b/Elixir/Tests/Engine/Aether/AetherParticleMaterialTableTest.cpp deleted file mode 100644 index f192a5ce..00000000 --- a/Elixir/Tests/Engine/Aether/AetherParticleMaterialTableTest.cpp +++ /dev/null @@ -1,55 +0,0 @@ -#include - -#include -#include - -using namespace Elixir; -using namespace Elixir::Aether; - -TEST(AetherParticleMaterialTableTest, DeduplicatesAProxyAndPreserveItsValues) -{ - auto material = CreateRef("Particle material"); - ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleSprite, true)); - ASSERT_TRUE(material->DefineParameter("Tint", { - .Kind = EMaterialParameterKind::Value, - .ValueType = EMaterialGraphValueType::Float4, - .DefaultValue = SMaterialParam::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 = MaterialCompiler::Build(*material); - ASSERT_TRUE(compiled); - - const auto proxy = instance->CreateRenderProxy(compiled.Material); - ASSERT_TRUE(proxy); - - ParticleMaterialTable table{ 1 }; - 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], UINT32_MAX); -} - -TEST(AetherParticleMaterialTableTest, RejectsAUniqueProxyPastCapacity) -{ - ParticleMaterialTable table{ 0 }; - - auto material = CreateRef("Particle material"); - auto instance = CreateRef(material); - const auto compiled = MaterialCompiler::Build(*material); - ASSERT_TRUE(compiled); - - const auto proxy = instance->CreateRenderProxy(compiled.Material); - ASSERT_TRUE(proxy); - EXPECT_FALSE(table.Add(*proxy)); -} \ No newline at end of file diff --git a/Elixir/Tests/Engine/Aether/ParticleMaterialTableTest.cpp b/Elixir/Tests/Engine/Aether/ParticleMaterialTableTest.cpp new file mode 100644 index 00000000..92904342 --- /dev/null +++ b/Elixir/Tests/Engine/Aether/ParticleMaterialTableTest.cpp @@ -0,0 +1,135 @@ +#include + +#include +#include +#include + +using namespace Elixir; +using namespace Elixir::Aether; + +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(ParticleMaterialTableTest, DeduplicatesAProxyAndPreserveItsValues) +{ + auto material = CreateRef("Particle material"); + ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleSprite, true)); + ASSERT_TRUE(material->DefineParameter("Tint", { + .Kind = EMaterialParameterKind::Value, + .ValueType = EMaterialGraphValueType::Float4, + .DefaultValue = SMaterialParam::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 = MaterialCompiler::Build(*material); + ASSERT_TRUE(compiled); + + const auto proxy = instance->CreateRenderProxy(compiled.Material); + ASSERT_TRUE(proxy); + + ParticleMaterialTable 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(ParticleMaterialTableTest, RejectsAUniqueProxyPastCapacity) +{ + ParticleMaterialTable table( + 0, + 0, + [](const Ref&) { return 0; } + ); + + auto material = CreateRef("Particle material"); + auto instance = CreateRef(material); + const auto compiled = MaterialCompiler::Build(*material); + ASSERT_TRUE(compiled); + + const auto proxy = instance->CreateRenderProxy(compiled.Material); + ASSERT_TRUE(proxy); + EXPECT_FALSE(table.Add(*proxy)); +} + +TEST(ParticleMaterialTableTest, ResolvesAuthoredTextureSlots) +{ + const auto texture = CreateRef(); + + auto material = CreateRef("Particle material"); + ASSERT_TRUE(material->DefineParameter("Albedo", { + .Kind = EMaterialParameterKind::Texture, + .DefaultValue = SMaterialParam::MakeTexture(texture), + })); + + auto instance = CreateRef(material); + const auto compiled = MaterialCompiler::Build(*material); + ASSERT_TRUE(compiled); + + const auto proxy = instance->CreateRenderProxy(compiled.Material); + ASSERT_TRUE(proxy); + + uint32_t resolveCount = 0; + ParticleMaterialTable 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); +} \ No newline at end of file From 7241d9c46497f439f2eeba128c06c5ead8a52047 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Thu, 30 Jul 2026 15:54:53 -0300 Subject: [PATCH 13/89] feat(aether): publish graph materials to sprite emitters Apply the Dissolve graph material through an immutable render proxy before compiling the particle system. Unify the sprite push-constant contract at 72 bytes across the vertex shader and both pixel shader paths. --- Dissolve/Source/Dissolve.cpp | 77 +++++++++++++++++++---- Elixir/Source/Engine/Aether/Renderer.cpp | 5 +- Elixir/Source/Engine/Aether/System.cpp | 11 ++++ Elixir/Source/Engine/Aether/System.h | 2 + Elixir/Tests/Engine/Aether/SystemTest.cpp | 11 ++++ Shaders/Aether/Sprite.ps.hlsl | 1 + Shaders/Aether/Sprite.vs.hlsl | 1 + Shaders/Material/ParticleSprite.ps.hlsl | 2 +- 8 files changed, 94 insertions(+), 16 deletions(-) diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index 5204a817..ce9d817c 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -6,6 +6,7 @@ #include #include +#include #include Ref pipeline; @@ -67,25 +68,46 @@ Dissolve::Dissolve() m_ParticleSystems[0] = Aether::LoadEffectFile("./Assets/VFX/FireAndFireworks.json"); m_ParticleSystems[1] = Aether::LoadEffectFile("./Assets/VFX/RibbonVortex.json"); - m_ParticleSystemInstances[0] = CreateScope(CreateRef(m_ParticleSystems[0]->Compile())); - m_ParticleSystemInstances[1] = CreateScope(CreateRef(m_ParticleSystems[1]->Compile())); - { 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 = EMaterialGraphValueType::Float4, + .DefaultValue = SMaterialParam::MakeVector({ 1.0f, 0.5f, 0.2f, 1.0f }), + }), "") + + EE_CORE_ASSERT(graphMaterial->DefineParameter("Albedo", { + .Kind = EMaterialParameterKind::Texture, + .DefaultValue = SMaterialParam::MakeTexture(tex), + }), "") + + SMaterialNode albedo; + albedo.Type = EMaterialNodeType::TextureSample; + albedo.TextureParameterName = "Albedo"; + const auto albedoNode = graph.AddNode(albedo); + + SMaterialNode tint; + tint.Type = EMaterialNodeType::Parameter; + tint.OutputType = EMaterialGraphValueType::Float4; + tint.ParameterName = "Tint"; + const auto tintNode = graph.AddNode(tint); + SMaterialNode baseColor; - baseColor.Type = EMaterialNodeType::Constant; + baseColor.Type = EMaterialNodeType::Multiply; baseColor.OutputType = EMaterialGraphValueType::Float4; - baseColor.ConstantValue = { 0.9f, 0.3f, 0.1f, 1.0f }; + baseColor.Inputs = { + (int32_t)albedoNode, + (int32_t)tintNode, + }; graph.SetChannel(EMaterialChannel::BaseColor, graph.AddNode(baseColor)); - SMaterialNode metallic; - metallic.Type = EMaterialNodeType::Constant; - metallic.OutputType = EMaterialGraphValueType::Float; - metallic.ConstantValue = { 0.9f, 0.0f, 0.0f, 0.0f }; - graph.SetChannel(EMaterialChannel::Metallic, graph.AddNode(metallic)); - - graphMaterial = CreateRef("DissolveGraph"); graphMaterial->SetGraph(std::move(graph)); const auto result = MaterialCompiler::Compile(m_ShaderLoader.get(), *graphMaterial); @@ -93,12 +115,41 @@ Dissolve::Dissolve() if (result) { compiledGraphMaterial = result.Material; - EE_CORE_INFO("Node-graph material compiled and loaded successfully.") + + auto instance = CreateRef(graphMaterial); + EE_CORE_ASSERT( + instance->SetVector("Tint", { 1.0f, 0.35f, 0.1f, 1.0f }), + "Dissolve graph material tint override must match its schema." + ) + + const auto proxy = instance->CreateRenderProxy(compiledGraphMaterial); + EE_CORE_ASSERT( + proxy, + "Dissolve graph material render proxy must match the compiled schema." + ) + + if (auto* emitter = m_ParticleSystems[0]->FindEmitter("FlameCore")) + { + emitter->SetMaterial(proxy); + EE_CORE_INFO("Published graph material to the FlameCore particle emitter.") + } + else + { + EE_CORE_ERROR("Dissolve particle emitter 'FlameCore' was not found.") + } } else EE_CORE_ERROR("Node-graph material compilation failed: {}", result.Diagnostics) } + m_ParticleSystemInstances[0] = CreateScope( + CreateRef(m_ParticleSystems[0]->Compile()) + ); + + m_ParticleSystemInstances[1] = CreateScope( + CreateRef(m_ParticleSystems[1]->Compile()) + ); + m_GraphicsContext->SetClearColor({ 0.015f, 0.025f, 0.06f, 1.0f }); } diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index 072b9456..8c53aea0 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -17,6 +17,7 @@ namespace Elixir::Aether struct SSpritePushConstants { glm::mat4 WorldTransform{ 1.0f }; + uint32_t MaterialIndex = UINT32_MAX; uint32_t SpriteIndex = 0; }; @@ -35,8 +36,8 @@ namespace Elixir::Aether struct SMaterialPushConstants { glm::mat4 WorldTransform{ 1.0f }; - uint32_t SpriteIndex = 0; uint32_t MaterialIndex = UINT32_MAX; + uint32_t SpriteIndex = 0; }; SEmitterData ToEmitterDescription( @@ -1511,8 +1512,8 @@ namespace Elixir::Aether { const SMaterialPushConstants pc{ .WorldTransform = worldTransform, - .SpriteIndex = item.SpriteIndex, .MaterialIndex = item.MaterialIndex, + .SpriteIndex = item.SpriteIndex, }; shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); } diff --git a/Elixir/Source/Engine/Aether/System.cpp b/Elixir/Source/Engine/Aether/System.cpp index 4d94972e..37bd68d7 100644 --- a/Elixir/Source/Engine/Aether/System.cpp +++ b/Elixir/Source/Engine/Aether/System.cpp @@ -15,6 +15,17 @@ 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; + } + SCompiledSystem System::Compile() const { SCompiledSystem system; diff --git a/Elixir/Source/Engine/Aether/System.h b/Elixir/Source/Engine/Aether/System.h index 19da8e8e..ef86a6bc 100644 --- a/Elixir/Source/Engine/Aether/System.h +++ b/Elixir/Source/Engine/Aether/System.h @@ -56,6 +56,8 @@ namespace Elixir::Aether Emitter& AddEmitter(const std::string& name, uint32_t maxParticles, float spawnRate); + Emitter* FindEmitter(std::string_view name) const; + SCompiledSystem Compile() const; ParameterStore& GetParameters() { return m_Parameters; } diff --git a/Elixir/Tests/Engine/Aether/SystemTest.cpp b/Elixir/Tests/Engine/Aether/SystemTest.cpp index 1cbeeb2e..5e630478 100644 --- a/Elixir/Tests/Engine/Aether/SystemTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemTest.cpp @@ -83,6 +83,17 @@ TEST(AetherSystemTest, CompileExposesOnlyAuthoredParameters) EXPECT_EQ(compiled.Parameters[3].Name, "SizeOverLife:1"); } +TEST(AetherSystemTest, 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(AetherSystemTest, CompileSnapshotsParticleSpriteMaterialForRenderData) { const auto material = CreateRef("Particle tint"); 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/ParticleSprite.ps.hlsl b/Shaders/Material/ParticleSprite.ps.hlsl index 135724a7..febab53b 100644 --- a/Shaders/Material/ParticleSprite.ps.hlsl +++ b/Shaders/Material/ParticleSprite.ps.hlsl @@ -29,8 +29,8 @@ StructuredBuffer materials; struct MaterialPushConstants { float4x4 WorldTransform; - uint SpriteIndex; uint MaterialIndex; + uint SpriteIndex; }; [[vk::push_constant]] From 17f13cb18d3fd84e7ac37bb86eda58566629805f Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Thu, 30 Jul 2026 16:46:08 -0300 Subject: [PATCH 14/89] refactor(aether): remove redundant functional include --- Elixir/Source/Engine/Aether/ParticleMaterialTable.h | 2 -- 1 file changed, 2 deletions(-) diff --git a/Elixir/Source/Engine/Aether/ParticleMaterialTable.h b/Elixir/Source/Engine/Aether/ParticleMaterialTable.h index d9500b74..0aa813dc 100644 --- a/Elixir/Source/Engine/Aether/ParticleMaterialTable.h +++ b/Elixir/Source/Engine/Aether/ParticleMaterialTable.h @@ -2,8 +2,6 @@ #include -#include - namespace Elixir::Aether { // ABI shared with Shaders/Material/ParticleSprite.ps.hlsl. From 00e04964b616e8c2ab9fdaa7f32c7810e1816b70 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Thu, 30 Jul 2026 16:52:42 -0300 Subject: [PATCH 15/89] refactor(material): compile renderer permutations independently Compile the Surface permutation before any enabled particle permutation.\n\nReturn no shader for unsupported particle usages instead of falling back to Surface. --- .../Source/Engine/Material/MaterialCompiler.cpp | 10 ++++++++-- .../Source/Engine/Material/MaterialCompiler.h | 17 ++++++++++++++--- .../Engine/Material/MaterialCompilerTest.cpp | 13 +++++++++++++ 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/Elixir/Source/Engine/Material/MaterialCompiler.cpp b/Elixir/Source/Engine/Material/MaterialCompiler.cpp index 23006550..5b6a6b28 100644 --- a/Elixir/Source/Engine/Material/MaterialCompiler.cpp +++ b/Elixir/Source/Engine/Material/MaterialCompiler.cpp @@ -123,10 +123,16 @@ namespace Elixir auto result = Build(material); if (!result) return result; + result = CompileSurface(loader, material, std::move(result)); + if (!result) return result; + if (material.SupportsUsage(EMaterialUsage::ParticleSprite)) - return CompileParticleSprite(loader, material, std::move(result)); + { + result = CompileParticleSprite(loader, material, std::move(result)); + if (!result) return result; + } - return CompileSurface(loader, material, std::move(result)); + return result; } std::string MaterialCompiler::InjectBody( diff --git a/Elixir/Source/Engine/Material/MaterialCompiler.h b/Elixir/Source/Engine/Material/MaterialCompiler.h index f02e72ad..f6bcd176 100644 --- a/Elixir/Source/Engine/Material/MaterialCompiler.h +++ b/Elixir/Source/Engine/Material/MaterialCompiler.h @@ -26,11 +26,22 @@ namespace Elixir return (UsageMask & GetMaterialUsageMask(usage)) != 0; } + // A Surface shader is not a fallback particle permutation. Until a + // renderer-specific permutation exists, Ribbon and Mesh resolve to + // no shader and their renderer can keep its existing fallback path. const Ref& GetShader(const EMaterialUsage usage) const { - return usage == EMaterialUsage::ParticleSprite - ? ParticleSpriteShader - : SurfaceShader; + switch (usage) + { + case EMaterialUsage::ParticleSprite: + return ParticleSpriteShader; + case EMaterialUsage::ParticleRibbon: + case EMaterialUsage::ParticleMesh: + break; + } + + static const Ref unsupportedUsageShader; + return unsupportedUsageShader; } }; diff --git a/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp b/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp index a61c2ed0..e896a9f6 100644 --- a/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp @@ -41,4 +41,17 @@ TEST(MaterialCompilerTest, PreservesEnabledRendererUsages) EXPECT_TRUE(result.Material->SupportsUsage(EMaterialUsage::ParticleSprite)); EXPECT_FALSE(result.Material->SupportsUsage(EMaterialUsage::ParticleRibbon)); EXPECT_FALSE(result.Material->SupportsUsage(EMaterialUsage::ParticleMesh)); +} + +TEST(MaterialCompilerTest, DoesNotAliasUnsupportedParticleUsagesToSurfaceShader) +{ + SCompiledMaterial material; + + const auto& ribbonShader = material.GetShader(EMaterialUsage::ParticleRibbon); + const auto& meshShader = material.GetShader(EMaterialUsage::ParticleMesh); + + EXPECT_FALSE(ribbonShader); + EXPECT_FALSE(meshShader); + EXPECT_NE(&ribbonShader, &material.SurfaceShader); + EXPECT_NE(&meshShader, &material.SurfaceShader); } \ No newline at end of file From 61964c0ba3522ca15e8795cc51885abcefb9950e Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Thu, 30 Jul 2026 20:20:55 -0300 Subject: [PATCH 16/89] feat(material): compile particle ribbon permutations Generate Ribbon vertex and fragment shader permutations for enabled materials.\n\nKeep the material push-constant ABI aligned across both stages. --- .../Engine/Material/MaterialCompiler.cpp | 91 +++++++++++++++++++ .../Source/Engine/Material/MaterialCompiler.h | 12 ++- .../Engine/Material/MaterialCompilerTest.cpp | 6 +- Shaders/Aether/Ribbon.vs.hlsl | 3 + Shaders/Material/ParticleRibbon.ps.hlsl | 84 +++++++++++++++++ Shaders/Material/ParticleRibbon.vs.hlsl | 5 + 6 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 Shaders/Material/ParticleRibbon.ps.hlsl create mode 100644 Shaders/Material/ParticleRibbon.vs.hlsl diff --git a/Elixir/Source/Engine/Material/MaterialCompiler.cpp b/Elixir/Source/Engine/Material/MaterialCompiler.cpp index 5b6a6b28..8daa8a1d 100644 --- a/Elixir/Source/Engine/Material/MaterialCompiler.cpp +++ b/Elixir/Source/Engine/Material/MaterialCompiler.cpp @@ -132,6 +132,12 @@ namespace Elixir if (!result) return result; } + if (material.SupportsUsage(EMaterialUsage::ParticleRibbon)) + { + result = CompileParticleRibbon(loader, material, std::move(result)); + if (!result) return result; + } + return result; } @@ -287,4 +293,89 @@ namespace Elixir return result; } + + SMaterialCompileResult MaterialCompiler::CompileParticleRibbon( + const ShaderLoader* loader, + const Material& material, + SMaterialCompileResult 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; + } } diff --git a/Elixir/Source/Engine/Material/MaterialCompiler.h b/Elixir/Source/Engine/Material/MaterialCompiler.h index f6bcd176..2f89ec3b 100644 --- a/Elixir/Source/Engine/Material/MaterialCompiler.h +++ b/Elixir/Source/Engine/Material/MaterialCompiler.h @@ -19,6 +19,7 @@ namespace Elixir uint32_t UsageMask = 0; Ref SurfaceShader; Ref ParticleSpriteShader; + Ref ParticleRibbonShader; std::vector Parameters; bool SupportsUsage(const EMaterialUsage usage) const @@ -26,9 +27,7 @@ namespace Elixir return (UsageMask & GetMaterialUsageMask(usage)) != 0; } - // A Surface shader is not a fallback particle permutation. Until a - // renderer-specific permutation exists, Ribbon and Mesh resolve to - // no shader and their renderer can keep its existing fallback path. + // A Surface shader is never a fallback particle permutation. const Ref& GetShader(const EMaterialUsage usage) const { switch (usage) @@ -36,6 +35,7 @@ namespace Elixir case EMaterialUsage::ParticleSprite: return ParticleSpriteShader; case EMaterialUsage::ParticleRibbon: + return ParticleRibbonShader; case EMaterialUsage::ParticleMesh: break; } @@ -79,5 +79,11 @@ namespace Elixir const Material& material, SMaterialCompileResult result ); + + static SMaterialCompileResult CompileParticleRibbon( + const ShaderLoader* loader, + const Material& material, + SMaterialCompileResult result + ); }; } diff --git a/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp b/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp index e896a9f6..e77c2c09 100644 --- a/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp @@ -34,12 +34,13 @@ TEST(MaterialCompilerTest, PreservesEnabledRendererUsages) { const auto material = CreateRef("Particle material"); ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleSprite, true)); + ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleRibbon, true)); const auto result = MaterialCompiler::Build(*material); ASSERT_TRUE(result); EXPECT_TRUE(result.Material->SupportsUsage(EMaterialUsage::ParticleSprite)); - EXPECT_FALSE(result.Material->SupportsUsage(EMaterialUsage::ParticleRibbon)); + EXPECT_TRUE(result.Material->SupportsUsage(EMaterialUsage::ParticleRibbon)); EXPECT_FALSE(result.Material->SupportsUsage(EMaterialUsage::ParticleMesh)); } @@ -47,9 +48,12 @@ TEST(MaterialCompilerTest, DoesNotAliasUnsupportedParticleUsagesToSurfaceShader) { 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_FALSE(ribbonShader); EXPECT_FALSE(meshShader); EXPECT_NE(&ribbonShader, &material.SurfaceShader); diff --git a/Shaders/Aether/Ribbon.vs.hlsl b/Shaders/Aether/Ribbon.vs.hlsl index ffdbda21..9b9b7e9d 100644 --- a/Shaders/Aether/Ribbon.vs.hlsl +++ b/Shaders/Aether/Ribbon.vs.hlsl @@ -37,6 +37,9 @@ struct PushConstants float4x4 WorldTransform; uint EmitterIndex; uint ParticleBaseOffset; +#if defined(MATERIAL_RIBBON) + uint MaterialIndex; +#endif }; [[vk::push_constant]] diff --git a/Shaders/Material/ParticleRibbon.ps.hlsl b/Shaders/Material/ParticleRibbon.ps.hlsl new file mode 100644 index 00000000..9ba06d86 --- /dev/null +++ b/Shaders/Material/ParticleRibbon.ps.hlsl @@ -0,0 +1,84 @@ +// 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(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 EmitterIndex; + uint ParticleBaseOffset; + uint MaterialIndex; +}; + +[[vk::push_constant]] +MaterialPushConstants pc; + +struct PSInput +{ + float4 ClipPos : SV_POSITION; + float4 Color : COLOR0; + float2 UV : TEXCOORD0; + nointerpolation float Valid : TEXCOORD1; +}; + +struct Surface +{ + float3 BaseColor; + float3 Normal; + float Metallic; + float Roughness; + float3 Emissive; +}; + +float3 SampleTex(uint index, float2 uv) +{ + return sprites[index].Sample(spriteSampler, uv).rgb; +} + +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.Emissive = float3(0.0f, 0.0f, 0.0f); + + // __GRAPH_BODY__ + + const float centeredAcrossRibbon = abs((input.UV.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); + + return float4(color, input.Color.a * edgeFade); +} \ 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..c9cb3a5e --- /dev/null +++ b/Shaders/Material/ParticleRibbon.vs.hlsl @@ -0,0 +1,5 @@ +// The material fragment stage needs MaterialIndex. This macro extends the +// shared Ribbon push-constant ABI, so both generated stages declare 76 bytes. +#define MATERIAL_RIBBON 1 + +#include "../Aether/Ribbon.vs.hlsl" \ No newline at end of file From 50dfac132ea9a2dedf2534c75fd5434b166c8a15 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Thu, 30 Jul 2026 22:23:56 -0300 Subject: [PATCH 17/89] feat(aether): render ribbon graph materials Publish Ribbon material proxies to the GPU material table and bind their generated shader pipelines.\n\nKeep Ribbon vertex resources isolated from fragment material bindings. --- Elixir/Source/Engine/Aether/Emitter.cpp | 27 ++-- Elixir/Source/Engine/Aether/Renderer.cpp | 150 ++++++++++++++++-- Elixir/Source/Engine/Aether/Renderer.h | 9 ++ Elixir/Tests/Engine/Aether/SystemTest.cpp | 26 +++ .../Engine/Material/MaterialCompilerTest.cpp | 2 +- Shaders/Aether/Ribbon.vs.hlsl | 8 + 6 files changed, 198 insertions(+), 24 deletions(-) diff --git a/Elixir/Source/Engine/Aether/Emitter.cpp b/Elixir/Source/Engine/Aether/Emitter.cpp index deacc34f..8a3348a1 100644 --- a/Elixir/Source/Engine/Aether/Emitter.cpp +++ b/Elixir/Source/Engine/Aether/Emitter.cpp @@ -52,23 +52,30 @@ namespace Elixir::Aether if (m_Material) { - if (m_RenderMode != EParticleRenderMode::Sprite) + if (m_RenderMode == EParticleRenderMode::Mesh) { EE_CORE_ERROR( - "Aether emitter '{}' only supports materials for Sprite rendering.", - m_Name - ) - } - else if (!m_Material->GetCompiledMaterial()->SupportsUsage(EMaterialUsage::ParticleSprite)) - { - EE_CORE_ERROR( - "Aether emitter '{}' requires a material enabled for ParticleSprite usage.", + "Aether emitter '{}' does not support Mesh materials yet.", m_Name ) } else { - emitter.Material = m_Material; + const auto usage = m_RenderMode == EParticleRenderMode::Sprite + ? EMaterialUsage::ParticleSprite + : EMaterialUsage::ParticleRibbon; + + if (!m_Material->GetCompiledMaterial()->SupportsUsage(usage)) + { + EE_CORE_ERROR( + "Aether emitter '{}' requires a material enabled for its render mode.", + m_Name + ) + } + else + { + emitter.Material = m_Material; + } } } diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index 8c53aea0..4ef790dc 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -40,6 +40,14 @@ namespace Elixir::Aether uint32_t SpriteIndex = 0; }; + struct SMaterialRibbonPushConstants + { + glm::mat4 WorldTransform{ 1.0f }; + uint32_t EmitterIndex = 0; + uint32_t ParticleBaseOffset = 0; + uint32_t MaterialIndex = UINT32_MAX; + }; + SEmitterData ToEmitterDescription( const SCompiledEmitter& emitter, uint32_t opBaseOffset, @@ -142,6 +150,26 @@ namespace Elixir::Aether : glm::mat4{ 1.0f }; } + bool TryGetParticleMaterialUsage( + const EParticleRenderMode renderMode, + EMaterialUsage& usage + ) + { + switch (renderMode) + { + case EParticleRenderMode::Sprite: + usage = EMaterialUsage::ParticleSprite; + return true; + case EParticleRenderMode::Ribbon: + usage = EMaterialUsage::ParticleRibbon; + return true; + case EParticleRenderMode::Mesh: + return false; + } + + return false; + } + Renderer::Renderer( const GraphicsContext* context, const ShaderLoader* shaderLoader, @@ -258,15 +286,30 @@ namespace Elixir::Aether ); } - // for (const auto& batch : renderBatches) { - if (!batch.Key.MaterialShader || batch.Items.empty()) + if (!batch.Key.MaterialShader || batch.Items.empty() || + !batch.Items.front().Material) + continue; + + EMaterialUsage usage; + if (!TryGetParticleMaterialUsage(batch.Key.RenderMode, usage)) continue; const auto& shader = batch.Items.front().Material - ->GetCompiledMaterial()->GetShader(EMaterialUsage::ParticleSprite); - PrepareParticleSpriteMaterialShader(shader); + ->GetCompiledMaterial()->GetShader(usage); + + if (batch.Key.RenderMode == EParticleRenderMode::Sprite) + { + PrepareParticleSpriteMaterialShader(shader); + continue; + } + + auto* runtime = FindParticleStateLayoutRuntime(batch.Key.ParticleStateLayout); + EE_CORE_ASSERT(runtime, "Aether particle state layout runtime is missing.") + if (!runtime) continue; + + PrepareParticleRibbonMaterialShader(*runtime, shader); } m_LastSubmissionMetrics.SimulationBatchCount = simulationBatches.size(); @@ -904,6 +947,25 @@ namespace Elixir::Aether shader->BindSampler("spriteSampler", m_SpriteSampler); } + void Renderer::PrepareParticleRibbonMaterialShader( + const SParticleStateLayoutRuntime& runtime, + const Ref& shader + ) + { + if (!shader || !m_MaterialShaderCache.insert(shader.get()).second) + return; + + constexpr SMaterialRibbonPushConstants pc{}; + shader->SetPushConstant("pc", (void*)&pc, sizeof(pc)); + + shader->BindConstantBuffer("cbFrame", m_FrameConstantBuffer); + shader->BindStorageBuffer("particles", runtime.ParticleStateBuffer); + shader->BindStorageBuffer("emitters", m_EmitterBuffer); + shader->BindStorageBuffer("materials", m_MaterialBuffer); + shader->BindTextureSet("sprites", m_Sprites); + shader->BindSampler("spriteSampler", m_SpriteSampler); + } + Ref Renderer::GetParticleSpritePipeline( SParticleStateLayoutRuntime& runtime, const Ref& shader @@ -929,6 +991,31 @@ namespace Elixir::Aether return pipeline; } + Ref Renderer::GetParticleRibbonPipeline( + SParticleStateLayoutRuntime& runtime, + const Ref& shader + ) const + { + const auto found = runtime.RibbonMaterialPipelines.find(shader.get()); + if (found != runtime.RibbonMaterialPipelines.end()) + return found->second; + + PipelineBuilder builder; + builder.SetShader(shader); + builder.SetInputTopology(EPrimitiveTopology::TriangleList); + builder.SetPolygonMode(EPolygonMode::Fill); + builder.SetCullMode(ECullMode::None, EFrontFace::CounterClockwise); + builder.EnableAlphaBlendingMax(); + builder.DisableDepthTest(); + builder.SetColorAttachmentFormat(EImageFormat::R8G8B8A8_SRGB); + builder.SetDepthAttachmentFormat(EDepthStencilImageFormat::D32_SFLOAT); + builder.SetBufferLayout({}); + + const auto pipeline = builder.Build(m_GraphicsContext); + runtime.RibbonMaterialPipelines.emplace(shader.get(), pipeline); + return pipeline; + } + void Renderer::BeginRendering(const Ref& cmd) const { const auto renderingInfo = SRenderingInfo @@ -1217,10 +1304,12 @@ namespace Elixir::Aether if (emitter.RenderMode == EParticleRenderMode::Sprite) spriteIndex = ResolveTextureIndex(emitter.SpriteTexture); - if (material && emitter.RenderMode == EParticleRenderMode::Sprite) + EMaterialUsage materialUsage; + + if (material && TryGetParticleMaterialUsage(emitter.RenderMode, materialUsage)) { const auto& shader = material->GetCompiledMaterial() - ->GetShader(EMaterialUsage::ParticleSprite); + ->GetShader(materialUsage); if (shader) { @@ -1464,17 +1553,52 @@ namespace Elixir::Aether case EParticleRenderMode::Ribbon: { - runtime->RibbonPipeline->Bind(cmd); + Ref shader = runtime->RibbonShader; + Ref pipeline = runtime->RibbonPipeline; + + if (batch.Key.MaterialShader) + { + EE_CORE_ASSERT( + !batch.Items.empty() && batch.Items.front().Material, + "Aether material ribbon batch requires a material proxy." + ) + + shader = batch.Items.front().Material->GetCompiledMaterial() + ->GetShader(EMaterialUsage::ParticleRibbon); + pipeline = GetParticleRibbonPipeline(*runtime, shader); + } + + pipeline->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, - }; + const auto worldTransform = GetParticleRenderTransform( + *item.Emitter, + *item.Instance->Instance + ); + + if (batch.Key.MaterialShader) + { + const SMaterialRibbonPushConstants pc{ + .WorldTransform = worldTransform, + .EmitterIndex = item.Instance->Allocation.Emitters.Offset + item.LocalEmitterIndex, + .ParticleBaseOffset = item.Instance->Allocation.Particles.Offset, + .MaterialIndex = item.MaterialIndex, + }; + + shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); + } + else + { + const SRibbonPushConstants pc{ + .WorldTransform = worldTransform, + .EmitterIndex = item.Instance->Allocation.Emitters.Offset + item.LocalEmitterIndex, + .ParticleBaseOffset = item.Instance->Allocation.Particles.Offset, + }; + + shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); + } - runtime->RibbonShader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); cmd->Draw(item.Emitter->MaxParticles * 6); } diff --git a/Elixir/Source/Engine/Aether/Renderer.h b/Elixir/Source/Engine/Aether/Renderer.h index b3acb53d..11e60ec1 100644 --- a/Elixir/Source/Engine/Aether/Renderer.h +++ b/Elixir/Source/Engine/Aether/Renderer.h @@ -173,6 +173,7 @@ namespace Elixir::Aether Ref UpdatePipeline; std::unordered_map> MaterialPipelines; + std::unordered_map> RibbonMaterialPipelines; Ref SpriteShader; Ref SpritePipeline; @@ -202,10 +203,18 @@ namespace Elixir::Aether uint32_t ResolveTextureIndex(const Ref& texture); void PrepareParticleSpriteMaterialShader(const Ref& shader); + void PrepareParticleRibbonMaterialShader( + const SParticleStateLayoutRuntime& runtime, + const Ref& shader + ); Ref GetParticleSpritePipeline( SParticleStateLayoutRuntime& runtime, const Ref& shader ) const; + Ref GetParticleRibbonPipeline( + SParticleStateLayoutRuntime& runtime, + const Ref& shader + ) const; void BeginRendering(const Ref& cmd) const; void EndRendering(const Ref& cmd) const; diff --git a/Elixir/Tests/Engine/Aether/SystemTest.cpp b/Elixir/Tests/Engine/Aether/SystemTest.cpp index 5e630478..4e810cdc 100644 --- a/Elixir/Tests/Engine/Aether/SystemTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemTest.cpp @@ -139,3 +139,29 @@ TEST(AetherSystemTest, CompileSnapshotsParticleSpriteMaterialForRenderData) EXPECT_FLOAT_EQ(first.Emitters[0].Material->GetValues()[0].x, 0.25f); EXPECT_FLOAT_EQ(second.Emitters[0].Material->GetValues()[0].x, 0.75f); } + +TEST(AetherSystemTest, CompileSnapshotsParticleRibbonMaterialForRenderData) +{ + const auto material = CreateRef("Particle ribbon"); + ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleRibbon, true)); + + const auto instance = CreateRef(material); + const auto compiledMaterial = MaterialCompiler::Build(*material); + ASSERT_TRUE(compiledMaterial); + + const auto proxy = instance->CreateRenderProxy(compiledMaterial.Material); + ASSERT_TRUE(proxy); + + System system{ "Ribbon material snapshot contract" }; + auto& emitter = system.AddEmitter("Ribbon", 8, 0.0f); + emitter.SetRenderMode(EParticleRenderMode::Ribbon); + emitter.SetMaterial(proxy); + + const auto compiled = system.Compile(); + + ASSERT_EQ(compiled.Emitters.size(), 1); + ASSERT_TRUE(compiled.Emitters[0].Material); + EXPECT_TRUE(compiled.Emitters[0].Material->GetCompiledMaterial()->SupportsUsage( + EMaterialUsage::ParticleRibbon + )); +} \ No newline at end of file diff --git a/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp b/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp index e77c2c09..6dcebd16 100644 --- a/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp @@ -44,7 +44,7 @@ TEST(MaterialCompilerTest, PreservesEnabledRendererUsages) EXPECT_FALSE(result.Material->SupportsUsage(EMaterialUsage::ParticleMesh)); } -TEST(MaterialCompilerTest, DoesNotAliasUnsupportedParticleUsagesToSurfaceShader) +TEST(MaterialCompilerTest, DoesNotAliasParticleUsageShadersToSurfaceShader) { SCompiledMaterial material; diff --git a/Shaders/Aether/Ribbon.vs.hlsl b/Shaders/Aether/Ribbon.vs.hlsl index 9b9b7e9d..fd86ded5 100644 --- a/Shaders/Aether/Ribbon.vs.hlsl +++ b/Shaders/Aether/Ribbon.vs.hlsl @@ -8,7 +8,11 @@ struct ParticleState float4 Metadata; // x = emitter index, y = ribbon link order, z = lifetime, w = alive }; +#if defined(MATERIAL_RIBBON) +[[vk::binding(1, 3)]] +#else [[vk::binding(1, 0)]] +#endif StructuredBuffer particles; struct Emitter @@ -19,7 +23,11 @@ struct Emitter float4 MetaD; // x = emission index }; +#if defined(MATERIAL_RIBBON) +[[vk::binding(2, 3)]] +#else [[vk::binding(2, 0)]] +#endif StructuredBuffer emitters; [[vk::binding(0, 0)]] From 0edf1081c3aa00117ae4bf148f0fc85092e1155f Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Thu, 30 Jul 2026 23:22:56 -0300 Subject: [PATCH 18/89] fix(aether): align ribbon material descriptors Keep Ribbon material resources in the descriptor sets expected by the bindless pipeline layout.\n\nAdd a Dissolve Ribbon material example for runtime coverage. --- Dissolve/Source/Dissolve.cpp | 84 +++++++ Elixir/Source/Engine/Aether/Renderer.cpp | 1 + Shaders/Aether/Ribbon.vs.hlsl | 11 +- Shaders/Material/ParticleRibbon.ps.hlsl | 24 +- Shaders/Material/ParticleRibbon.vs.hlsl | 279 ++++++++++++++++++++++- 5 files changed, 374 insertions(+), 25 deletions(-) diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index ce9d817c..6322682e 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -142,6 +142,90 @@ Dissolve::Dissolve() EE_CORE_ERROR("Node-graph material compilation failed: {}", result.Diagnostics) } + { + 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->DefineParameter("Tint", { + .Kind = EMaterialParameterKind::Value, + .ValueType = EMaterialGraphValueType::Float4, + .DefaultValue = SMaterialParam::MakeVector({ 0.2f, 0.5f, 1.0f, 1.0f }), + }), "") + + EE_CORE_ASSERT(ribbonMaterial->DefineParameter("Glow", { + .Kind = EMaterialParameterKind::Value, + .ValueType = EMaterialGraphValueType::Float4, + .DefaultValue = SMaterialParam::MakeVector({ 0.05f, 0.2f, 1.0f, 1.0f }), + }), "") + + EE_CORE_ASSERT(ribbonMaterial->DefineParameter("Albedo", { + .Kind = EMaterialParameterKind::Texture, + .DefaultValue = SMaterialParam::MakeTexture(tex), + }), "") + + SMaterialNode panner; + panner.Type = EMaterialNodeType::Panner; + panner.OutputType = EMaterialGraphValueType::Float2; + panner.ConstantValue = { 0.08f, -0.35f, 0.0f, 0.0f }; + const auto pannerNode = graph1.AddNode(panner); + + SMaterialNode albedo1; + albedo1.Type = EMaterialNodeType::TextureSample; + albedo1.OutputType = EMaterialGraphValueType::Float3; + albedo1.TextureParameterName = "Albedo"; + albedo1.Inputs = { static_cast(pannerNode) }; + const auto albedoNode1 = graph1.AddNode(albedo1); + + SMaterialNode tint1; + tint1.Type = EMaterialNodeType::Parameter; + tint1.OutputType = EMaterialGraphValueType::Float4; + tint1.ParameterName = "Tint"; + const auto tintNode1 = graph1.AddNode(tint1); + + SMaterialNode color; + color.Type = EMaterialNodeType::Multiply; + color.OutputType = EMaterialGraphValueType::Float4; + color.Inputs = { + static_cast(albedoNode1), + static_cast(tintNode1), + }; + graph1.SetChannel(EMaterialChannel::BaseColor, albedoNode1); + + SMaterialNode glow; + glow.Type = EMaterialNodeType::Parameter; + glow.OutputType = EMaterialGraphValueType::Float4; + glow.ParameterName = "Glow"; + //graph1.SetChannel(EMaterialChannel::Emissive, graph1.AddNode(glow)); + + ribbonMaterial->SetGraph(std::move(graph1)); + + const auto compileResult = MaterialCompiler::Compile( + m_ShaderLoader.get(), + *ribbonMaterial + ); + EE_CORE_ASSERT(compileResult, "Ribbon material compilation failed.") + + const auto instance = CreateRef(ribbonMaterial); + EE_CORE_ASSERT( + instance->SetVector("Tint", { 0.15f, 0.6f, 1.0f, 1.0f }), + "Ribbon tint override must match the schema." + ) + + const auto proxy = instance->CreateRenderProxy(compileResult.Material); + EE_CORE_ASSERT(proxy, "Ribbon material proxy creation failed.") + + if (auto* emitter = m_ParticleSystems[1]->FindEmitter("PathRibbon")) + { + emitter->SetMaterial(proxy); + EE_CORE_INFO("Published graph material to the PathRibbon particle emitter.") + } + } + m_ParticleSystemInstances[0] = CreateScope( CreateRef(m_ParticleSystems[0]->Compile()) ); diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index 4ef790dc..9cfa694d 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -31,6 +31,7 @@ namespace Elixir::Aether glm::mat4 WorldTransform{ 1.0f }; uint32_t EmitterIndex = 0; uint32_t ParticleBaseOffset = 0; + uint32_t MaterialIndex = UINT32_MAX; }; struct SMaterialPushConstants diff --git a/Shaders/Aether/Ribbon.vs.hlsl b/Shaders/Aether/Ribbon.vs.hlsl index fd86ded5..d32cc377 100644 --- a/Shaders/Aether/Ribbon.vs.hlsl +++ b/Shaders/Aether/Ribbon.vs.hlsl @@ -8,11 +8,7 @@ struct ParticleState float4 Metadata; // x = emitter index, y = ribbon link order, z = lifetime, w = alive }; -#if defined(MATERIAL_RIBBON) -[[vk::binding(1, 3)]] -#else [[vk::binding(1, 0)]] -#endif StructuredBuffer particles; struct Emitter @@ -23,11 +19,8 @@ struct Emitter float4 MetaD; // x = emission index }; -#if defined(MATERIAL_RIBBON) -[[vk::binding(2, 3)]] -#else + [[vk::binding(2, 0)]] -#endif StructuredBuffer emitters; [[vk::binding(0, 0)]] @@ -45,9 +38,7 @@ struct PushConstants float4x4 WorldTransform; uint EmitterIndex; uint ParticleBaseOffset; -#if defined(MATERIAL_RIBBON) uint MaterialIndex; -#endif }; [[vk::push_constant]] diff --git a/Shaders/Material/ParticleRibbon.ps.hlsl b/Shaders/Material/ParticleRibbon.ps.hlsl index 9ba06d86..f39e8b71 100644 --- a/Shaders/Material/ParticleRibbon.ps.hlsl +++ b/Shaders/Material/ParticleRibbon.ps.hlsl @@ -11,7 +11,7 @@ cbuffer cbFrame : register(b0) float Time; }; -[[vk::binding(1, 0)]] +[[vk::binding(3, 0)]] SamplerState spriteSampler : register(s0); [[vk::binding(1, 1)]] @@ -23,7 +23,7 @@ struct CompiledMaterial uint TextureIndices[32]; }; -[[vk::binding(2, 0)]] +[[vk::binding(4, 0)]] StructuredBuffer materials; struct MaterialPushConstants @@ -39,9 +39,9 @@ MaterialPushConstants pc; struct PSInput { - float4 ClipPos : SV_POSITION; - float4 Color : COLOR0; - float2 UV : TEXCOORD0; + float4 ClipPos : SV_POSITION; + float4 Color : COLOR0; + float2 TexCoord : TEXCOORD0; nointerpolation float Valid : TEXCOORD1; }; @@ -74,11 +74,13 @@ float4 main(PSInput input) : SV_Target0 // __GRAPH_BODY__ - const float centeredAcrossRibbon = abs((input.UV.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 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, input.Color.a * edgeFade); + return float4(color, 1.0f); } \ No newline at end of file diff --git a/Shaders/Material/ParticleRibbon.vs.hlsl b/Shaders/Material/ParticleRibbon.vs.hlsl index c9cb3a5e..a778db6c 100644 --- a/Shaders/Material/ParticleRibbon.vs.hlsl +++ b/Shaders/Material/ParticleRibbon.vs.hlsl @@ -1,5 +1,276 @@ -// The material fragment stage needs MaterialIndex. This macro extends the -// shared Ribbon push-constant ABI, so both generated stages declare 76 bytes. -#define MATERIAL_RIBBON 1 +// Template for EMaterialUsage::ParticleRibbon. -#include "../Aether/Ribbon.vs.hlsl" \ No newline at end of file +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 From 3c5941899dce9cc049ef40190cc51fbd4b38107c Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Fri, 31 Jul 2026 10:39:25 -0300 Subject: [PATCH 19/89] feat(aether): render mesh graph materials Compile Mesh shader permutations and publish their material proxies to the GPU material table.\n\nBind Mesh material pipelines with the same texture and push-constant contract as Sprite and Ribbon. --- Dissolve/Source/Dissolve.cpp | 10 + Elixir/Source/Engine/Aether/Emitter.cpp | 36 ++-- Elixir/Source/Engine/Aether/Renderer.cpp | 200 ++++++++++++------ Elixir/Source/Engine/Aether/Renderer.h | 10 +- .../Engine/Material/MaterialCompiler.cpp | 90 ++++++++ .../Source/Engine/Material/MaterialCompiler.h | 9 +- Elixir/Tests/Engine/Aether/SystemTest.cpp | 27 +++ .../Engine/Material/MaterialCompilerTest.cpp | 4 +- Shaders/Aether/Mesh.vs.hlsl | 3 +- Shaders/Material/ParticleMesh.ps.hlsl | 88 ++++++++ Shaders/Material/ParticleMesh.vs.hlsl | 122 +++++++++++ 11 files changed, 511 insertions(+), 88 deletions(-) create mode 100644 Shaders/Material/ParticleMesh.ps.hlsl create mode 100644 Shaders/Material/ParticleMesh.vs.hlsl diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index 6322682e..a6cb4d4f 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -150,6 +150,10 @@ Dissolve::Dissolve() 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, @@ -224,6 +228,12 @@ Dissolve::Dissolve() emitter->SetMaterial(proxy); EE_CORE_INFO("Published graph material to the PathRibbon particle emitter.") } + + if (auto* emitter = m_ParticleSystems[1]->FindEmitter("CrystalShards")) + { + emitter->SetMaterial(proxy); + EE_CORE_INFO("Published graph material to the CrystalShards particle emitter.") + } } m_ParticleSystemInstances[0] = CreateScope( diff --git a/Elixir/Source/Engine/Aether/Emitter.cpp b/Elixir/Source/Engine/Aether/Emitter.cpp index 8a3348a1..e0c3b6c6 100644 --- a/Elixir/Source/Engine/Aether/Emitter.cpp +++ b/Elixir/Source/Engine/Aether/Emitter.cpp @@ -52,30 +52,32 @@ namespace Elixir::Aether if (m_Material) { - if (m_RenderMode == EParticleRenderMode::Mesh) + EMaterialUsage usage; + + switch (m_RenderMode) + { + case EParticleRenderMode::Sprite: + usage = EMaterialUsage::ParticleSprite; + break; + case EParticleRenderMode::Ribbon: + usage = EMaterialUsage::ParticleRibbon; + break; + case EParticleRenderMode::Mesh: + usage = EMaterialUsage::ParticleMesh; + break; + } + + const auto& material = m_Material->GetCompiledMaterial(); + if (!material || !material->SupportsUsage(usage)) { EE_CORE_ERROR( - "Aether emitter '{}' does not support Mesh materials yet.", + "Aether emitter '{}' material does not support its render mode.", m_Name ) } else { - const auto usage = m_RenderMode == EParticleRenderMode::Sprite - ? EMaterialUsage::ParticleSprite - : EMaterialUsage::ParticleRibbon; - - if (!m_Material->GetCompiledMaterial()->SupportsUsage(usage)) - { - EE_CORE_ERROR( - "Aether emitter '{}' requires a material enabled for its render mode.", - m_Name - ) - } - else - { - emitter.Material = m_Material; - } + emitter.Material = m_Material; } } diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index 9cfa694d..1e00ae40 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -22,26 +22,12 @@ namespace Elixir::Aether }; struct SMeshPushConstants - { - glm::mat4 WorldTransform{ 1.0f }; - }; - - struct SRibbonPushConstants - { - glm::mat4 WorldTransform{ 1.0f }; - uint32_t EmitterIndex = 0; - uint32_t ParticleBaseOffset = 0; - uint32_t MaterialIndex = UINT32_MAX; - }; - - struct SMaterialPushConstants { glm::mat4 WorldTransform{ 1.0f }; uint32_t MaterialIndex = UINT32_MAX; - uint32_t SpriteIndex = 0; }; - struct SMaterialRibbonPushConstants + struct SRibbonPushConstants { glm::mat4 WorldTransform{ 1.0f }; uint32_t EmitterIndex = 0; @@ -151,7 +137,7 @@ namespace Elixir::Aether : glm::mat4{ 1.0f }; } - bool TryGetParticleMaterialUsage( + bool TryToGetParticleMaterialUsage( const EParticleRenderMode renderMode, EMaterialUsage& usage ) @@ -165,7 +151,8 @@ namespace Elixir::Aether usage = EMaterialUsage::ParticleRibbon; return true; case EParticleRenderMode::Mesh: - return false; + usage = EMaterialUsage::ParticleMesh; + return true; } return false; @@ -294,23 +281,29 @@ namespace Elixir::Aether continue; EMaterialUsage usage; - if (!TryGetParticleMaterialUsage(batch.Key.RenderMode, usage)) + if (!TryToGetParticleMaterialUsage(batch.Key.RenderMode, usage)) continue; const auto& shader = batch.Items.front().Material ->GetCompiledMaterial()->GetShader(usage); - if (batch.Key.RenderMode == EParticleRenderMode::Sprite) + switch (batch.Key.RenderMode) { - PrepareParticleSpriteMaterialShader(shader); - continue; + case EParticleRenderMode::Sprite: + PrepareParticleSpriteMaterialShader(shader); + break; + case EParticleRenderMode::Ribbon: + { + if (const auto* runtime = FindParticleStateLayoutRuntime(batch.Key.ParticleStateLayout)) + { + PrepareParticleRibbonMaterialShader(*runtime, shader); + } + break; + } + case EParticleRenderMode::Mesh: + PrepareParticleMeshMaterialShader(shader); + break; } - - auto* runtime = FindParticleStateLayoutRuntime(batch.Key.ParticleStateLayout); - EE_CORE_ASSERT(runtime, "Aether particle state layout runtime is missing.") - if (!runtime) continue; - - PrepareParticleRibbonMaterialShader(*runtime, shader); } m_LastSubmissionMetrics.SimulationBatchCount = simulationBatches.size(); @@ -937,7 +930,7 @@ namespace Elixir::Aether if (!shader || !m_MaterialShaderCache.insert(shader.get()).second) return; - const SMaterialPushConstants pc{ + const SSpritePushConstants pc{ .SpriteIndex = m_WhiteTextureHandle.Index, }; @@ -956,7 +949,7 @@ namespace Elixir::Aether if (!shader || !m_MaterialShaderCache.insert(shader.get()).second) return; - constexpr SMaterialRibbonPushConstants pc{}; + constexpr SRibbonPushConstants pc{}; shader->SetPushConstant("pc", (void*)&pc, sizeof(pc)); shader->BindConstantBuffer("cbFrame", m_FrameConstantBuffer); @@ -967,13 +960,27 @@ namespace Elixir::Aether shader->BindSampler("spriteSampler", m_SpriteSampler); } + void Renderer::PrepareParticleMeshMaterialShader(const Ref& shader) + { + if (!shader || !m_MaterialShaderCache.insert(shader.get()).second) + return; + + constexpr SMeshPushConstants pc{}; + shader->SetPushConstant("pc", (void*)&pc, sizeof(pc)); + + shader->BindConstantBuffer("cbFrame", m_FrameConstantBuffer); + shader->BindStorageBuffer("materials", m_MaterialBuffer); + shader->BindTextureSet("sprites", m_Sprites); + shader->BindSampler("spriteSampler", m_SpriteSampler); + } + Ref Renderer::GetParticleSpritePipeline( SParticleStateLayoutRuntime& runtime, const Ref& shader ) const { - const auto found = runtime.MaterialPipelines.find(shader.get()); - if (found != runtime.MaterialPipelines.end()) + const auto found = runtime.SpriteMaterialPipelines.find(shader.get()); + if (found != runtime.SpriteMaterialPipelines.end()) return found->second; PipelineBuilder builder; @@ -988,7 +995,7 @@ namespace Elixir::Aether builder.SetBufferLayout(runtime.SpritePipeline->GetBufferLayout()); const auto pipeline = builder.Build(m_GraphicsContext); - runtime.MaterialPipelines.emplace(shader.get(), pipeline); + runtime.SpriteMaterialPipelines.emplace(shader.get(), pipeline); return pipeline; } @@ -1017,6 +1024,35 @@ namespace Elixir::Aether return pipeline; } + Ref Renderer::GetParticleMeshPipeline( + SParticleStateLayoutRuntime& runtime, + const Ref& shader + ) const + { + const auto found = runtime.MeshMaterialPipelines.find(shader.get()); + if (found != runtime.MeshMaterialPipelines.end()) + return found->second; + + PipelineBuilder builder; + builder.SetShader(shader); + builder.SetInputTopology(EPrimitiveTopology::TriangleList); + builder.SetPolygonMode(EPolygonMode::Fill); + builder.SetCullMode(ECullMode::Back, EFrontFace::CounterClockwise); + builder.EnableAlphaBlendingMax(); + builder.SetColorAttachmentFormat(EImageFormat::R8G8B8A8_SRGB); + builder.SetDepthAttachmentFormat(EDepthStencilImageFormat::D32_SFLOAT); + builder.SetBufferLayout(runtime.MeshPipeline->GetBufferLayout()); + + auto info = builder.GetCreateInfo(); + info.DepthStencil.DepthTestEnable = true; + info.DepthStencil.DepthWriteEnable = true; + info.DepthStencil.DepthCompareOp = ECompareOp::LessOrEqual; + + const auto pipeline = GraphicsPipeline::Create(m_GraphicsContext, info); + runtime.MeshMaterialPipelines.emplace(shader.get(), pipeline); + return pipeline; + } + void Renderer::BeginRendering(const Ref& cmd) const { const auto renderingInfo = SRenderingInfo @@ -1307,7 +1343,7 @@ namespace Elixir::Aether EMaterialUsage materialUsage; - if (material && TryGetParticleMaterialUsage(emitter.RenderMode, materialUsage)) + if (material && TryToGetParticleMaterialUsage(emitter.RenderMode, materialUsage)) { const auto& shader = material->GetCompiledMaterial() ->GetShader(materialUsage); @@ -1527,22 +1563,53 @@ namespace Elixir::Aether switch (batch.Key.RenderMode) { - case EParticleRenderMode::Mesh: + case EParticleRenderMode::Sprite: { - runtime->MeshPipeline->Bind(cmd); - m_MeshVertexBuffer->Bind(cmd); - // TODO: Enhance this api - particleBuffer->BindAs(cmd, std::span{}, 1, 1); + Ref shader = runtime->SpriteShader; + Ref pipeline = runtime->SpritePipeline; + + if (batch.Key.MaterialShader) + { + EE_CORE_ASSERT( + !batch.Items.empty() && batch.Items.front().Material, + "Aether material sprite batch requires a material proxy." + ) + + shader = batch.Items.front().Material->GetCompiledMaterial() + ->GetShader(EMaterialUsage::ParticleSprite); + pipeline = GetParticleSpritePipeline(*runtime, shader); + } + + pipeline->Bind(cmd); + particleBuffer->BindAs(cmd); for (const auto& item : batch.Items) { - const SMeshPushConstants pc{ - .WorldTransform = GetParticleRenderTransform(*item.Emitter, *item.Instance->Instance) - }; + const auto worldTransform = GetParticleRenderTransform( + *item.Emitter, + *item.Instance->Instance + ); + + if (batch.Key.MaterialShader) + { + const SSpritePushConstants pc{ + .WorldTransform = worldTransform, + .MaterialIndex = item.MaterialIndex, + .SpriteIndex = item.SpriteIndex, + }; + shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); + } + else + { + const SSpritePushConstants pc{ + .WorldTransform = worldTransform, + .SpriteIndex = item.SpriteIndex, + }; + shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); + } - runtime->MeshShader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); cmd->Draw( - m_MeshVertexCount, + 6, item.Emitter->MaxParticles, 0, item.Instance->Allocation.Particles.Offset + item.Emitter->LocalParticleOffset @@ -1580,7 +1647,7 @@ namespace Elixir::Aether if (batch.Key.MaterialShader) { - const SMaterialRibbonPushConstants pc{ + const SRibbonPushConstants pc{ .WorldTransform = worldTransform, .EmitterIndex = item.Instance->Allocation.Emitters.Offset + item.LocalEmitterIndex, .ParticleBaseOffset = item.Instance->Allocation.Particles.Offset, @@ -1606,53 +1673,52 @@ namespace Elixir::Aether return; } - case EParticleRenderMode::Sprite: + case EParticleRenderMode::Mesh: { - Ref shader = runtime->SpriteShader; - Ref pipeline = runtime->SpritePipeline; + Ref shader = runtime->MeshShader; + Ref pipeline = runtime->MeshPipeline; if (batch.Key.MaterialShader) { - EE_CORE_ASSERT( - !batch.Items.empty() && batch.Items.front().Material, - "Aether material sprite batch requires a material proxy." - ) + const auto& material = batch.Items.front().Material; + EE_CORE_ASSERT(material, "") - shader = batch.Items.front().Material->GetCompiledMaterial() - ->GetShader(EMaterialUsage::ParticleSprite); - pipeline = GetParticleSpritePipeline(*runtime, shader); + shader = material->GetCompiledMaterial() + ->GetShader(EMaterialUsage::ParticleMesh); + pipeline = GetParticleMeshPipeline(*runtime, shader); } + if (!shader || !pipeline) return; + pipeline->Bind(cmd); - particleBuffer->BindAs(cmd); + m_MeshVertexBuffer->Bind(cmd); + // TODO: Enhance this api + particleBuffer->BindAs(cmd, std::span{}, 1, 1); for (const auto& item : batch.Items) { - const auto worldTransform = GetParticleRenderTransform( - *item.Emitter, - *item.Instance->Instance - ); + const auto worldTransform = GetParticleRenderTransform(*item.Emitter, *item.Instance->Instance); - if (batch.Key.MaterialShader) + if (item.Material) { - const SMaterialPushConstants pc{ + const SMeshPushConstants pc{ .WorldTransform = worldTransform, - .MaterialIndex = item.MaterialIndex, - .SpriteIndex = item.SpriteIndex, + .MaterialIndex = item.MaterialIndex }; + shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); } else { - const SSpritePushConstants pc{ - .WorldTransform = worldTransform, - .SpriteIndex = item.SpriteIndex, + const SMeshPushConstants pc{ + .WorldTransform = worldTransform }; + shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); } cmd->Draw( - 6, + m_MeshVertexCount, item.Emitter->MaxParticles, 0, item.Instance->Allocation.Particles.Offset + item.Emitter->LocalParticleOffset diff --git a/Elixir/Source/Engine/Aether/Renderer.h b/Elixir/Source/Engine/Aether/Renderer.h index 11e60ec1..5ffbd545 100644 --- a/Elixir/Source/Engine/Aether/Renderer.h +++ b/Elixir/Source/Engine/Aether/Renderer.h @@ -172,8 +172,9 @@ namespace Elixir::Aether Ref UpdateShader; Ref UpdatePipeline; - std::unordered_map> MaterialPipelines; + std::unordered_map> SpriteMaterialPipelines; std::unordered_map> RibbonMaterialPipelines; + std::unordered_map> MeshMaterialPipelines; Ref SpriteShader; Ref SpritePipeline; @@ -202,11 +203,14 @@ namespace Elixir::Aether void BindParticleStateLayoutShaderParameters(const SParticleStateLayoutRuntime& runtime) const; uint32_t ResolveTextureIndex(const Ref& texture); + void PrepareParticleSpriteMaterialShader(const Ref& shader); void PrepareParticleRibbonMaterialShader( const SParticleStateLayoutRuntime& runtime, const Ref& shader ); + void PrepareParticleMeshMaterialShader(const Ref& shader); + Ref GetParticleSpritePipeline( SParticleStateLayoutRuntime& runtime, const Ref& shader @@ -215,6 +219,10 @@ namespace Elixir::Aether SParticleStateLayoutRuntime& runtime, const Ref& shader ) const; + Ref GetParticleMeshPipeline( + SParticleStateLayoutRuntime& runtime, + const Ref& shader + ) const; void BeginRendering(const Ref& cmd) const; void EndRendering(const Ref& cmd) const; diff --git a/Elixir/Source/Engine/Material/MaterialCompiler.cpp b/Elixir/Source/Engine/Material/MaterialCompiler.cpp index 8daa8a1d..d738ede1 100644 --- a/Elixir/Source/Engine/Material/MaterialCompiler.cpp +++ b/Elixir/Source/Engine/Material/MaterialCompiler.cpp @@ -138,6 +138,12 @@ namespace Elixir if (!result) return result; } + if (material.SupportsUsage(EMaterialUsage::ParticleMesh)) + { + result = CompileParticleMesh(loader, material, std::move(result)); + if (!result) return result; + } + return result; } @@ -378,4 +384,88 @@ namespace Elixir return result; } + + SMaterialCompileResult MaterialCompiler::CompileParticleMesh( + const ShaderLoader* loader, + const Material& material, + SMaterialCompileResult 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/Material/MaterialCompiler.h b/Elixir/Source/Engine/Material/MaterialCompiler.h index 2f89ec3b..03d537b1 100644 --- a/Elixir/Source/Engine/Material/MaterialCompiler.h +++ b/Elixir/Source/Engine/Material/MaterialCompiler.h @@ -20,6 +20,7 @@ namespace Elixir Ref SurfaceShader; Ref ParticleSpriteShader; Ref ParticleRibbonShader; + Ref ParticleMeshShader; std::vector Parameters; bool SupportsUsage(const EMaterialUsage usage) const @@ -37,7 +38,7 @@ namespace Elixir case EMaterialUsage::ParticleRibbon: return ParticleRibbonShader; case EMaterialUsage::ParticleMesh: - break; + return ParticleMeshShader; } static const Ref unsupportedUsageShader; @@ -85,5 +86,11 @@ namespace Elixir const Material& material, SMaterialCompileResult result ); + + static SMaterialCompileResult CompileParticleMesh( + const ShaderLoader* loader, + const Material& material, + SMaterialCompileResult result + ); }; } diff --git a/Elixir/Tests/Engine/Aether/SystemTest.cpp b/Elixir/Tests/Engine/Aether/SystemTest.cpp index 4e810cdc..d23dbcb7 100644 --- a/Elixir/Tests/Engine/Aether/SystemTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemTest.cpp @@ -164,4 +164,31 @@ TEST(AetherSystemTest, CompileSnapshotsParticleRibbonMaterialForRenderData) EXPECT_TRUE(compiled.Emitters[0].Material->GetCompiledMaterial()->SupportsUsage( EMaterialUsage::ParticleRibbon )); +} + +TEST(AetherSystemTest, CompileSnapshotsParticleMeshMaterialForRenderData) +{ + const auto material = CreateRef("Particle mesh"); + material->SetUsage(EMaterialUsage::ParticleMesh, true); + + const auto instance = CreateRef(material); + + const auto compiledMaterial = MaterialCompiler::Build(*material); + ASSERT_TRUE(compiledMaterial); + + const auto proxy = instance->CreateRenderProxy(compiledMaterial.Material); + ASSERT_TRUE(proxy); + + System system{ "Mesh material" }; + auto& emitter = system.AddEmitter("Mesh", 8, 0.0f); + emitter.SetRenderMode(EParticleRenderMode::Mesh); + emitter.SetMaterial(proxy); + + const auto compiled = system.Compile(); + + ASSERT_EQ(compiled.Emitters.size(), 1); + ASSERT_TRUE(compiled.Emitters[0].Material); + EXPECT_TRUE(compiled.Emitters[0].Material->GetCompiledMaterial()->SupportsUsage( + EMaterialUsage::ParticleMesh + )); } \ No newline at end of file diff --git a/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp b/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp index 6dcebd16..d7eaa542 100644 --- a/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp @@ -35,13 +35,14 @@ TEST(MaterialCompilerTest, 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 = MaterialCompiler::Build(*material); ASSERT_TRUE(result); EXPECT_TRUE(result.Material->SupportsUsage(EMaterialUsage::ParticleSprite)); EXPECT_TRUE(result.Material->SupportsUsage(EMaterialUsage::ParticleRibbon)); - EXPECT_FALSE(result.Material->SupportsUsage(EMaterialUsage::ParticleMesh)); + EXPECT_TRUE(result.Material->SupportsUsage(EMaterialUsage::ParticleMesh)); } TEST(MaterialCompilerTest, DoesNotAliasParticleUsageShadersToSurfaceShader) @@ -54,6 +55,7 @@ TEST(MaterialCompilerTest, DoesNotAliasParticleUsageShadersToSurfaceShader) 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); 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/Material/ParticleMesh.ps.hlsl b/Shaders/Material/ParticleMesh.ps.hlsl new file mode 100644 index 00000000..036c56d3 --- /dev/null +++ b/Shaders/Material/ParticleMesh.ps.hlsl @@ -0,0 +1,88 @@ +// 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; + float3 Emissive; +}; + +float3 SampleTex(uint index, float2 uv) +{ + return sprites[index].Sample(spriteSampler, uv).rgb; +} + +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.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, 1.0f); +} \ 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 From 6ca40f54cc28a33323c9af85abf59df6428d976f Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Sat, 1 Aug 2026 09:37:31 -0300 Subject: [PATCH 20/89] refactor(material): centralize particle frame resources Move particle material frame data, texture registration and GPU storage into Engine/Material. Keep Aether as a consumer of the centralized frame snapshot and cover bindless descriptor visibility. --- .../Engine/Aether/ParticleMaterialTable.cpp | 46 ------ .../Engine/Aether/ParticleMaterialTable.h | 45 ------ Elixir/Source/Engine/Aether/Renderer.cpp | 132 +++++++----------- Elixir/Source/Engine/Aether/Renderer.h | 38 +++-- Elixir/Source/Engine/Material/Material.cpp | 2 +- Elixir/Source/Engine/Material/Material.h | 2 +- .../Engine/Material/MaterialFrameTable.cpp | 57 ++++++++ .../Engine/Material/MaterialFrameTable.h | 40 ++++++ .../Source/Engine/Material/MaterialSystem.cpp | 55 ++++++++ .../Source/Engine/Material/MaterialSystem.h | 38 +++++ .../Material/MaterialTextureRegistry.cpp | 63 +++++++++ .../Engine/Material/MaterialTextureRegistry.h | 47 +++++++ .../MaterialFrameTableTest.cpp} | 15 +- .../Material/MaterialTextureRegistryTest.cpp | 19 +++ 14 files changed, 399 insertions(+), 200 deletions(-) delete mode 100644 Elixir/Source/Engine/Aether/ParticleMaterialTable.cpp delete mode 100644 Elixir/Source/Engine/Aether/ParticleMaterialTable.h create mode 100644 Elixir/Source/Engine/Material/MaterialFrameTable.cpp create mode 100644 Elixir/Source/Engine/Material/MaterialFrameTable.h create mode 100644 Elixir/Source/Engine/Material/MaterialSystem.cpp create mode 100644 Elixir/Source/Engine/Material/MaterialSystem.h create mode 100644 Elixir/Source/Engine/Material/MaterialTextureRegistry.cpp create mode 100644 Elixir/Source/Engine/Material/MaterialTextureRegistry.h rename Elixir/Tests/Engine/{Aether/ParticleMaterialTableTest.cpp => Material/MaterialFrameTableTest.cpp} (90%) create mode 100644 Elixir/Tests/Engine/Material/MaterialTextureRegistryTest.cpp diff --git a/Elixir/Source/Engine/Aether/ParticleMaterialTable.cpp b/Elixir/Source/Engine/Aether/ParticleMaterialTable.cpp deleted file mode 100644 index 32f3fc74..00000000 --- a/Elixir/Source/Engine/Aether/ParticleMaterialTable.cpp +++ /dev/null @@ -1,46 +0,0 @@ -#include "epch.h" -#include "ParticleMaterialTable.h" - -namespace Elixir::Aether -{ - std::optional ParticleMaterialTable::Add(const MaterialRenderProxy& material) - { - const auto found = m_Indices.find(&material); - if (found != m_Indices.end()) - return found->second; - - 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; - } - - SParticleMaterialData ParticleMaterialTable::BuildData( - const MaterialRenderProxy& material - ) const - { - SParticleMaterialData data{}; - std::ranges::fill(data.TextureIndices, m_FallbackTextureIndex); - - const auto& values = material.GetValues(); - std::copy_n( - values.begin(), - std::min(values.size(), data.Values.size()), - 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/Aether/ParticleMaterialTable.h b/Elixir/Source/Engine/Aether/ParticleMaterialTable.h deleted file mode 100644 index 0aa813dc..00000000 --- a/Elixir/Source/Engine/Aether/ParticleMaterialTable.h +++ /dev/null @@ -1,45 +0,0 @@ -#pragma once - -#include - -namespace Elixir::Aether -{ - // ABI shared with Shaders/Material/ParticleSprite.ps.hlsl. - struct alignas(16) SParticleMaterialData - { - std::array Values{}; - std::array TextureIndices{}; - }; - - // Renderer-owned, frame-local staging data. It does not contain mutable - // particle simulation state. - class ELIXIR_API ParticleMaterialTable final - { - public: - using TextureIndexResolver = std::function&)>; - - ParticleMaterialTable( - const uint32_t capacity, - uint32_t fallbackTextureIndex, - TextureIndexResolver resolver - ) : m_Capacity(capacity), - m_FallbackTextureIndex(fallbackTextureIndex), - m_TextureIndexResolver(std::move(resolver)) {} - - // Returns the stable index for this render submission, or nullopt when - // the per-frame capacity is exhausted. - std::optional Add(const MaterialRenderProxy& material); - - const std::vector& GetData() const { return m_Data; } - uint32_t GetCount() const { return static_cast(m_Data.size()); } - - private: - SParticleMaterialData 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/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index 1e00ae40..f3848806 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -1,9 +1,7 @@ #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 @@ -165,6 +163,7 @@ namespace Elixir::Aether ) : m_ParticlePoolLimits(limits), m_ParticleStateLayouts(m_ParticlePoolLimits.ParticleCapacity), m_ParticleResourcePool(m_ParticlePoolLimits, m_ParticleStateLayouts), + m_MaterialSystem(CreateRef(context, limits.MaterialCapacity)), m_GraphicsContext(context) { static_assert(sizeof(SGPUParticleState) == PARTICLE_STATE_CORE_V1_STRIDE); @@ -254,25 +253,15 @@ namespace Elixir::Aether if (submittedInstances.empty()) return; - ParticleMaterialTable materials( - m_ParticlePoolLimits.MaterialCapacity, - m_WhiteTextureHandle.Index, - [this](const Ref& texture) - { - return ResolveTextureIndex(texture); - } + const auto materialInputs = CollectMaterialFrameInputs(submittedInstances); + const auto materialSnapshot = m_MaterialSystem->BuildFrameSnapshot( + materialInputs.Materials, + materialInputs.Textures, + m_SubmissionSerial ); const auto simulationBatches = BuildSimulationBatches(submittedInstances); - const auto renderBatches = BuildRenderBatches(submittedInstances, materials); - - if (!materials.GetData().empty()) - { - m_MaterialBuffer->UpdateData( - materials.GetData().data(), - materials.GetData().size() * sizeof(SParticleMaterialData) - ); - } + const auto renderBatches = BuildRenderBatches(submittedInstances, materialSnapshot); for (const auto& batch : renderBatches) { @@ -308,7 +297,7 @@ namespace Elixir::Aether m_LastSubmissionMetrics.SimulationBatchCount = simulationBatches.size(); m_LastSubmissionMetrics.RenderBatchCount = renderBatches.size(); - m_LastSubmissionMetrics.SubmittedMaterialCount = materials.GetCount(); + m_LastSubmissionMetrics.SubmittedMaterialCount = materialSnapshot.MaterialCount; for (const auto& batch : renderBatches) m_LastSubmissionMetrics.SubmittedRenderItemCount += batch.Items.size(); @@ -407,9 +396,6 @@ namespace Elixir::Aether 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); } @@ -636,11 +622,6 @@ namespace Elixir::Aether sizeof(SParameterData) * m_ParticlePoolLimits.ParameterCapacity ); - m_MaterialBuffer = DynamicStorageBuffer::Create( - m_GraphicsContext, - sizeof(SParticleMaterialData) * m_ParticlePoolLimits.MaterialCapacity - ); - m_ParamsBuffer = UniformBuffer::Create( m_GraphicsContext, sizeof(SParamsData) @@ -813,15 +794,6 @@ namespace Elixir::Aether 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); } @@ -867,7 +839,7 @@ namespace Elixir::Aether runtime.UpdateShader->BindConstantBuffer("cbParams", m_ParamsBuffer); const SSpritePushConstants spritePushConstants{ - .SpriteIndex = m_WhiteTextureHandle.Index + .SpriteIndex = m_MaterialSystem->GetFallbackTextureIndex(), }; runtime.SpriteShader->SetPushConstant( "pc", @@ -876,8 +848,8 @@ namespace Elixir::Aether ); runtime.SpriteShader->BindConstantBuffer("cbFrame", m_FrameConstantBuffer); - runtime.SpriteShader->BindTextureSet("sprites", m_Sprites); - runtime.SpriteShader->BindSampler("spriteSampler", m_SpriteSampler); + runtime.SpriteShader->BindTextureSet("sprites", m_MaterialSystem->GetTextureSet()); + runtime.SpriteShader->BindSampler("spriteSampler", m_MaterialSystem->GetSampler()); constexpr SRibbonPushConstants ribbonPushConstants{}; runtime.RibbonShader->SetPushConstant( @@ -900,45 +872,20 @@ namespace Elixir::Aether runtime.MeshShader->BindConstantBuffer("cbFrame", m_FrameConstantBuffer); } - uint32_t Renderer::ResolveTextureIndex(const Ref& texture) - { - if (!texture) - return m_WhiteTextureHandle.Index; - - const auto binding = m_TextureBindings.find(texture); - if (binding != m_TextureBindings.end()) - { - if (binding->second.ReadySubmission <= m_SubmissionSerial) - return binding->second.Handle.Index; - - return m_WhiteTextureHandle.Index; - } - - // RegisterTexture only marks the bindless slot dirty. Vulkan flushes it - // in GraphicsContext::Prepare() before the next render callback. - const auto handle = m_Sprites->AddTexture(texture); - m_TextureBindings.emplace(texture, STextureBinding{ - .Handle = handle, - .ReadySubmission = m_SubmissionSerial + 1, - }); - - return m_WhiteTextureHandle.Index; - } - void Renderer::PrepareParticleSpriteMaterialShader(const Ref& shader) { if (!shader || !m_MaterialShaderCache.insert(shader.get()).second) return; const SSpritePushConstants pc{ - .SpriteIndex = m_WhiteTextureHandle.Index, + .SpriteIndex = m_MaterialSystem->GetFallbackTextureIndex(), }; shader->SetPushConstant("pc", (void*)&pc, sizeof(pc)); shader->BindConstantBuffer("cbFrame", m_FrameConstantBuffer); - shader->BindStorageBuffer("materials", m_MaterialBuffer); - shader->BindTextureSet("sprites", m_Sprites); - shader->BindSampler("spriteSampler", m_SpriteSampler); + shader->BindStorageBuffer("materials", m_MaterialSystem->GetFrameBuffer()); + shader->BindTextureSet("sprites", m_MaterialSystem->GetTextureSet()); + shader->BindSampler("spriteSampler", m_MaterialSystem->GetSampler()); } void Renderer::PrepareParticleRibbonMaterialShader( @@ -955,9 +902,9 @@ namespace Elixir::Aether shader->BindConstantBuffer("cbFrame", m_FrameConstantBuffer); shader->BindStorageBuffer("particles", runtime.ParticleStateBuffer); shader->BindStorageBuffer("emitters", m_EmitterBuffer); - shader->BindStorageBuffer("materials", m_MaterialBuffer); - shader->BindTextureSet("sprites", m_Sprites); - shader->BindSampler("spriteSampler", m_SpriteSampler); + shader->BindStorageBuffer("materials", m_MaterialSystem->GetFrameBuffer()); + shader->BindTextureSet("sprites", m_MaterialSystem->GetTextureSet()); + shader->BindSampler("spriteSampler", m_MaterialSystem->GetSampler()); } void Renderer::PrepareParticleMeshMaterialShader(const Ref& shader) @@ -969,9 +916,9 @@ namespace Elixir::Aether shader->SetPushConstant("pc", (void*)&pc, sizeof(pc)); shader->BindConstantBuffer("cbFrame", m_FrameConstantBuffer); - shader->BindStorageBuffer("materials", m_MaterialBuffer); - shader->BindTextureSet("sprites", m_Sprites); - shader->BindSampler("spriteSampler", m_SpriteSampler); + shader->BindStorageBuffer("materials", m_MaterialSystem->GetFrameBuffer()); + shader->BindTextureSet("sprites", m_MaterialSystem->GetTextureSet()); + shader->BindSampler("spriteSampler", m_MaterialSystem->GetSampler()); } Ref Renderer::GetParticleSpritePipeline( @@ -1317,7 +1264,7 @@ namespace Elixir::Aether std::vector Renderer::BuildRenderBatches( const std::vector& instances, - ParticleMaterialTable& materials + const SMaterialFrameSnapshot& materials ) { std::vector batches; @@ -1336,10 +1283,10 @@ namespace Elixir::Aether const MaterialRenderProxy* material = emitter.Material.get(); const Shader* materialShader = nullptr; uint32_t materialIndex = UINT32_MAX; - uint32_t spriteIndex = m_WhiteTextureHandle.Index; + uint32_t spriteIndex = m_MaterialSystem->GetFallbackTextureIndex(); if (emitter.RenderMode == EParticleRenderMode::Sprite) - spriteIndex = ResolveTextureIndex(emitter.SpriteTexture); + spriteIndex = m_MaterialSystem->FindTextureIndex(emitter.SpriteTexture); EMaterialUsage materialUsage; @@ -1350,7 +1297,10 @@ namespace Elixir::Aether if (shader) { - if (const auto index = materials.Add(*material)) + const auto index = materials.Table->Find(*material); + EE_CORE_ASSERT(index, "Material frame snapshot is missing an emitter material.") + + if (index) { materialShader = shader.get(); materialIndex = *index; @@ -1424,6 +1374,32 @@ namespace Elixir::Aether return batches; } + Renderer::SMaterialFrameInputs Renderer::CollectMaterialFrameInputs( + const std::vector& instances + ) const + { + SMaterialFrameInputs inputs; + + for (const auto& instance : instances) + { + const auto& emitters = instance.Instance->GetCompiledSystem().Emitters; + + for (const auto& emitter : emitters) + { + if (emitter.MaxParticles == 0) + continue; + + if (emitter.Material) + inputs.Materials.push_back(emitter.Material); + + if (emitter.RenderMode == EParticleRenderMode::Sprite && emitter.SpriteTexture) + inputs.Textures.push_back(emitter.SpriteTexture); + } + } + + return inputs; + } + void Renderer::SimulateBatch(const Ref& cmd, const SSimulationBatch& batch) { const auto* runtime = FindParticleStateLayoutRuntime(batch.ParticleStateLayout); diff --git a/Elixir/Source/Engine/Aether/Renderer.h b/Elixir/Source/Engine/Aether/Renderer.h index 5ffbd545..fe213a68 100644 --- a/Elixir/Source/Engine/Aether/Renderer.h +++ b/Elixir/Source/Engine/Aether/Renderer.h @@ -5,8 +5,8 @@ #include #include #include -#include #include +#include #include #include @@ -202,8 +202,6 @@ namespace Elixir::Aether void BindShaderParameters(); void BindParticleStateLayoutShaderParameters(const SParticleStateLayoutRuntime& runtime) const; - uint32_t ResolveTextureIndex(const Ref& texture); - void PrepareParticleSpriteMaterialShader(const Ref& shader); void PrepareParticleRibbonMaterialShader( const SParticleStateLayoutRuntime& runtime, @@ -252,6 +250,12 @@ namespace Elixir::Aether std::vector Instances; }; + struct SMaterialFrameInputs + { + std::vector> Materials; + std::vector> Textures; + }; + struct SRenderBatchKey { EParticleStateLayout ParticleStateLayout = EParticleStateLayout::CoreV1; @@ -297,15 +301,19 @@ namespace Elixir::Aether bool IsParticleStateLayoutSupported(EParticleStateLayout layout) const; - std::vector - BuildSimulationBatches(const std::vector& instances) const; + std::vector BuildSimulationBatches( + const std::vector& instances + ) const; - std::vector - BuildRenderBatches( + std::vector BuildRenderBatches( const std::vector& instances, - ParticleMaterialTable& materials + const SMaterialFrameSnapshot& materials ); + SMaterialFrameInputs CollectMaterialFrameInputs( + const std::vector& instances + ) const; + void SimulateBatch( const Ref& cmd, const SSimulationBatch& batch @@ -377,23 +385,11 @@ namespace Elixir::Aether Ref m_EmitterBuffer; Ref m_OpBuffer; Ref m_ParameterBuffer; - Ref m_MaterialBuffer; Ref m_ParamsBuffer; - Ref m_Sprites; - Ref m_SpriteSampler; + Ref m_MaterialSystem; std::unordered_set m_MaterialShaderCache; - struct STextureBinding - { - SResourceHandle Handle; - uint64_t ReadySubmission = 0; - }; - - std::unordered_map, STextureBinding> m_TextureBindings; - - SResourceHandle m_WhiteTextureHandle{}; - uint32_t m_MeshVertexCount = 0; Ref m_MeshVertexBuffer; diff --git a/Elixir/Source/Engine/Material/Material.cpp b/Elixir/Source/Engine/Material/Material.cpp index fdc3cdf0..f91ff2d6 100644 --- a/Elixir/Source/Engine/Material/Material.cpp +++ b/Elixir/Source/Engine/Material/Material.cpp @@ -69,7 +69,7 @@ namespace Elixir bool Material::IsParameterValueCompatible( const std::string& name, const SMaterialParam& value - ) + ) const { const auto* parameter = FindParameter(name); return parameter && IsValueCompatible(*parameter, value); diff --git a/Elixir/Source/Engine/Material/Material.h b/Elixir/Source/Engine/Material/Material.h index 2e27803f..baead1e7 100644 --- a/Elixir/Source/Engine/Material/Material.h +++ b/Elixir/Source/Engine/Material/Material.h @@ -92,7 +92,7 @@ namespace Elixir bool IsParameterValueCompatible( const std::string& name, const SMaterialParam& value - ); + ) const; bool ValidateGraph(std::string* error = nullptr) const; const std::string& GetName() const { return m_Name; } diff --git a/Elixir/Source/Engine/Material/MaterialFrameTable.cpp b/Elixir/Source/Engine/Material/MaterialFrameTable.cpp new file mode 100644 index 00000000..47076589 --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialFrameTable.cpp @@ -0,0 +1,57 @@ +#include "epch.h" +#include "MaterialFrameTable.h" + +namespace Elixir +{ + MaterialFrameTable::MaterialFrameTable( + const uint32_t capacity, + const uint32_t fallbackTextureIndex, + TextureIndexResolver resolver + ) : m_Capacity(capacity), + m_FallbackTextureIndex(fallbackTextureIndex), + m_TextureIndexResolver(std::move(resolver)) {} + + std::optional MaterialFrameTable::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 MaterialFrameTable::Find(const MaterialRenderProxy& material) const + { + const auto found = m_Indices.find(&material); + if (found == m_Indices.end()) + return std::nullopt; + + return found->second; + } + + SMaterialFrameData MaterialFrameTable::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/Material/MaterialFrameTable.h b/Elixir/Source/Engine/Material/MaterialFrameTable.h new file mode 100644 index 00000000..1f67ec48 --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialFrameTable.h @@ -0,0 +1,40 @@ +#pragma once + +#include + +namespace Elixir +{ + // GPU ABI shared by material templates. + struct alignas(16) SMaterialFrameData + { + std::array Values{}; + std::array TextureIndices{}; + }; + + class ELIXIR_API MaterialFrameTable final + { + public: + using TextureIndexResolver = std::function&)>; + + MaterialFrameTable( + uint32_t capacity, + uint32_t fallbackTextureIndex, + TextureIndexResolver resolver + ); + + std::optional Add(const MaterialRenderProxy& material); + std::optional Find(const MaterialRenderProxy& material) const; + + const std::vector& GetData() const { return m_Data; } + uint32_t GetCount() const { return static_cast(m_Data.size()); } + + private: + 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/Material/MaterialSystem.cpp b/Elixir/Source/Engine/Material/MaterialSystem.cpp new file mode 100644 index 00000000..979cbbc8 --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialSystem.cpp @@ -0,0 +1,55 @@ +#include "epch.h" +#include "MaterialSystem.h" + +namespace Elixir +{ + MaterialSystem::MaterialSystem(const GraphicsContext* context, const uint32_t capacity) + : m_MaterialCapacity(capacity), + m_FrameBuffer(DynamicStorageBuffer::Create( + context, + sizeof(SMaterialFrameData) * capacity) + ), + m_Textures(context) {} + + SMaterialFrameSnapshot MaterialSystem::BuildFrameSnapshot( + std::span> materials, + std::span> textures, + uint64_t submissionSerial + ) + { + m_Textures.BeginFrame(submissionSerial); + + const auto table = CreateRef( + m_MaterialCapacity, + m_Textures.GetFallbackIndex(), + [this](const Ref& texture) + { + return m_Textures.Resolve(texture); + } + ); + + for (const auto& material : materials) + { + if (material) + table->Add(*material); + } + + for (const auto& texture : textures) + m_Textures.Resolve(texture); + + if (!table->GetData().empty()) + { + m_FrameBuffer->UpdateData( + table->GetData().data(), + table->GetData().size() * sizeof(SMaterialFrameData) + ); + } + + return { table, table->GetCount(), submissionSerial }; + } + + uint32_t MaterialSystem::FindTextureIndex(const Ref& texture) const + { + return m_Textures.Find(texture); + } +} diff --git a/Elixir/Source/Engine/Material/MaterialSystem.h b/Elixir/Source/Engine/Material/MaterialSystem.h new file mode 100644 index 00000000..2e8cd36e --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialSystem.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include + +namespace Elixir +{ + struct SMaterialFrameSnapshot + { + Ref Table; + uint32_t MaterialCount = 0; + uint64_t SubmissionSerial = 0; + }; + + class ELIXIR_API MaterialSystem final + { + public: + MaterialSystem(const GraphicsContext* context, uint32_t capacity); + + SMaterialFrameSnapshot BuildFrameSnapshot( + std::span> materials, + std::span> textures, + uint64_t submissionSerial + ); + + const Ref& GetFrameBuffer() const { return m_FrameBuffer; } + const Ref& GetTextureSet() const { return m_Textures.GetTextureSet(); } + const Ref& GetSampler() const { return m_Textures.GetSampler(); } + uint32_t GetFallbackTextureIndex() const { return m_Textures.GetFallbackIndex(); } + uint32_t FindTextureIndex(const Ref& texture) const; + + private: + uint32_t m_MaterialCapacity = 0; + Ref m_FrameBuffer; + MaterialTextureRegistry m_Textures; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/MaterialTextureRegistry.cpp b/Elixir/Source/Engine/Material/MaterialTextureRegistry.cpp new file mode 100644 index 00000000..da89f408 --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialTextureRegistry.cpp @@ -0,0 +1,63 @@ +#include "epch.h" +#include "MaterialTextureRegistry.h" + +#include +#include + +namespace Elixir +{ + MaterialTextureRegistry::MaterialTextureRegistry(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 MaterialTextureRegistry::BeginFrame(const uint64_t submissionSerial) + { + m_SubmissionSerial = submissionSerial; + } + + uint32_t MaterialTextureRegistry::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, SMaterialTextureBinding{ + .Handle = handle, + .ReadySubmission = m_SubmissionSerial + 1, + }); + + return GetFallbackIndex(); + } + + uint32_t MaterialTextureRegistry::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/Material/MaterialTextureRegistry.h b/Elixir/Source/Engine/Material/MaterialTextureRegistry.h new file mode 100644 index 00000000..26341055 --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialTextureRegistry.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include + +namespace Elixir +{ + struct SMaterialTextureBinding + { + SResourceHandle Handle{}; + uint64_t ReadySubmission = 0; + + uint32_t GetIndexForSubmission( + const uint64_t submissionSerial, + const uint32_t fallbackIndex + ) const + { + return ReadySubmission <= submissionSerial + ? Handle.Index + : fallbackIndex; + } + }; + + class ELIXIR_API MaterialTextureRegistry final + { + public: + explicit MaterialTextureRegistry(const GraphicsContext* context); + + void BeginFrame(uint64_t submissionSerial); + uint32_t Resolve(const Ref& texture); + uint32_t Find(const Ref& texture) const; + + uint32_t GetFallbackIndex() const { return m_FallbackTextureHandle.Index; } + const Ref& GetTextureSet() const { return m_Textures; } + const Ref& GetSampler() const { return m_Sampler; } + + private: + Ref m_Textures; + Ref m_Sampler; + SResourceHandle m_FallbackTextureHandle; + std::unordered_map, SMaterialTextureBinding> m_Bindings; + + uint64_t m_SubmissionSerial = 0; + + const GraphicsContext* m_GraphicsContext; + }; +} \ No newline at end of file diff --git a/Elixir/Tests/Engine/Aether/ParticleMaterialTableTest.cpp b/Elixir/Tests/Engine/Material/MaterialFrameTableTest.cpp similarity index 90% rename from Elixir/Tests/Engine/Aether/ParticleMaterialTableTest.cpp rename to Elixir/Tests/Engine/Material/MaterialFrameTableTest.cpp index 92904342..7d1c1e54 100644 --- a/Elixir/Tests/Engine/Aether/ParticleMaterialTableTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialFrameTableTest.cpp @@ -1,11 +1,10 @@ #include -#include #include +#include #include using namespace Elixir; -using namespace Elixir::Aether; namespace { @@ -39,7 +38,7 @@ namespace }; } -TEST(ParticleMaterialTableTest, DeduplicatesAProxyAndPreserveItsValues) +TEST(MaterialFrameTableTest, DeduplicatesAProxyAndPreserveItsValues) { auto material = CreateRef("Particle material"); ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleSprite, true)); @@ -58,7 +57,7 @@ TEST(ParticleMaterialTableTest, DeduplicatesAProxyAndPreserveItsValues) const auto proxy = instance->CreateRenderProxy(compiled.Material); ASSERT_TRUE(proxy); - ParticleMaterialTable table( + MaterialFrameTable table( 1, 17, [](const Ref&) { return 23; } @@ -78,9 +77,9 @@ TEST(ParticleMaterialTableTest, DeduplicatesAProxyAndPreserveItsValues) EXPECT_EQ(data.TextureIndices.back(), 17); } -TEST(ParticleMaterialTableTest, RejectsAUniqueProxyPastCapacity) +TEST(MaterialFrameTableTest, RejectsAUniqueProxyPastCapacity) { - ParticleMaterialTable table( + MaterialFrameTable table( 0, 0, [](const Ref&) { return 0; } @@ -96,7 +95,7 @@ TEST(ParticleMaterialTableTest, RejectsAUniqueProxyPastCapacity) EXPECT_FALSE(table.Add(*proxy)); } -TEST(ParticleMaterialTableTest, ResolvesAuthoredTextureSlots) +TEST(MaterialFrameTableTest, ResolvesAuthoredTextureSlots) { const auto texture = CreateRef(); @@ -114,7 +113,7 @@ TEST(ParticleMaterialTableTest, ResolvesAuthoredTextureSlots) ASSERT_TRUE(proxy); uint32_t resolveCount = 0; - ParticleMaterialTable table( + MaterialFrameTable table( 1, 5, [&resolveCount, &texture](const Ref& resolved) diff --git a/Elixir/Tests/Engine/Material/MaterialTextureRegistryTest.cpp b/Elixir/Tests/Engine/Material/MaterialTextureRegistryTest.cpp new file mode 100644 index 00000000..bde5b6f0 --- /dev/null +++ b/Elixir/Tests/Engine/Material/MaterialTextureRegistryTest.cpp @@ -0,0 +1,19 @@ +#include + +#include + +using namespace Elixir; + +TEST(MaterialTextureRegistryTest, UsesFallbackUntilDescriptorIsVisible) +{ + constexpr uint32_t fallbackIndex = 3; + + const SMaterialTextureBinding 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); +} \ No newline at end of file From 779088f23ad11e36613687ac17ff9ee5fa2dc326 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Sat, 1 Aug 2026 19:00:51 -0300 Subject: [PATCH 21/89] refactor(material): centralize particle pass preparation Move particle material pipeline selection, resource binding and pipeline caching into Engine/Material. Keep Aether responsible for particle buffers and draw submission while it consumes prepared material passes. --- Elixir/Source/Engine/Aether/Renderer.cpp | 334 +++++++----------- Elixir/Source/Engine/Aether/Renderer.h | 30 +- .../Engine/Material/MaterialRenderer.cpp | 149 ++++++++ .../Source/Engine/Material/MaterialRenderer.h | 125 +++++++ .../Source/Engine/Material/MaterialSystem.cpp | 28 +- .../Source/Engine/Material/MaterialSystem.h | 11 + .../Engine/Material/MaterialRendererTest.cpp | 21 ++ 7 files changed, 462 insertions(+), 236 deletions(-) create mode 100644 Elixir/Source/Engine/Material/MaterialRenderer.cpp create mode 100644 Elixir/Source/Engine/Material/MaterialRenderer.h create mode 100644 Elixir/Tests/Engine/Material/MaterialRendererTest.cpp diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index f3848806..6860e095 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -135,21 +135,21 @@ namespace Elixir::Aether : glm::mat4{ 1.0f }; } - bool TryToGetParticleMaterialUsage( + bool TryToGetParticleMaterialPass( const EParticleRenderMode renderMode, - EMaterialUsage& usage + EMaterialPass& pass ) { switch (renderMode) { case EParticleRenderMode::Sprite: - usage = EMaterialUsage::ParticleSprite; + pass = EMaterialPass::ParticleSprite; return true; case EParticleRenderMode::Ribbon: - usage = EMaterialUsage::ParticleRibbon; + pass = EMaterialPass::ParticleRibbon; return true; case EParticleRenderMode::Mesh: - usage = EMaterialUsage::ParticleMesh; + pass = EMaterialPass::ParticleMesh; return true; } @@ -261,39 +261,9 @@ namespace Elixir::Aether ); const auto simulationBatches = BuildSimulationBatches(submittedInstances); - const auto renderBatches = BuildRenderBatches(submittedInstances, materialSnapshot); - for (const auto& batch : renderBatches) - { - if (!batch.Key.MaterialShader || batch.Items.empty() || - !batch.Items.front().Material) - continue; - - EMaterialUsage usage; - if (!TryToGetParticleMaterialUsage(batch.Key.RenderMode, usage)) - continue; - - const auto& shader = batch.Items.front().Material - ->GetCompiledMaterial()->GetShader(usage); - - switch (batch.Key.RenderMode) - { - case EParticleRenderMode::Sprite: - PrepareParticleSpriteMaterialShader(shader); - break; - case EParticleRenderMode::Ribbon: - { - if (const auto* runtime = FindParticleStateLayoutRuntime(batch.Key.ParticleStateLayout)) - { - PrepareParticleRibbonMaterialShader(*runtime, shader); - } - break; - } - case EParticleRenderMode::Mesh: - PrepareParticleMeshMaterialShader(shader); - break; - } - } + auto renderBatches = BuildRenderBatches(submittedInstances, materialSnapshot); + PrepareMaterialBatches(renderBatches); m_LastSubmissionMetrics.SimulationBatchCount = simulationBatches.size(); m_LastSubmissionMetrics.RenderBatchCount = renderBatches.size(); @@ -872,134 +842,6 @@ namespace Elixir::Aether runtime.MeshShader->BindConstantBuffer("cbFrame", m_FrameConstantBuffer); } - void Renderer::PrepareParticleSpriteMaterialShader(const Ref& shader) - { - if (!shader || !m_MaterialShaderCache.insert(shader.get()).second) - return; - - const SSpritePushConstants pc{ - .SpriteIndex = m_MaterialSystem->GetFallbackTextureIndex(), - }; - - shader->SetPushConstant("pc", (void*)&pc, sizeof(pc)); - shader->BindConstantBuffer("cbFrame", m_FrameConstantBuffer); - shader->BindStorageBuffer("materials", m_MaterialSystem->GetFrameBuffer()); - shader->BindTextureSet("sprites", m_MaterialSystem->GetTextureSet()); - shader->BindSampler("spriteSampler", m_MaterialSystem->GetSampler()); - } - - void Renderer::PrepareParticleRibbonMaterialShader( - const SParticleStateLayoutRuntime& runtime, - const Ref& shader - ) - { - if (!shader || !m_MaterialShaderCache.insert(shader.get()).second) - return; - - constexpr SRibbonPushConstants pc{}; - shader->SetPushConstant("pc", (void*)&pc, sizeof(pc)); - - shader->BindConstantBuffer("cbFrame", m_FrameConstantBuffer); - shader->BindStorageBuffer("particles", runtime.ParticleStateBuffer); - shader->BindStorageBuffer("emitters", m_EmitterBuffer); - shader->BindStorageBuffer("materials", m_MaterialSystem->GetFrameBuffer()); - shader->BindTextureSet("sprites", m_MaterialSystem->GetTextureSet()); - shader->BindSampler("spriteSampler", m_MaterialSystem->GetSampler()); - } - - void Renderer::PrepareParticleMeshMaterialShader(const Ref& shader) - { - if (!shader || !m_MaterialShaderCache.insert(shader.get()).second) - return; - - constexpr SMeshPushConstants pc{}; - shader->SetPushConstant("pc", (void*)&pc, sizeof(pc)); - - shader->BindConstantBuffer("cbFrame", m_FrameConstantBuffer); - shader->BindStorageBuffer("materials", m_MaterialSystem->GetFrameBuffer()); - shader->BindTextureSet("sprites", m_MaterialSystem->GetTextureSet()); - shader->BindSampler("spriteSampler", m_MaterialSystem->GetSampler()); - } - - Ref Renderer::GetParticleSpritePipeline( - SParticleStateLayoutRuntime& runtime, - const Ref& shader - ) const - { - const auto found = runtime.SpriteMaterialPipelines.find(shader.get()); - if (found != runtime.SpriteMaterialPipelines.end()) - return found->second; - - PipelineBuilder builder; - builder.SetShader(shader); - builder.SetInputTopology(EPrimitiveTopology::TriangleList); - builder.SetPolygonMode(EPolygonMode::Fill); - builder.SetCullMode(ECullMode::None, EFrontFace::CounterClockwise); - builder.EnableAlphaBlending(); - builder.DisableDepthTest(); - builder.SetColorAttachmentFormat(EImageFormat::R8G8B8A8_SRGB); - builder.SetDepthAttachmentFormat(EDepthStencilImageFormat::D32_SFLOAT); - builder.SetBufferLayout(runtime.SpritePipeline->GetBufferLayout()); - - const auto pipeline = builder.Build(m_GraphicsContext); - runtime.SpriteMaterialPipelines.emplace(shader.get(), pipeline); - return pipeline; - } - - Ref Renderer::GetParticleRibbonPipeline( - SParticleStateLayoutRuntime& runtime, - const Ref& shader - ) const - { - const auto found = runtime.RibbonMaterialPipelines.find(shader.get()); - if (found != runtime.RibbonMaterialPipelines.end()) - return found->second; - - PipelineBuilder builder; - builder.SetShader(shader); - builder.SetInputTopology(EPrimitiveTopology::TriangleList); - builder.SetPolygonMode(EPolygonMode::Fill); - builder.SetCullMode(ECullMode::None, EFrontFace::CounterClockwise); - builder.EnableAlphaBlendingMax(); - builder.DisableDepthTest(); - builder.SetColorAttachmentFormat(EImageFormat::R8G8B8A8_SRGB); - builder.SetDepthAttachmentFormat(EDepthStencilImageFormat::D32_SFLOAT); - builder.SetBufferLayout({}); - - const auto pipeline = builder.Build(m_GraphicsContext); - runtime.RibbonMaterialPipelines.emplace(shader.get(), pipeline); - return pipeline; - } - - Ref Renderer::GetParticleMeshPipeline( - SParticleStateLayoutRuntime& runtime, - const Ref& shader - ) const - { - const auto found = runtime.MeshMaterialPipelines.find(shader.get()); - if (found != runtime.MeshMaterialPipelines.end()) - return found->second; - - PipelineBuilder builder; - builder.SetShader(shader); - builder.SetInputTopology(EPrimitiveTopology::TriangleList); - builder.SetPolygonMode(EPolygonMode::Fill); - builder.SetCullMode(ECullMode::Back, EFrontFace::CounterClockwise); - builder.EnableAlphaBlendingMax(); - builder.SetColorAttachmentFormat(EImageFormat::R8G8B8A8_SRGB); - builder.SetDepthAttachmentFormat(EDepthStencilImageFormat::D32_SFLOAT); - builder.SetBufferLayout(runtime.MeshPipeline->GetBufferLayout()); - - auto info = builder.GetCreateInfo(); - info.DepthStencil.DepthTestEnable = true; - info.DepthStencil.DepthWriteEnable = true; - info.DepthStencil.DepthCompareOp = ECompareOp::LessOrEqual; - - const auto pipeline = GraphicsPipeline::Create(m_GraphicsContext, info); - runtime.MeshMaterialPipelines.emplace(shader.get(), pipeline); - return pipeline; - } - void Renderer::BeginRendering(const Ref& cmd) const { const auto renderingInfo = SRenderingInfo @@ -1281,28 +1123,25 @@ namespace Elixir::Aether continue; const MaterialRenderProxy* material = emitter.Material.get(); - const Shader* materialShader = nullptr; + SMaterialProgramKey materialProgram; uint32_t materialIndex = UINT32_MAX; uint32_t spriteIndex = m_MaterialSystem->GetFallbackTextureIndex(); if (emitter.RenderMode == EParticleRenderMode::Sprite) spriteIndex = m_MaterialSystem->FindTextureIndex(emitter.SpriteTexture); - EMaterialUsage materialUsage; - - if (material && TryToGetParticleMaterialUsage(emitter.RenderMode, materialUsage)) + EMaterialPass materialPass; + if (material && TryToGetParticleMaterialPass(emitter.RenderMode, materialPass)) { - const auto& shader = material->GetCompiledMaterial() - ->GetShader(materialUsage); - - if (shader) + const auto program = m_MaterialSystem->GetProgramKey(materialPass, *material); + if (program) { const auto index = materials.Table->Find(*material); EE_CORE_ASSERT(index, "Material frame snapshot is missing an emitter material.") if (index) { - materialShader = shader.get(); + materialProgram = *program; materialIndex = *index; } else @@ -1318,7 +1157,7 @@ namespace Elixir::Aether const SRenderBatchKey key{ .ParticleStateLayout = instance.ParticleStateLayout, .RenderMode = emitter.RenderMode, - .MaterialShader = materialShader, + .MaterialProgram = materialProgram, }; SRenderBatch* batch = nullptr; @@ -1365,15 +1204,114 @@ namespace Elixir::Aether GetRenderModeOrder(right.Key.RenderMode); } - return std::less{}( - left.Key.MaterialShader, - right.Key.MaterialShader + return std::less{}( + left.Key.MaterialProgram.Identity, + right.Key.MaterialProgram.Identity ); }); return batches; } + void Renderer::PrepareMaterialBatches(std::vector& batches) + { + static const BufferLayout ribbonVertexLayout; + + const std::array constantBuffers{ + SMaterialConstantBufferBinding{ + .Name = "cbFrame", + .Buffer = m_FrameConstantBuffer, + }, + }; + + for (auto& batch : batches) + { + if (!batch.Key.MaterialProgram || batch.Items.empty() || + !batch.Items.front().Material) + continue; + + const auto* runtime = FindParticleStateLayoutRuntime(batch.Key.ParticleStateLayout); + EE_CORE_ASSERT(runtime, "Aether particle state layout runtime is missing.") + if (!runtime) continue; + + const auto* material = batch.Items.front().Material; + + switch (batch.Key.RenderMode) + { + case EParticleRenderMode::Sprite: + { + const SSpritePushConstants initial{ + .SpriteIndex = m_MaterialSystem->GetFallbackTextureIndex(), + }; + + batch.PreparedMaterial = m_MaterialSystem->PrepareMaterialPass({ + .Pass = EMaterialPass::ParticleSprite, + .Material = material, + .Pipeline = { + .VertexLayoutKey = uint64_t(runtime->Key), + .VertexLayout = &runtime->SpritePipeline->GetBufferLayout(), + }, + .ExternalResources = { + .ConstantBuffers = constantBuffers, + }, + .InitialPushConstants = std::as_bytes(std::span{ &initial, size_t{ 1 }}), + }); + break; + } + + case EParticleRenderMode::Ribbon: + { + const SRibbonPushConstants initial{}; + + const std::array storageBuffers{ + SMaterialStorageBufferBinding{ + .Name = "particles", + .Buffer = MaterialStorageBuffer{ runtime->ParticleStateBuffer }, + }, + SMaterialStorageBufferBinding{ + .Name = "emitters", + .Buffer = MaterialStorageBuffer{ m_EmitterBuffer }, + }, + }; + + batch.PreparedMaterial = m_MaterialSystem->PrepareMaterialPass({ + .Pass = EMaterialPass::ParticleRibbon, + .Material = material, + .Pipeline = { + .VertexLayoutKey = uint64_t(runtime->Key), + .VertexLayout = &ribbonVertexLayout, + }, + .ExternalResources = { + .ConstantBuffers = constantBuffers, + .StorageBuffers = storageBuffers, + }, + .InitialPushConstants = std::as_bytes(std::span{ &initial, size_t{ 1 }}), + }); + break; + } + + case EParticleRenderMode::Mesh: + { + constexpr SMeshPushConstants initial{}; + + batch.PreparedMaterial = m_MaterialSystem->PrepareMaterialPass({ + .Pass = EMaterialPass::ParticleMesh, + .Material = material, + .Pipeline = { + .VertexLayoutKey = uint64_t(runtime->Key), + .VertexLayout = &runtime->MeshPipeline->GetBufferLayout(), + }, + .ExternalResources = { + .ConstantBuffers = constantBuffers, + }, + .InitialPushConstants = std::as_bytes(std::span{ &initial, size_t{ 1 }}), + }); + break; + } + } + } + } + Renderer::SMaterialFrameInputs Renderer::CollectMaterialFrameInputs( const std::vector& instances ) const @@ -1544,16 +1482,10 @@ namespace Elixir::Aether Ref shader = runtime->SpriteShader; Ref pipeline = runtime->SpritePipeline; - if (batch.Key.MaterialShader) + if (batch.PreparedMaterial) { - EE_CORE_ASSERT( - !batch.Items.empty() && batch.Items.front().Material, - "Aether material sprite batch requires a material proxy." - ) - - shader = batch.Items.front().Material->GetCompiledMaterial() - ->GetShader(EMaterialUsage::ParticleSprite); - pipeline = GetParticleSpritePipeline(*runtime, shader); + shader = batch.PreparedMaterial->Shader; + pipeline = batch.PreparedMaterial->Pipeline; } pipeline->Bind(cmd); @@ -1566,7 +1498,7 @@ namespace Elixir::Aether *item.Instance->Instance ); - if (batch.Key.MaterialShader) + if (batch.PreparedMaterial) { const SSpritePushConstants pc{ .WorldTransform = worldTransform, @@ -1600,16 +1532,10 @@ namespace Elixir::Aether Ref shader = runtime->RibbonShader; Ref pipeline = runtime->RibbonPipeline; - if (batch.Key.MaterialShader) + if (batch.PreparedMaterial) { - EE_CORE_ASSERT( - !batch.Items.empty() && batch.Items.front().Material, - "Aether material ribbon batch requires a material proxy." - ) - - shader = batch.Items.front().Material->GetCompiledMaterial() - ->GetShader(EMaterialUsage::ParticleRibbon); - pipeline = GetParticleRibbonPipeline(*runtime, shader); + shader = batch.PreparedMaterial->Shader; + pipeline = batch.PreparedMaterial->Pipeline; } pipeline->Bind(cmd); @@ -1621,7 +1547,7 @@ namespace Elixir::Aether *item.Instance->Instance ); - if (batch.Key.MaterialShader) + if (batch.PreparedMaterial) { const SRibbonPushConstants pc{ .WorldTransform = worldTransform, @@ -1654,14 +1580,10 @@ namespace Elixir::Aether Ref shader = runtime->MeshShader; Ref pipeline = runtime->MeshPipeline; - if (batch.Key.MaterialShader) + if (batch.PreparedMaterial) { - const auto& material = batch.Items.front().Material; - EE_CORE_ASSERT(material, "") - - shader = material->GetCompiledMaterial() - ->GetShader(EMaterialUsage::ParticleMesh); - pipeline = GetParticleMeshPipeline(*runtime, shader); + shader = batch.PreparedMaterial->Shader; + pipeline = batch.PreparedMaterial->Pipeline; } if (!shader || !pipeline) return; @@ -1675,7 +1597,7 @@ namespace Elixir::Aether { const auto worldTransform = GetParticleRenderTransform(*item.Emitter, *item.Instance->Instance); - if (item.Material) + if (batch.PreparedMaterial) { const SMeshPushConstants pc{ .WorldTransform = worldTransform, diff --git a/Elixir/Source/Engine/Aether/Renderer.h b/Elixir/Source/Engine/Aether/Renderer.h index fe213a68..a6c58f78 100644 --- a/Elixir/Source/Engine/Aether/Renderer.h +++ b/Elixir/Source/Engine/Aether/Renderer.h @@ -172,10 +172,6 @@ namespace Elixir::Aether Ref UpdateShader; Ref UpdatePipeline; - std::unordered_map> SpriteMaterialPipelines; - std::unordered_map> RibbonMaterialPipelines; - std::unordered_map> MeshMaterialPipelines; - Ref SpriteShader; Ref SpritePipeline; Ref RibbonShader; @@ -202,26 +198,6 @@ namespace Elixir::Aether void BindShaderParameters(); void BindParticleStateLayoutShaderParameters(const SParticleStateLayoutRuntime& runtime) const; - void PrepareParticleSpriteMaterialShader(const Ref& shader); - void PrepareParticleRibbonMaterialShader( - const SParticleStateLayoutRuntime& runtime, - const Ref& shader - ); - void PrepareParticleMeshMaterialShader(const Ref& shader); - - Ref GetParticleSpritePipeline( - SParticleStateLayoutRuntime& runtime, - const Ref& shader - ) const; - Ref GetParticleRibbonPipeline( - SParticleStateLayoutRuntime& runtime, - const Ref& shader - ) const; - Ref GetParticleMeshPipeline( - SParticleStateLayoutRuntime& runtime, - const Ref& shader - ) const; - void BeginRendering(const Ref& cmd) const; void EndRendering(const Ref& cmd) const; @@ -260,7 +236,7 @@ namespace Elixir::Aether { EParticleStateLayout ParticleStateLayout = EParticleStateLayout::CoreV1; EParticleRenderMode RenderMode = EParticleRenderMode::Sprite; - const Shader* MaterialShader = nullptr; + SMaterialProgramKey MaterialProgram; bool operator==(const SRenderBatchKey&) const = default; }; @@ -278,6 +254,7 @@ namespace Elixir::Aether struct SRenderBatch { SRenderBatchKey Key; + std::optional PreparedMaterial; std::vector Items; }; @@ -310,6 +287,8 @@ namespace Elixir::Aether const SMaterialFrameSnapshot& materials ); + void PrepareMaterialBatches(std::vector& batches); + SMaterialFrameInputs CollectMaterialFrameInputs( const std::vector& instances ) const; @@ -388,7 +367,6 @@ namespace Elixir::Aether Ref m_ParamsBuffer; Ref m_MaterialSystem; - std::unordered_set m_MaterialShaderCache; uint32_t m_MeshVertexCount = 0; Ref m_MeshVertexBuffer; diff --git a/Elixir/Source/Engine/Material/MaterialRenderer.cpp b/Elixir/Source/Engine/Material/MaterialRenderer.cpp new file mode 100644 index 00000000..227b107e --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialRenderer.cpp @@ -0,0 +1,149 @@ +#include "epch.h" +#include "MaterialRenderer.h" + +#include + +namespace Elixir +{ + MaterialRenderer::MaterialRenderer( + const GraphicsContext* context, + Ref frameBuffer, + const MaterialTextureRegistry& textures + ) : m_FrameBuffer(std::move(frameBuffer)), + m_Textures(textures), + m_Context(context) {} + + EMaterialUsage MaterialRenderer::GetUsage(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; + } + + std::optional MaterialRenderer::GetProgramKey( + const EMaterialPass pass, + const MaterialRenderProxy& material + ) const + { + 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 SMaterialProgramKey{ .Identity = shader.get() }; + } + + std::optional MaterialRenderer::Prepare( + const SMaterialPassRequest& request + ) + { + if (!request.Material || !request.Pipeline.VertexLayout) + 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 (!request.InitialPushConstants.empty()) + { + shader->SetPushConstant( + "pc", + const_cast(static_cast(request.InitialPushConstants.data())), + request.InitialPushConstants.size() + ); + } + + 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 + ); + } + + shader->BindStorageBuffer("materials", m_FrameBuffer); + shader->BindTextureSet("sprites", m_Textures.GetTextureSet()); + shader->BindSampler("spriteSampler", m_Textures.GetSampler()); + + return SPreparedMaterialPass{ + .Shader = shader, + .Pipeline = GetPipeline(request.Pass, shader, request.Pipeline), + }; + } + + Ref MaterialRenderer::GetPipeline( + const EMaterialPass pass, + const Ref& shader, + const SMaterialPipelineRequest& 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; + } +} diff --git a/Elixir/Source/Engine/Material/MaterialRenderer.h b/Elixir/Source/Engine/Material/MaterialRenderer.h new file mode 100644 index 00000000..13dd0e60 --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialRenderer.h @@ -0,0 +1,125 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace Elixir +{ + enum class EMaterialPass : uint8_t + { + ParticleSprite, + ParticleRibbon, + ParticleMesh, + }; + + struct SMaterialProgramKey + { + const void* Identity = nullptr; + + explicit operator bool() const { return Identity != nullptr; } + bool operator==(const SMaterialProgramKey&) const = default; + }; + + struct SMaterialPipelineRequest + { + uint64_t VertexLayoutKey = 0; + const BufferLayout* VertexLayout = nullptr; + }; + + struct SMaterialConstantBufferBinding + { + std::string_view Name; + Ref Buffer; + }; + + using MaterialStorageBuffer = std::variant, Ref>; + + struct SMaterialStorageBufferBinding + { + std::string_view Name; + MaterialStorageBuffer Buffer; + }; + + struct SMaterialExternalResources + { + std::span ConstantBuffers; + std::span StorageBuffers; + }; + + struct SMaterialPassRequest + { + EMaterialPass Pass = EMaterialPass::ParticleSprite; + const MaterialRenderProxy* Material = nullptr; + SMaterialPipelineRequest Pipeline; + SMaterialExternalResources ExternalResources; + std::span InitialPushConstants; + }; + + struct SPreparedMaterialPass + { + Ref Shader; + Ref Pipeline; + + explicit operator bool() const { return Shader && Pipeline; } + }; + + class ELIXIR_API MaterialRenderer final + { + public: + MaterialRenderer( + const GraphicsContext* context, + Ref frameBuffer, + const MaterialTextureRegistry& textures + ); + + static EMaterialUsage GetUsage(EMaterialPass pass); + + std::optional GetProgramKey( + EMaterialPass pass, + const MaterialRenderProxy& material + ) const; + + std::optional Prepare( + const SMaterialPassRequest& request + ); + + private: + struct SPipelineKey + { + EMaterialPass Pass = EMaterialPass::ParticleSprite; + const Shader* Shader = nullptr; + uint64_t VertexLayoutKey = 0; + + bool operator==(const SPipelineKey&) const = default; + }; + + struct SPipelineKeyHasher + { + size_t operator()(const SPipelineKey& key) const + { + size_t hash = std::hash{}(uint32_t(key.Pass)); + hash ^= std::hash{}(key.Shader) + + 0x9e3779b9 + (hash << 6) + (hash >> 2); + hash ^= std::hash{}(key.VertexLayoutKey) + + 0x9e3779b9 + (hash << 6) + (hash >> 2); + return hash; + } + }; + + Ref GetPipeline( + EMaterialPass pass, + const Ref& shader, + const SMaterialPipelineRequest& request + ); + + Ref m_FrameBuffer; + const MaterialTextureRegistry& m_Textures; + std::unordered_map, SPipelineKeyHasher> m_Pipelines; + + const GraphicsContext* m_Context = nullptr; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/MaterialSystem.cpp b/Elixir/Source/Engine/Material/MaterialSystem.cpp index 979cbbc8..12e182f1 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.cpp +++ b/Elixir/Source/Engine/Material/MaterialSystem.cpp @@ -9,12 +9,17 @@ namespace Elixir context, sizeof(SMaterialFrameData) * capacity) ), - m_Textures(context) {} + m_Textures(context), + m_Renderer(CreateScope( + context, + m_FrameBuffer, + m_Textures + )) {} SMaterialFrameSnapshot MaterialSystem::BuildFrameSnapshot( - std::span> materials, - std::span> textures, - uint64_t submissionSerial + const std::span> materials, + const std::span> textures, + const uint64_t submissionSerial ) { m_Textures.BeginFrame(submissionSerial); @@ -48,6 +53,21 @@ namespace Elixir return { table, table->GetCount(), submissionSerial }; } + std::optional MaterialSystem::GetProgramKey( + const EMaterialPass pass, + const MaterialRenderProxy& material + ) const + { + return m_Renderer->GetProgramKey(pass, material); + } + + std::optional MaterialSystem::PrepareMaterialPass( + const SMaterialPassRequest& request + ) const + { + return m_Renderer->Prepare(request); + } + uint32_t MaterialSystem::FindTextureIndex(const Ref& texture) const { return m_Textures.Find(texture); diff --git a/Elixir/Source/Engine/Material/MaterialSystem.h b/Elixir/Source/Engine/Material/MaterialSystem.h index 2e8cd36e..4cfb2a3c 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.h +++ b/Elixir/Source/Engine/Material/MaterialSystem.h @@ -2,6 +2,7 @@ #include #include +#include #include namespace Elixir @@ -24,6 +25,15 @@ namespace Elixir uint64_t submissionSerial ); + std::optional GetProgramKey( + EMaterialPass pass, + const MaterialRenderProxy& material + ) const; + + std::optional PrepareMaterialPass( + const SMaterialPassRequest& request + ) const; + const Ref& GetFrameBuffer() const { return m_FrameBuffer; } const Ref& GetTextureSet() const { return m_Textures.GetTextureSet(); } const Ref& GetSampler() const { return m_Textures.GetSampler(); } @@ -34,5 +44,6 @@ namespace Elixir uint32_t m_MaterialCapacity = 0; Ref m_FrameBuffer; MaterialTextureRegistry m_Textures; + Scope m_Renderer; }; } \ No newline at end of file diff --git a/Elixir/Tests/Engine/Material/MaterialRendererTest.cpp b/Elixir/Tests/Engine/Material/MaterialRendererTest.cpp new file mode 100644 index 00000000..d9b16a42 --- /dev/null +++ b/Elixir/Tests/Engine/Material/MaterialRendererTest.cpp @@ -0,0 +1,21 @@ +#include + +#include + +using namespace Elixir; + +TEST(MaterialRendererTest, MapsParticlePassesToMaterialUsages) +{ + EXPECT_EQ( + MaterialRenderer::GetUsage(EMaterialPass::ParticleSprite), + EMaterialUsage::ParticleSprite + ); + EXPECT_EQ( + MaterialRenderer::GetUsage(EMaterialPass::ParticleRibbon), + EMaterialUsage::ParticleRibbon + ); + EXPECT_EQ( + MaterialRenderer::GetUsage(EMaterialPass::ParticleMesh), + EMaterialUsage::ParticleMesh + ); +} \ No newline at end of file From acc669337ec11c5d706fc96b2dec51a1b40fb9a5 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Sat, 1 Aug 2026 23:31:36 -0300 Subject: [PATCH 22/89] refactor(material): centralize particle draw submission Move prepared material pass binding and draw dispatch into Engine/Material. Keep Aether responsible for particle geometry while it supplies draw callbacks for Sprite, Ribbon and Mesh. --- Elixir/Source/Engine/Aether/Renderer.cpp | 253 ++++++++++-------- .../Engine/Material/MaterialRenderer.cpp | 100 +++++-- .../Source/Engine/Material/MaterialRenderer.h | 64 ++++- .../Source/Engine/Material/MaterialSystem.h | 7 + .../Engine/Material/MaterialRendererTest.cpp | 5 + 5 files changed, 302 insertions(+), 127 deletions(-) diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index 6860e095..5e0d2124 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -1469,7 +1469,7 @@ namespace Elixir::Aether void Renderer::RenderBatch(const Ref& cmd, const SRenderBatch& batch) { - auto* runtime = FindParticleStateLayoutRuntime(batch.Key.ParticleStateLayout); + const auto* runtime = FindParticleStateLayoutRuntime(batch.Key.ParticleStateLayout); EE_CORE_ASSERT(runtime, "Aether particle state layout runtime is missing.") if (!runtime) return; @@ -1479,97 +1479,122 @@ namespace Elixir::Aether { case EParticleRenderMode::Sprite: { - Ref shader = runtime->SpriteShader; - Ref pipeline = runtime->SpritePipeline; - - if (batch.PreparedMaterial) + const auto drawSprite = [ + this, + &batch, + &particleBuffer, + useMaterial = bool(batch.PreparedMaterial) + ](const Ref& cmd, const Ref& shader) { - shader = batch.PreparedMaterial->Shader; - pipeline = batch.PreparedMaterial->Pipeline; - } + particleBuffer->BindAs(cmd); - pipeline->Bind(cmd); - particleBuffer->BindAs(cmd); + for (const auto& item : batch.Items) + { + const auto worldTransform = GetParticleRenderTransform( + *item.Emitter, + *item.Instance->Instance + ); - for (const auto& item : batch.Items) - { - const auto worldTransform = GetParticleRenderTransform( - *item.Emitter, - *item.Instance->Instance - ); + if (useMaterial) + { + const SSpritePushConstants pc{ + .WorldTransform = worldTransform, + .MaterialIndex = item.MaterialIndex, + .SpriteIndex = item.SpriteIndex, + }; + shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); + } + else + { + const SSpritePushConstants pc{ + .WorldTransform = worldTransform, + .SpriteIndex = item.SpriteIndex, + }; + shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); + } - if (batch.PreparedMaterial) - { - const SSpritePushConstants pc{ - .WorldTransform = worldTransform, - .MaterialIndex = item.MaterialIndex, - .SpriteIndex = item.SpriteIndex, - }; - shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - } - else - { - const SSpritePushConstants pc{ - .WorldTransform = worldTransform, - .SpriteIndex = item.SpriteIndex, - }; - shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); + cmd->Draw( + 6, + item.Emitter->MaxParticles, + 0, + item.Instance->Allocation.Particles.Offset + item.Emitter->LocalParticleOffset + ); } + }; - cmd->Draw( - 6, - item.Emitter->MaxParticles, - 0, - item.Instance->Allocation.Particles.Offset + item.Emitter->LocalParticleOffset + if (batch.PreparedMaterial) + { + m_MaterialSystem->DrawMaterial( + { + .CommandBuffer = cmd, + .MaterialPass = &*batch.PreparedMaterial, + }, + drawSprite ); } + else + { + runtime->SpritePipeline->Bind(cmd); + drawSprite(cmd, runtime->SpriteShader); + } return; } case EParticleRenderMode::Ribbon: { - Ref shader = runtime->RibbonShader; - Ref pipeline = runtime->RibbonPipeline; - - if (batch.PreparedMaterial) + const auto drawRibbon = [ + &batch, + useMaterial = bool(batch.PreparedMaterial) + ](const Ref& cmd, const Ref& shader) { - shader = batch.PreparedMaterial->Shader; - pipeline = batch.PreparedMaterial->Pipeline; - } - - pipeline->Bind(cmd); + for (const auto& item : batch.Items) + { + const auto worldTransform = GetParticleRenderTransform( + *item.Emitter, + *item.Instance->Instance + ); - for (const auto& item : batch.Items) - { - const auto worldTransform = GetParticleRenderTransform( - *item.Emitter, - *item.Instance->Instance - ); + if (useMaterial) + { + const SRibbonPushConstants pc{ + .WorldTransform = worldTransform, + .EmitterIndex = item.Instance->Allocation.Emitters.Offset + item.LocalEmitterIndex, + .ParticleBaseOffset = item.Instance->Allocation.Particles.Offset, + .MaterialIndex = item.MaterialIndex, + }; + + shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); + } + else + { + const SRibbonPushConstants pc{ + .WorldTransform = worldTransform, + .EmitterIndex = item.Instance->Allocation.Emitters.Offset + item.LocalEmitterIndex, + .ParticleBaseOffset = item.Instance->Allocation.Particles.Offset, + }; - if (batch.PreparedMaterial) - { - const SRibbonPushConstants pc{ - .WorldTransform = worldTransform, - .EmitterIndex = item.Instance->Allocation.Emitters.Offset + item.LocalEmitterIndex, - .ParticleBaseOffset = item.Instance->Allocation.Particles.Offset, - .MaterialIndex = item.MaterialIndex, - }; - - shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - } - else - { - const SRibbonPushConstants pc{ - .WorldTransform = worldTransform, - .EmitterIndex = item.Instance->Allocation.Emitters.Offset + item.LocalEmitterIndex, - .ParticleBaseOffset = item.Instance->Allocation.Particles.Offset, - }; + shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); + } - shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); + cmd->Draw(item.Emitter->MaxParticles * 6); } + }; - cmd->Draw(item.Emitter->MaxParticles * 6); + if (batch.PreparedMaterial) + { + m_MaterialSystem->DrawMaterial( + { + .CommandBuffer = cmd, + .MaterialPass = &*batch.PreparedMaterial, + }, + drawRibbon + ); + } + else + { + runtime->RibbonPipeline->Bind(cmd); + drawRibbon(cmd, runtime->RibbonShader); } return; @@ -1577,51 +1602,67 @@ namespace Elixir::Aether case EParticleRenderMode::Mesh: { - Ref shader = runtime->MeshShader; - Ref pipeline = runtime->MeshPipeline; - - if (batch.PreparedMaterial) + const auto drawMesh = [ + this, + &batch, + &particleBuffer, + useMaterial = bool(batch.PreparedMaterial) + ](const Ref& cmd, const Ref& shader) { - shader = batch.PreparedMaterial->Shader; - pipeline = batch.PreparedMaterial->Pipeline; - } + m_MeshVertexBuffer->Bind(cmd); - if (!shader || !pipeline) return; + // TODO: Enhance this api + particleBuffer->BindAs(cmd, std::span{}, 1, 1); - pipeline->Bind(cmd); - m_MeshVertexBuffer->Bind(cmd); - // TODO: Enhance this api - particleBuffer->BindAs(cmd, std::span{}, 1, 1); + for (const auto& item : batch.Items) + { + const auto worldTransform = GetParticleRenderTransform( + *item.Emitter, + *item.Instance->Instance + ); - for (const auto& item : batch.Items) - { - const auto worldTransform = GetParticleRenderTransform(*item.Emitter, *item.Instance->Instance); + if (useMaterial) + { + const SMeshPushConstants pc{ + .WorldTransform = worldTransform, + .MaterialIndex = item.MaterialIndex + }; - if (batch.PreparedMaterial) - { - const SMeshPushConstants pc{ - .WorldTransform = worldTransform, - .MaterialIndex = item.MaterialIndex - }; + shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); + } + else + { + const SMeshPushConstants pc{ + .WorldTransform = worldTransform + }; - shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - } - else - { - const SMeshPushConstants pc{ - .WorldTransform = worldTransform - }; + shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); + } - shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); + cmd->Draw( + m_MeshVertexCount, + item.Emitter->MaxParticles, + 0, + item.Instance->Allocation.Particles.Offset + item.Emitter->LocalParticleOffset + ); } + }; - cmd->Draw( - m_MeshVertexCount, - item.Emitter->MaxParticles, - 0, - item.Instance->Allocation.Particles.Offset + item.Emitter->LocalParticleOffset + if (batch.PreparedMaterial) + { + m_MaterialSystem->DrawMaterial( + { + .CommandBuffer = cmd, + .MaterialPass = &*batch.PreparedMaterial, + }, + drawMesh ); } + else if (runtime->MeshShader && runtime->MeshPipeline) + { + runtime->MeshPipeline->Bind(cmd); + drawMesh(cmd, runtime->MeshShader); + } return; } diff --git a/Elixir/Source/Engine/Material/MaterialRenderer.cpp b/Elixir/Source/Engine/Material/MaterialRenderer.cpp index 227b107e..4efd7c13 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderer.cpp +++ b/Elixir/Source/Engine/Material/MaterialRenderer.cpp @@ -57,6 +57,9 @@ namespace Elixir 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( @@ -66,26 +69,6 @@ namespace Elixir ); } - 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 - ); - } - - shader->BindStorageBuffer("materials", m_FrameBuffer); - shader->BindTextureSet("sprites", m_Textures.GetTextureSet()); - shader->BindSampler("spriteSampler", m_Textures.GetSampler()); - return SPreparedMaterialPass{ .Shader = shader, .Pipeline = GetPipeline(request.Pass, shader, request.Pipeline), @@ -146,4 +129,81 @@ namespace Elixir m_Pipelines.emplace(key, pipeline); return pipeline; } + + bool MaterialRenderer::BindDescriptorResources( + const Ref& shader, + const SMaterialPassRequest& 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; + } + + 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 + ); + } + + shader->BindStorageBuffer("materials", m_FrameBuffer); + shader->BindTextureSet("sprites", m_Textures.GetTextureSet()); + shader->BindSampler("spriteSampler", m_Textures.GetSampler()); + + m_DescriptorBindings.emplace(shader.get(), std::move(state)); + return true; + } } diff --git a/Elixir/Source/Engine/Material/MaterialRenderer.h b/Elixir/Source/Engine/Material/MaterialRenderer.h index 13dd0e60..38f1d04f 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderer.h +++ b/Elixir/Source/Engine/Material/MaterialRenderer.h @@ -1,5 +1,8 @@ #pragma once +#include +#include +#include #include #include @@ -48,6 +51,11 @@ namespace Elixir { std::span ConstantBuffers; std::span StorageBuffers; + + uint32_t GetResourceCount() const + { + return (uint32_t)(ConstantBuffers.size() + StorageBuffers.size()); + } }; struct SMaterialPassRequest @@ -67,6 +75,17 @@ namespace Elixir explicit operator bool() const { return Shader && Pipeline; } }; + struct SMaterialDrawRequest + { + Ref CommandBuffer; + const SPreparedMaterialPass* MaterialPass = nullptr; + + explicit operator bool() const + { + return CommandBuffer && MaterialPass && *MaterialPass; + } + }; + class ELIXIR_API MaterialRenderer final { public: @@ -87,7 +106,44 @@ namespace Elixir const SMaterialPassRequest& request ); + template + requires std::invocable&, const Ref&> + void Draw(const SMaterialDrawRequest& request, T&& recordGeometry) const + { + if (!request) return; + + request.MaterialPass->Pipeline->Bind(request.CommandBuffer); + std::forward(recordGeometry)( + request.CommandBuffer, + request.MaterialPass->Shader + ); + } + private: + enum class EDescriptorBindingType : uint8_t + { + ConstantBuffer, + StorageBuffer, + DynamicStorageBuffer, + }; + + struct SDescriptorBinding + { + std::string Name; + const void* Resource = nullptr; + EDescriptorBindingType Type = EDescriptorBindingType::ConstantBuffer; + + bool operator==(const SDescriptorBinding&) const = default; + }; + + struct SDescriptorBindingState + { + EMaterialPass Pass = EMaterialPass::ParticleSprite; + std::vector ExternalResources; + + bool operator==(const SDescriptorBindingState&) const = default; + }; + struct SPipelineKey { EMaterialPass Pass = EMaterialPass::ParticleSprite; @@ -116,10 +172,16 @@ namespace Elixir const SMaterialPipelineRequest& request ); + bool BindDescriptorResources( + const Ref& shader, + const SMaterialPassRequest& request + ); + Ref m_FrameBuffer; const MaterialTextureRegistry& m_Textures; std::unordered_map, SPipelineKeyHasher> m_Pipelines; + std::unordered_map m_DescriptorBindings; const GraphicsContext* m_Context = nullptr; }; -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/Material/MaterialSystem.h b/Elixir/Source/Engine/Material/MaterialSystem.h index 4cfb2a3c..19bfa097 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.h +++ b/Elixir/Source/Engine/Material/MaterialSystem.h @@ -34,6 +34,13 @@ namespace Elixir const SMaterialPassRequest& request ) const; + template + requires std::invocable&, const Ref&> + void DrawMaterial(const SMaterialDrawRequest& request, T&& recordGeometry) const + { + m_Renderer->Draw(request, std::forward(recordGeometry)); + } + const Ref& GetFrameBuffer() const { return m_FrameBuffer; } const Ref& GetTextureSet() const { return m_Textures.GetTextureSet(); } const Ref& GetSampler() const { return m_Textures.GetSampler(); } diff --git a/Elixir/Tests/Engine/Material/MaterialRendererTest.cpp b/Elixir/Tests/Engine/Material/MaterialRendererTest.cpp index d9b16a42..d584edbc 100644 --- a/Elixir/Tests/Engine/Material/MaterialRendererTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialRendererTest.cpp @@ -18,4 +18,9 @@ TEST(MaterialRendererTest, MapsParticlePassesToMaterialUsages) MaterialRenderer::GetUsage(EMaterialPass::ParticleMesh), EMaterialUsage::ParticleMesh ); +} + +TEST(MaterialRendererTest, RejectsAndIncompleteDrawRequest) +{ + EXPECT_FALSE(static_cast(SMaterialDrawRequest{})); } \ No newline at end of file From b913a4bbfdcd19f351380796d22da245fb161f59 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Sun, 2 Aug 2026 13:10:57 -0300 Subject: [PATCH 23/89] refactor(material): remove legacy particle graphics paths Compile and own default particle material proxies in Engine/Material. Keep Aether responsible for particle geometry and submit every Sprite, Ribbon and Mesh batch through a prepared material pass. --- Elixir/Source/Engine/Aether/Renderer.cpp | 334 +++++------------- Elixir/Source/Engine/Aether/Renderer.h | 15 +- .../Source/Engine/Material/MaterialSystem.cpp | 60 +++- .../Source/Engine/Material/MaterialSystem.h | 21 +- .../Material/ParticleMaterialDefaults.cpp | 37 ++ .../Material/ParticleMaterialDefaults.h | 10 + .../Material/ParticleMaterialDefaultsTest.cpp | 21 ++ 7 files changed, 244 insertions(+), 254 deletions(-) create mode 100644 Elixir/Source/Engine/Material/ParticleMaterialDefaults.cpp create mode 100644 Elixir/Source/Engine/Material/ParticleMaterialDefaults.h create mode 100644 Elixir/Tests/Engine/Material/ParticleMaterialDefaultsTest.cpp diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index 5e0d2124..7c1bbb79 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -2,7 +2,6 @@ #include "Renderer.h" #include "Engine/Graphics/CommandBuffer.h" -#include "Engine/Graphics/Pipeline/PipelineBuilder.h" namespace Elixir::Aether { @@ -163,7 +162,11 @@ namespace Elixir::Aether ) : m_ParticlePoolLimits(limits), m_ParticleStateLayouts(m_ParticlePoolLimits.ParticleCapacity), m_ParticleResourcePool(m_ParticlePoolLimits, m_ParticleStateLayouts), - m_MaterialSystem(CreateRef(context, limits.MaterialCapacity)), + m_MaterialSystem(CreateRef( + context, + shaderLoader, + limits.MaterialCapacity + )), m_GraphicsContext(context) { static_assert(sizeof(SGPUParticleState) == PARTICLE_STATE_CORE_V1_STRIDE); @@ -401,24 +404,6 @@ namespace Elixir::Aether 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); @@ -426,7 +411,7 @@ namespace Elixir::Aether pipelineInfo.Shader = runtime.UpdateShader; runtime.UpdatePipeline = ComputePipeline::Create(m_GraphicsContext, pipelineInfo); - const BufferLayout spriteBufferLayout({ + runtime.SpriteVertexLayout = {{ { { { EDataType::Vec4, "PositionSize" }, @@ -438,33 +423,9 @@ namespace Elixir::Aether }, 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({ + runtime.MeshVertexLayout = {{ { { { EDataType::Vec3, "Position" }, @@ -483,24 +444,7 @@ namespace Elixir::Aether }, 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() @@ -660,7 +604,7 @@ namespace Elixir::Aether ) if (!coreV1Runtime) return; - m_MeshVertexBuffer->SetLayout(coreV1Runtime->MeshPipeline->GetBufferLayout()); + m_MeshVertexBuffer->SetLayout(coreV1Runtime->MeshVertexLayout); } void Renderer::InitPerFrameData() @@ -807,39 +751,6 @@ namespace Elixir::Aether runtime.UpdateShader->BindStorageBuffer("ops", m_OpBuffer); runtime.UpdateShader->BindStorageBuffer("parameters", m_ParameterBuffer); runtime.UpdateShader->BindConstantBuffer("cbParams", m_ParamsBuffer); - - const SSpritePushConstants spritePushConstants{ - .SpriteIndex = m_MaterialSystem->GetFallbackTextureIndex(), - }; - runtime.SpriteShader->SetPushConstant( - "pc", - (void*)&spritePushConstants, - sizeof(spritePushConstants) - ); - - runtime.SpriteShader->BindConstantBuffer("cbFrame", m_FrameConstantBuffer); - runtime.SpriteShader->BindTextureSet("sprites", m_MaterialSystem->GetTextureSet()); - runtime.SpriteShader->BindSampler("spriteSampler", m_MaterialSystem->GetSampler()); - - 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); } void Renderer::BeginRendering(const Ref& cmd) const @@ -1122,42 +1033,40 @@ namespace Elixir::Aether if (emitter.MaxParticles == 0) continue; - const MaterialRenderProxy* material = emitter.Material.get(); - SMaterialProgramKey materialProgram; - uint32_t materialIndex = UINT32_MAX; + EMaterialPass materialPass; + if (!TryToGetParticleMaterialPass(emitter.RenderMode, materialPass)) + continue; + + const auto& materialRef = m_MaterialSystem->ResolveParticleMaterial( + materialPass, + emitter.Material + ); + + const auto* material = materialRef.get(); uint32_t spriteIndex = m_MaterialSystem->GetFallbackTextureIndex(); if (emitter.RenderMode == EParticleRenderMode::Sprite) spriteIndex = m_MaterialSystem->FindTextureIndex(emitter.SpriteTexture); - EMaterialPass materialPass; - if (material && TryToGetParticleMaterialPass(emitter.RenderMode, materialPass)) - { - const auto program = m_MaterialSystem->GetProgramKey(materialPass, *material); - if (program) - { - const auto index = materials.Table->Find(*material); - EE_CORE_ASSERT(index, "Material frame snapshot is missing an emitter material.") - - if (index) - { - materialProgram = *program; - materialIndex = *index; - } - else - { - EE_CORE_ERROR( - "Aether particle material capacity ({}) exceeded.", - m_ParticlePoolLimits.MaterialCapacity - ) - } - } - } + const auto program = m_MaterialSystem->GetProgramKey( + materialPass, + *material + ); + + EE_CORE_ASSERT( + program, + "Resolved particle material must provide its shader permutation." + ) + if (!program) continue; + + const auto index = materials.Table->Find(*material); + EE_CORE_ASSERT(index, "Material frame snapshot is missing an emitter material.") + if (!index) continue; const SRenderBatchKey key{ .ParticleStateLayout = instance.ParticleStateLayout, .RenderMode = emitter.RenderMode, - .MaterialProgram = materialProgram, + .MaterialProgram = *program, }; SRenderBatch* batch = nullptr; @@ -1183,7 +1092,7 @@ namespace Elixir::Aether .Instance = &instance, .Emitter = &emitter, .Material = material, - .MaterialIndex = materialIndex, + .MaterialIndex = *index, .SpriteIndex = spriteIndex, .LocalEmitterIndex = emitterIndex, }); @@ -1226,8 +1135,7 @@ namespace Elixir::Aether for (auto& batch : batches) { - if (!batch.Key.MaterialProgram || batch.Items.empty() || - !batch.Items.front().Material) + if (!batch.Key.MaterialProgram || batch.Items.empty()) continue; const auto* runtime = FindParticleStateLayoutRuntime(batch.Key.ParticleStateLayout); @@ -1249,7 +1157,7 @@ namespace Elixir::Aether .Material = material, .Pipeline = { .VertexLayoutKey = uint64_t(runtime->Key), - .VertexLayout = &runtime->SpritePipeline->GetBufferLayout(), + .VertexLayout = &runtime->SpriteVertexLayout, }, .ExternalResources = { .ConstantBuffers = constantBuffers, @@ -1299,7 +1207,7 @@ namespace Elixir::Aether .Material = material, .Pipeline = { .VertexLayoutKey = uint64_t(runtime->Key), - .VertexLayout = &runtime->MeshPipeline->GetBufferLayout(), + .VertexLayout = &runtime->MeshVertexLayout, }, .ExternalResources = { .ConstantBuffers = constantBuffers, @@ -1327,8 +1235,16 @@ namespace Elixir::Aether if (emitter.MaxParticles == 0) continue; - if (emitter.Material) - inputs.Materials.push_back(emitter.Material); + EMaterialPass pass; + if (!TryToGetParticleMaterialPass(emitter.RenderMode, pass)) + continue; + + const auto& material = m_MaterialSystem->ResolveParticleMaterial( + pass, + emitter.Material + ); + + inputs.Materials.push_back(material); if (emitter.RenderMode == EParticleRenderMode::Sprite && emitter.SpriteTexture) inputs.Textures.push_back(emitter.SpriteTexture); @@ -1469,6 +1385,9 @@ namespace Elixir::Aether void Renderer::RenderBatch(const Ref& cmd, const SRenderBatch& batch) { + if (!batch.PreparedMaterial) + return; + const auto* runtime = FindParticleStateLayoutRuntime(batch.Key.ParticleStateLayout); EE_CORE_ASSERT(runtime, "Aether particle state layout runtime is missing.") if (!runtime) return; @@ -1480,10 +1399,8 @@ namespace Elixir::Aether case EParticleRenderMode::Sprite: { const auto drawSprite = [ - this, &batch, - &particleBuffer, - useMaterial = bool(batch.PreparedMaterial) + &particleBuffer ](const Ref& cmd, const Ref& shader) { particleBuffer->BindAs(cmd); @@ -1495,24 +1412,13 @@ namespace Elixir::Aether *item.Instance->Instance ); - if (useMaterial) - { - const SSpritePushConstants pc{ - .WorldTransform = worldTransform, - .MaterialIndex = item.MaterialIndex, - .SpriteIndex = item.SpriteIndex, - }; - shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - } - else - { - const SSpritePushConstants pc{ - .WorldTransform = worldTransform, - .SpriteIndex = item.SpriteIndex, - }; - shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - } + const SSpritePushConstants pc{ + .WorldTransform = worldTransform, + .MaterialIndex = item.MaterialIndex, + .SpriteIndex = item.SpriteIndex, + }; + shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); cmd->Draw( 6, item.Emitter->MaxParticles, @@ -1522,21 +1428,13 @@ namespace Elixir::Aether } }; - if (batch.PreparedMaterial) - { - m_MaterialSystem->DrawMaterial( - { - .CommandBuffer = cmd, - .MaterialPass = &*batch.PreparedMaterial, - }, - drawSprite - ); - } - else - { - runtime->SpritePipeline->Bind(cmd); - drawSprite(cmd, runtime->SpriteShader); - } + m_MaterialSystem->DrawMaterial( + { + .CommandBuffer = cmd, + .MaterialPass = &*batch.PreparedMaterial, + }, + drawSprite + ); return; } @@ -1544,8 +1442,7 @@ namespace Elixir::Aether case EParticleRenderMode::Ribbon: { const auto drawRibbon = [ - &batch, - useMaterial = bool(batch.PreparedMaterial) + &batch ](const Ref& cmd, const Ref& shader) { for (const auto& item : batch.Items) @@ -1555,47 +1452,25 @@ namespace Elixir::Aether *item.Instance->Instance ); - if (useMaterial) - { - const SRibbonPushConstants pc{ - .WorldTransform = worldTransform, - .EmitterIndex = item.Instance->Allocation.Emitters.Offset + item.LocalEmitterIndex, - .ParticleBaseOffset = item.Instance->Allocation.Particles.Offset, - .MaterialIndex = item.MaterialIndex, - }; - - shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - } - else - { - const SRibbonPushConstants pc{ - .WorldTransform = worldTransform, - .EmitterIndex = item.Instance->Allocation.Emitters.Offset + item.LocalEmitterIndex, - .ParticleBaseOffset = item.Instance->Allocation.Particles.Offset, - }; - - shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - } + const SRibbonPushConstants pc{ + .WorldTransform = worldTransform, + .EmitterIndex = item.Instance->Allocation.Emitters.Offset + item.LocalEmitterIndex, + .ParticleBaseOffset = item.Instance->Allocation.Particles.Offset, + .MaterialIndex = item.MaterialIndex, + }; + shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); cmd->Draw(item.Emitter->MaxParticles * 6); } }; - if (batch.PreparedMaterial) - { - m_MaterialSystem->DrawMaterial( - { - .CommandBuffer = cmd, - .MaterialPass = &*batch.PreparedMaterial, - }, - drawRibbon - ); - } - else - { - runtime->RibbonPipeline->Bind(cmd); - drawRibbon(cmd, runtime->RibbonShader); - } + m_MaterialSystem->DrawMaterial( + { + .CommandBuffer = cmd, + .MaterialPass = &*batch.PreparedMaterial, + }, + drawRibbon + ); return; } @@ -1605,8 +1480,7 @@ namespace Elixir::Aether const auto drawMesh = [ this, &batch, - &particleBuffer, - useMaterial = bool(batch.PreparedMaterial) + &particleBuffer ](const Ref& cmd, const Ref& shader) { m_MeshVertexBuffer->Bind(cmd); @@ -1621,24 +1495,12 @@ namespace Elixir::Aether *item.Instance->Instance ); - if (useMaterial) - { - const SMeshPushConstants pc{ - .WorldTransform = worldTransform, - .MaterialIndex = item.MaterialIndex - }; - - shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - } - else - { - const SMeshPushConstants pc{ - .WorldTransform = worldTransform - }; - - shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - } + const SMeshPushConstants pc{ + .WorldTransform = worldTransform, + .MaterialIndex = item.MaterialIndex + }; + shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); cmd->Draw( m_MeshVertexCount, item.Emitter->MaxParticles, @@ -1648,21 +1510,13 @@ namespace Elixir::Aether } }; - if (batch.PreparedMaterial) - { - m_MaterialSystem->DrawMaterial( - { - .CommandBuffer = cmd, - .MaterialPass = &*batch.PreparedMaterial, - }, - drawMesh - ); - } - else if (runtime->MeshShader && runtime->MeshPipeline) - { - runtime->MeshPipeline->Bind(cmd); - drawMesh(cmd, runtime->MeshShader); - } + m_MaterialSystem->DrawMaterial( + { + .CommandBuffer = cmd, + .MaterialPass = &*batch.PreparedMaterial, + }, + drawMesh + ); return; } diff --git a/Elixir/Source/Engine/Aether/Renderer.h b/Elixir/Source/Engine/Aether/Renderer.h index a6c58f78..98622ba8 100644 --- a/Elixir/Source/Engine/Aether/Renderer.h +++ b/Elixir/Source/Engine/Aether/Renderer.h @@ -172,21 +172,16 @@ namespace Elixir::Aether Ref UpdateShader; Ref UpdatePipeline; - Ref SpriteShader; - Ref SpritePipeline; - Ref RibbonShader; - Ref RibbonPipeline; - Ref MeshShader; - Ref MeshPipeline; + // Geometry ABI owned by Particles System. MaterialRenderer receives these + // layouts to create the pipeline for the selected material pass. + BufferLayout SpriteVertexLayout; + BufferLayout MeshVertexLayout; bool IsReady() const { return ParticleStateBuffer && SpawnShader && SpawnPipeline && - UpdateShader && UpdatePipeline && - SpriteShader && SpritePipeline && - RibbonShader && RibbonPipeline && - MeshShader && MeshPipeline; + UpdateShader && UpdatePipeline; } }; diff --git a/Elixir/Source/Engine/Material/MaterialSystem.cpp b/Elixir/Source/Engine/Material/MaterialSystem.cpp index 12e182f1..e373c827 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.cpp +++ b/Elixir/Source/Engine/Material/MaterialSystem.cpp @@ -1,10 +1,17 @@ #include "epch.h" #include "MaterialSystem.h" +#include "ParticleMaterialDefaults.h" + +#include +#include namespace Elixir { - MaterialSystem::MaterialSystem(const GraphicsContext* context, const uint32_t capacity) - : m_MaterialCapacity(capacity), + MaterialSystem::MaterialSystem( + const GraphicsContext* context, + const ShaderLoader* shaderLoader, + const uint32_t capacity + ) : m_MaterialCapacity(capacity), m_FrameBuffer(DynamicStorageBuffer::Create( context, sizeof(SMaterialFrameData) * capacity) @@ -14,7 +21,10 @@ namespace Elixir context, m_FrameBuffer, m_Textures - )) {} + )) + { + CreateDefaultParticleMaterials(shaderLoader); + } SMaterialFrameSnapshot MaterialSystem::BuildFrameSnapshot( const std::span> materials, @@ -61,6 +71,20 @@ namespace Elixir return m_Renderer->GetProgramKey(pass, material); } + const Ref& MaterialSystem::ResolveParticleMaterial( + const EMaterialPass pass, + const Ref& authoredMaterial + ) const + { + if (authoredMaterial && m_Renderer->GetProgramKey(pass, *authoredMaterial)) + return authoredMaterial; + + const auto& fallback = m_DefaultParticleMaterials[GetParticleMaterialSlot(pass)]; + EE_CORE_ASSERT(fallback, "MaterialSystem default particle material is unavailable.") + + return fallback; + } + std::optional MaterialSystem::PrepareMaterialPass( const SMaterialPassRequest& request ) const @@ -72,4 +96,34 @@ namespace Elixir { return m_Textures.Find(texture); } + + void MaterialSystem::CreateDefaultParticleMaterials(const ShaderLoader* shaderLoader) + { + for (const auto pass : { + EMaterialPass::ParticleSprite, + EMaterialPass::ParticleRibbon, + EMaterialPass::ParticleMesh, + }) + { + const auto source = CreateDefaultParticleMaterial(MaterialRenderer::GetUsage(pass)); + const auto compiled = MaterialCompiler::Compile(shaderLoader, *source); + + EE_CORE_ASSERT( + compiled, + "Default particle material compilation failed: {}", + compiled.Diagnostics + ) + if (!compiled) continue; + + const auto instance = CreateRef(source); + const auto proxy = instance->CreateRenderProxy(compiled.Material); + + EE_CORE_ASSERT( + proxy, + "Default particle material render proxy creation failed." + ) + + m_DefaultParticleMaterials[GetParticleMaterialSlot(pass)] = proxy; + } + } } diff --git a/Elixir/Source/Engine/Material/MaterialSystem.h b/Elixir/Source/Engine/Material/MaterialSystem.h index 19bfa097..60e62b91 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.h +++ b/Elixir/Source/Engine/Material/MaterialSystem.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -17,7 +18,11 @@ namespace Elixir class ELIXIR_API MaterialSystem final { public: - MaterialSystem(const GraphicsContext* context, uint32_t capacity); + MaterialSystem( + const GraphicsContext* context, + const ShaderLoader* shaderLoader, + uint32_t capacity + ); SMaterialFrameSnapshot BuildFrameSnapshot( std::span> materials, @@ -30,6 +35,11 @@ namespace Elixir const MaterialRenderProxy& material ) const; + const Ref& ResolveParticleMaterial( + EMaterialPass pass, + const Ref& authoredMaterial + ) const; + std::optional PrepareMaterialPass( const SMaterialPassRequest& request ) const; @@ -48,9 +58,18 @@ namespace Elixir uint32_t FindTextureIndex(const Ref& texture) const; private: + static constexpr size_t GetParticleMaterialSlot(const EMaterialPass pass) + { + return static_cast(pass); + } + + void CreateDefaultParticleMaterials(const ShaderLoader* shaderLoader); + uint32_t m_MaterialCapacity = 0; Ref m_FrameBuffer; MaterialTextureRegistry m_Textures; Scope m_Renderer; + + std::array, 3> m_DefaultParticleMaterials; }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/ParticleMaterialDefaults.cpp b/Elixir/Source/Engine/Material/ParticleMaterialDefaults.cpp new file mode 100644 index 00000000..1e97a3d1 --- /dev/null +++ b/Elixir/Source/Engine/Material/ParticleMaterialDefaults.cpp @@ -0,0 +1,37 @@ +#include "epch.h" +#include "ParticleMaterialDefaults.h" + +namespace Elixir +{ + namespace + { + std::string GetDefaultParticleMaterialName(const EMaterialUsage usage) + { + switch (usage) + { + case EMaterialUsage::ParticleSprite: + return "Engine.DefaultParticleSprite"; + case EMaterialUsage::ParticleRibbon: + return "Engine.DefaultParticleRibbon"; + case EMaterialUsage::ParticleMesh: + return "Engine.DefaultParticleMesh"; + } + + return "Engine.DefaultParticle"; + } + } + + Ref CreateDefaultParticleMaterial(const EMaterialUsage usage) + { + const auto name = GetDefaultParticleMaterialName(usage); + const auto material = CreateRef(name); + + const bool usageWasEnabled = material->SetUsage(usage, true); + EE_CORE_ASSERT( + usageWasEnabled, + "A default particle material must enable its particle usage." + ) + + return material; + } +} diff --git a/Elixir/Source/Engine/Material/ParticleMaterialDefaults.h b/Elixir/Source/Engine/Material/ParticleMaterialDefaults.h new file mode 100644 index 00000000..43678452 --- /dev/null +++ b/Elixir/Source/Engine/Material/ParticleMaterialDefaults.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +namespace Elixir +{ + // Creates the engine-owned source material used when a particle emitter + // has no authored material proxy for a supported particle usage. + Ref CreateDefaultParticleMaterial(EMaterialUsage usage); +} \ No newline at end of file diff --git a/Elixir/Tests/Engine/Material/ParticleMaterialDefaultsTest.cpp b/Elixir/Tests/Engine/Material/ParticleMaterialDefaultsTest.cpp new file mode 100644 index 00000000..671b5c60 --- /dev/null +++ b/Elixir/Tests/Engine/Material/ParticleMaterialDefaultsTest.cpp @@ -0,0 +1,21 @@ +#include + +#include + +using namespace Elixir; + +TEST(ParticleMaterialDefaultsTest, CreatesAValidMaterialForEachParticleUsage) +{ + for (const auto usage: { + EMaterialUsage::ParticleSprite, + EMaterialUsage::ParticleRibbon, + EMaterialUsage::ParticleMesh, + }) + { + const auto material = CreateDefaultParticleMaterial(usage); + + ASSERT_TRUE(material); + EXPECT_TRUE(material->SupportsUsage(usage)); + EXPECT_TRUE(material->ValidateGraph()); + } +} \ No newline at end of file From 2a1a184b9ba82b5567e63f57e8b1941eb5e7b6bd Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Sun, 2 Aug 2026 14:56:25 -0300 Subject: [PATCH 24/89] refactor(material): resolve particle defaults at effect import Move default particle material proxy ownership from MaterialSystem to ParticleMaterialLibrary. Bind an explicit proxy while loading effects so Aether renders only compiled emitter materials. --- Dissolve/Source/Dissolve.cpp | 14 ++++- Elixir/Source/Engine/Aether/Effect.cpp | 34 +++++++++-- Elixir/Source/Engine/Aether/Effect.h | 10 +++- Elixir/Source/Engine/Aether/Renderer.cpp | 36 +++++------ .../Source/Engine/Material/MaterialSystem.cpp | 60 +------------------ .../Source/Engine/Material/MaterialSystem.h | 21 +------ .../Material/ParticleMaterialLibrary.cpp | 52 ++++++++++++++++ .../Engine/Material/ParticleMaterialLibrary.h | 25 ++++++++ Elixir/Tests/Engine/Aether/SystemTest.cpp | 11 ++++ 9 files changed, 161 insertions(+), 102 deletions(-) create mode 100644 Elixir/Source/Engine/Material/ParticleMaterialLibrary.cpp create mode 100644 Elixir/Source/Engine/Material/ParticleMaterialLibrary.h diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index a6cb4d4f..e04fff97 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -8,6 +8,7 @@ #include #include #include +#include Ref pipeline; Scope m_ParticlesRenderer; @@ -17,6 +18,7 @@ std::array, 2> m_ParticleSystemInstances; Ref graphMaterial; Ref compiledGraphMaterial; +Scope particleMaterialLibrary; Dissolve::Dissolve() { @@ -65,8 +67,16 @@ Dissolve::Dissolve() 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"); + particleMaterialLibrary = CreateScope(m_ShaderLoader.get()); + + m_ParticleSystems[0] = Aether::LoadEffectFile( + "./Assets/VFX/FireAndFireworks.json", + *particleMaterialLibrary + ); + m_ParticleSystems[1] = Aether::LoadEffectFile( + "./Assets/VFX/RibbonVortex.json", + *particleMaterialLibrary + ); { MaterialGraph graph; diff --git a/Elixir/Source/Engine/Aether/Effect.cpp b/Elixir/Source/Engine/Aether/Effect.cpp index 8e6cd68a..a81252a4 100644 --- a/Elixir/Source/Engine/Aether/Effect.cpp +++ b/Elixir/Source/Engine/Aether/Effect.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -37,6 +38,21 @@ namespace Elixir::Aether return std::nullopt; } + EMaterialUsage GetParticleMaterialUsage(const EParticleRenderMode mode) + { + switch (mode) + { + case EParticleRenderMode::Sprite: + return EMaterialUsage::ParticleSprite; + case EParticleRenderMode::Ribbon: + return EMaterialUsage::ParticleRibbon; + case EParticleRenderMode::Mesh: + return EMaterialUsage::ParticleMesh; + } + + return EMaterialUsage::ParticleSprite; + } + // Parses an effect asset into a System. Every parsing step funnels // failures through Fail(), which logs and latches m_Failed; helpers // short-circuit once latched so a single root cause is reported and @@ -44,8 +60,11 @@ namespace Elixir::Aether class EffectParser { public: - explicit EffectParser(std::filesystem::path filepath) - : m_Filepath(std::move(filepath)) {} + EffectParser( + std::filesystem::path filepath, + const ParticleMaterialLibrary& materials + ) : m_Filepath(std::move(filepath)), + m_Materials(materials) {} Ref Parse(od::object& root); @@ -856,6 +875,9 @@ namespace Elixir::Aether auto& emitter = system->AddEmitter(name, maxParticles, spawnRate.Value); emitter.SetRenderMode(renderMode); + emitter.SetMaterial( + m_Materials.GetDefault(GetParticleMaterialUsage(renderMode)) + ); if (HasField(json, "burst")) { @@ -909,6 +931,7 @@ namespace Elixir::Aether } std::filesystem::path m_Filepath; + const ParticleMaterialLibrary& m_Materials; bool m_Failed = false; }; @@ -951,7 +974,10 @@ namespace Elixir::Aether } } - Ref LoadEffectFile(const std::filesystem::path& filepath) + Ref LoadEffectFile( + const std::filesystem::path& filepath, + const ParticleMaterialLibrary& materials + ) { od::parser parser; @@ -978,7 +1004,7 @@ namespace Elixir::Aether return nullptr; } - EffectParser effectParser{ filepath }; + EffectParser effectParser{ filepath, materials }; return effectParser.Parse(root); } } diff --git a/Elixir/Source/Engine/Aether/Effect.h b/Elixir/Source/Engine/Aether/Effect.h index ff74c561..eee84cce 100644 --- a/Elixir/Source/Engine/Aether/Effect.h +++ b/Elixir/Source/Engine/Aether/Effect.h @@ -2,7 +2,15 @@ #include +namespace Elixir +{ + class ParticleMaterialLibrary; +} + namespace Elixir::Aether { - ELIXIR_API Ref LoadEffectFile(const std::filesystem::path& filepath); + ELIXIR_API Ref LoadEffectFile( + const std::filesystem::path& filepath, + const ParticleMaterialLibrary& materials + ); } \ No newline at end of file diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index 7c1bbb79..1dbbcc6d 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -162,11 +162,7 @@ namespace Elixir::Aether ) : m_ParticlePoolLimits(limits), m_ParticleStateLayouts(m_ParticlePoolLimits.ParticleCapacity), m_ParticleResourcePool(m_ParticlePoolLimits, m_ParticleStateLayouts), - m_MaterialSystem(CreateRef( - context, - shaderLoader, - limits.MaterialCapacity - )), + m_MaterialSystem(CreateRef(context, limits.MaterialCapacity)), m_GraphicsContext(context) { static_assert(sizeof(SGPUParticleState) == PARTICLE_STATE_CORE_V1_STRIDE); @@ -1037,12 +1033,16 @@ namespace Elixir::Aether if (!TryToGetParticleMaterialPass(emitter.RenderMode, materialPass)) continue; - const auto& materialRef = m_MaterialSystem->ResolveParticleMaterial( - materialPass, - emitter.Material - ); + const auto* material = emitter.Material.get(); + if (!material) + { + EE_CORE_ERROR( + "Aether emitter '{}' has no compiled material proxy.", + emitter.Name + ) + continue; + } - const auto* material = materialRef.get(); uint32_t spriteIndex = m_MaterialSystem->GetFallbackTextureIndex(); if (emitter.RenderMode == EParticleRenderMode::Sprite) @@ -1235,16 +1235,16 @@ namespace Elixir::Aether if (emitter.MaxParticles == 0) continue; - EMaterialPass pass; - if (!TryToGetParticleMaterialPass(emitter.RenderMode, pass)) + if (!emitter.Material) + { + EE_CORE_ERROR( + "Aether emitter '{}' has no compiled material proxy.", + emitter.Name + ); continue; + } - const auto& material = m_MaterialSystem->ResolveParticleMaterial( - pass, - emitter.Material - ); - - inputs.Materials.push_back(material); + inputs.Materials.push_back(emitter.Material); if (emitter.RenderMode == EParticleRenderMode::Sprite && emitter.SpriteTexture) inputs.Textures.push_back(emitter.SpriteTexture); diff --git a/Elixir/Source/Engine/Material/MaterialSystem.cpp b/Elixir/Source/Engine/Material/MaterialSystem.cpp index e373c827..12e182f1 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.cpp +++ b/Elixir/Source/Engine/Material/MaterialSystem.cpp @@ -1,17 +1,10 @@ #include "epch.h" #include "MaterialSystem.h" -#include "ParticleMaterialDefaults.h" - -#include -#include namespace Elixir { - MaterialSystem::MaterialSystem( - const GraphicsContext* context, - const ShaderLoader* shaderLoader, - const uint32_t capacity - ) : m_MaterialCapacity(capacity), + MaterialSystem::MaterialSystem(const GraphicsContext* context, const uint32_t capacity) + : m_MaterialCapacity(capacity), m_FrameBuffer(DynamicStorageBuffer::Create( context, sizeof(SMaterialFrameData) * capacity) @@ -21,10 +14,7 @@ namespace Elixir context, m_FrameBuffer, m_Textures - )) - { - CreateDefaultParticleMaterials(shaderLoader); - } + )) {} SMaterialFrameSnapshot MaterialSystem::BuildFrameSnapshot( const std::span> materials, @@ -71,20 +61,6 @@ namespace Elixir return m_Renderer->GetProgramKey(pass, material); } - const Ref& MaterialSystem::ResolveParticleMaterial( - const EMaterialPass pass, - const Ref& authoredMaterial - ) const - { - if (authoredMaterial && m_Renderer->GetProgramKey(pass, *authoredMaterial)) - return authoredMaterial; - - const auto& fallback = m_DefaultParticleMaterials[GetParticleMaterialSlot(pass)]; - EE_CORE_ASSERT(fallback, "MaterialSystem default particle material is unavailable.") - - return fallback; - } - std::optional MaterialSystem::PrepareMaterialPass( const SMaterialPassRequest& request ) const @@ -96,34 +72,4 @@ namespace Elixir { return m_Textures.Find(texture); } - - void MaterialSystem::CreateDefaultParticleMaterials(const ShaderLoader* shaderLoader) - { - for (const auto pass : { - EMaterialPass::ParticleSprite, - EMaterialPass::ParticleRibbon, - EMaterialPass::ParticleMesh, - }) - { - const auto source = CreateDefaultParticleMaterial(MaterialRenderer::GetUsage(pass)); - const auto compiled = MaterialCompiler::Compile(shaderLoader, *source); - - EE_CORE_ASSERT( - compiled, - "Default particle material compilation failed: {}", - compiled.Diagnostics - ) - if (!compiled) continue; - - const auto instance = CreateRef(source); - const auto proxy = instance->CreateRenderProxy(compiled.Material); - - EE_CORE_ASSERT( - proxy, - "Default particle material render proxy creation failed." - ) - - m_DefaultParticleMaterials[GetParticleMaterialSlot(pass)] = proxy; - } - } } diff --git a/Elixir/Source/Engine/Material/MaterialSystem.h b/Elixir/Source/Engine/Material/MaterialSystem.h index 60e62b91..19bfa097 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.h +++ b/Elixir/Source/Engine/Material/MaterialSystem.h @@ -1,7 +1,6 @@ #pragma once #include -#include #include #include #include @@ -18,11 +17,7 @@ namespace Elixir class ELIXIR_API MaterialSystem final { public: - MaterialSystem( - const GraphicsContext* context, - const ShaderLoader* shaderLoader, - uint32_t capacity - ); + MaterialSystem(const GraphicsContext* context, uint32_t capacity); SMaterialFrameSnapshot BuildFrameSnapshot( std::span> materials, @@ -35,11 +30,6 @@ namespace Elixir const MaterialRenderProxy& material ) const; - const Ref& ResolveParticleMaterial( - EMaterialPass pass, - const Ref& authoredMaterial - ) const; - std::optional PrepareMaterialPass( const SMaterialPassRequest& request ) const; @@ -58,18 +48,9 @@ namespace Elixir uint32_t FindTextureIndex(const Ref& texture) const; private: - static constexpr size_t GetParticleMaterialSlot(const EMaterialPass pass) - { - return static_cast(pass); - } - - void CreateDefaultParticleMaterials(const ShaderLoader* shaderLoader); - uint32_t m_MaterialCapacity = 0; Ref m_FrameBuffer; MaterialTextureRegistry m_Textures; Scope m_Renderer; - - std::array, 3> m_DefaultParticleMaterials; }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/ParticleMaterialLibrary.cpp b/Elixir/Source/Engine/Material/ParticleMaterialLibrary.cpp new file mode 100644 index 00000000..38561df5 --- /dev/null +++ b/Elixir/Source/Engine/Material/ParticleMaterialLibrary.cpp @@ -0,0 +1,52 @@ +#include "epch.h" +#include "ParticleMaterialLibrary.h" +#include "ParticleMaterialDefaults.h" + +#include +#include + +namespace Elixir +{ + ParticleMaterialLibrary::ParticleMaterialLibrary(const ShaderLoader* shaderLoader) + { + for (const auto usage : { + EMaterialUsage::ParticleSprite, + EMaterialUsage::ParticleRibbon, + EMaterialUsage::ParticleMesh, + }) + { + const auto source = CreateDefaultParticleMaterial(usage); + const auto compiled = MaterialCompiler::Compile(shaderLoader, *source); + + EE_CORE_ASSERT( + compiled, + "Default particle material compilation failed: {}", + compiled.Diagnostics + ) + if (!compiled) continue; + + const auto instance = CreateRef(source); + const auto proxy = instance->CreateRenderProxy(compiled.Material); + + EE_CORE_ASSERT( + proxy, + "Default particle material render proxy creation failed." + ) + if (!proxy) continue; + + m_Defaults[GetSlot(usage)] = proxy; + } + } + + const Ref& ParticleMaterialLibrary::GetDefault( + const EMaterialUsage usage + ) const + { + const auto& material = m_Defaults[GetSlot(usage)]; + EE_CORE_ASSERT( + material, + "Particle material library default is unavailable." + ) + return material; + } +} diff --git a/Elixir/Source/Engine/Material/ParticleMaterialLibrary.h b/Elixir/Source/Engine/Material/ParticleMaterialLibrary.h new file mode 100644 index 00000000..c5093662 --- /dev/null +++ b/Elixir/Source/Engine/Material/ParticleMaterialLibrary.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +namespace Elixir +{ + // Owns compiled built-in particle material proxies. This is an asset + // service: MaterialSystem never chooses a fallback while rendering. + class ELIXIR_API ParticleMaterialLibrary final + { + public: + explicit ParticleMaterialLibrary(const ShaderLoader* shaderLoader); + + const Ref& GetDefault(EMaterialUsage usage) const; + + private: + static constexpr size_t GetSlot(const EMaterialUsage usage) + { + return static_cast(usage); + } + + std::array, 3> m_Defaults; + }; +} \ No newline at end of file diff --git a/Elixir/Tests/Engine/Aether/SystemTest.cpp b/Elixir/Tests/Engine/Aether/SystemTest.cpp index d23dbcb7..a8c14e6b 100644 --- a/Elixir/Tests/Engine/Aether/SystemTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemTest.cpp @@ -191,4 +191,15 @@ TEST(AetherSystemTest, CompileSnapshotsParticleMeshMaterialForRenderData) EXPECT_TRUE(compiled.Emitters[0].Material->GetCompiledMaterial()->SupportsUsage( EMaterialUsage::ParticleMesh )); +} + +TEST(AetherSystemTest, KeepsAnEmitterWithoutAnExplicitMaterialUnbound) +{ + System system{ "Explicit material contract" }; + system.AddEmitter("Smoke", 8, 0.0f); + + const auto compiled = system.Compile(); + + ASSERT_EQ(compiled.Emitters.size(), 1); + EXPECT_FALSE(compiled.Emitters[0].Material); } \ No newline at end of file From f6ea18d2e5a2020d8a8eb211067fb1ae6a2120db Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Sun, 2 Aug 2026 18:17:12 -0300 Subject: [PATCH 25/89] refactor(material): publish particle material scenes Introduce MaterialRenderScene as the frame-boundary contract for particle material inputs. MaterialSystem now builds frame snapshots from the published scene while Aether retains geometry submission for the next phase. --- Elixir/Source/Engine/Aether/Renderer.cpp | 30 +++++++++---------- Elixir/Source/Engine/Aether/Renderer.h | 8 +---- .../Engine/Material/MaterialRenderScene.cpp | 10 +++++++ .../Engine/Material/MaterialRenderScene.h | 28 +++++++++++++++++ .../Source/Engine/Material/MaterialSystem.cpp | 12 ++++---- .../Source/Engine/Material/MaterialSystem.h | 4 +-- .../Material/MaterialRenderSceneTest.cpp | 21 +++++++++++++ 7 files changed, 82 insertions(+), 31 deletions(-) create mode 100644 Elixir/Source/Engine/Material/MaterialRenderScene.cpp create mode 100644 Elixir/Source/Engine/Material/MaterialRenderScene.h create mode 100644 Elixir/Tests/Engine/Material/MaterialRenderSceneTest.cpp diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index 1dbbcc6d..bda705e5 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -252,10 +252,9 @@ namespace Elixir::Aether if (submittedInstances.empty()) return; - const auto materialInputs = CollectMaterialFrameInputs(submittedInstances); + const auto materialScene = BuildMaterialRenderScene(submittedInstances); const auto materialSnapshot = m_MaterialSystem->BuildFrameSnapshot( - materialInputs.Materials, - materialInputs.Textures, + materialScene, m_SubmissionSerial ); @@ -1220,11 +1219,11 @@ namespace Elixir::Aether } } - Renderer::SMaterialFrameInputs Renderer::CollectMaterialFrameInputs( + MaterialRenderScene Renderer::BuildMaterialRenderScene( const std::vector& instances ) const { - SMaterialFrameInputs inputs; + MaterialRenderScene scene; for (const auto& instance : instances) { @@ -1235,23 +1234,22 @@ namespace Elixir::Aether if (emitter.MaxParticles == 0) continue; - if (!emitter.Material) - { - EE_CORE_ERROR( - "Aether emitter '{}' has no compiled material proxy.", - emitter.Name - ); + EMaterialPass pass; + if (!TryToGetParticleMaterialPass(emitter.RenderMode, pass)) continue; - } - inputs.Materials.push_back(emitter.Material); + scene.Add({ + .Pass = pass, + .Material = emitter.Material, + .AdditionalTexture = emitter.RenderMode == EParticleRenderMode::Sprite + ? emitter.SpriteTexture + : Ref{}, - if (emitter.RenderMode == EParticleRenderMode::Sprite && emitter.SpriteTexture) - inputs.Textures.push_back(emitter.SpriteTexture); + }); } } - return inputs; + return scene; } void Renderer::SimulateBatch(const Ref& cmd, const SSimulationBatch& batch) diff --git a/Elixir/Source/Engine/Aether/Renderer.h b/Elixir/Source/Engine/Aether/Renderer.h index 98622ba8..d3b209f4 100644 --- a/Elixir/Source/Engine/Aether/Renderer.h +++ b/Elixir/Source/Engine/Aether/Renderer.h @@ -221,12 +221,6 @@ namespace Elixir::Aether std::vector Instances; }; - struct SMaterialFrameInputs - { - std::vector> Materials; - std::vector> Textures; - }; - struct SRenderBatchKey { EParticleStateLayout ParticleStateLayout = EParticleStateLayout::CoreV1; @@ -284,7 +278,7 @@ namespace Elixir::Aether void PrepareMaterialBatches(std::vector& batches); - SMaterialFrameInputs CollectMaterialFrameInputs( + MaterialRenderScene BuildMaterialRenderScene( const std::vector& instances ) const; diff --git a/Elixir/Source/Engine/Material/MaterialRenderScene.cpp b/Elixir/Source/Engine/Material/MaterialRenderScene.cpp new file mode 100644 index 00000000..0087e0f2 --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialRenderScene.cpp @@ -0,0 +1,10 @@ +#include "epch.h" +#include "MaterialRenderScene.h" + +namespace Elixir +{ + void MaterialRenderScene::Add(SMaterialRenderItem item) + { + m_Items.push_back(std::move(item)); + } +} diff --git a/Elixir/Source/Engine/Material/MaterialRenderScene.h b/Elixir/Source/Engine/Material/MaterialRenderScene.h new file mode 100644 index 00000000..4a3d3093 --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialRenderScene.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +namespace Elixir +{ + // A frame-local material item. Geometry producers may temporarily expose + // an additional texture while their legacy data is migrated into MaterialRenderProxy. + struct SMaterialRenderItem + { + EMaterialPass Pass = EMaterialPass::ParticleSprite; + Ref Material; + Ref AdditionalTexture; + }; + + // Immutable after frame publication. MaterialSystem consumes this object + // synchronously while recording the frame command buffer. + class ELIXIR_API MaterialRenderScene final + { + public: + void Add(SMaterialRenderItem item); + + std::span GetItems() const { return m_Items; } + + private: + std::vector m_Items; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/MaterialSystem.cpp b/Elixir/Source/Engine/Material/MaterialSystem.cpp index 12e182f1..8042526e 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.cpp +++ b/Elixir/Source/Engine/Material/MaterialSystem.cpp @@ -17,8 +17,7 @@ namespace Elixir )) {} SMaterialFrameSnapshot MaterialSystem::BuildFrameSnapshot( - const std::span> materials, - const std::span> textures, + const MaterialRenderScene& scene, const uint64_t submissionSerial ) { @@ -33,14 +32,15 @@ namespace Elixir } ); - for (const auto& material : materials) + for (const auto& item : scene.GetItems()) { + const auto& material = item.Material; if (material) table->Add(*material); - } - for (const auto& texture : textures) - m_Textures.Resolve(texture); + if (item.AdditionalTexture) + m_Textures.Resolve(item.AdditionalTexture); + } if (!table->GetData().empty()) { diff --git a/Elixir/Source/Engine/Material/MaterialSystem.h b/Elixir/Source/Engine/Material/MaterialSystem.h index 19bfa097..54d07259 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.h +++ b/Elixir/Source/Engine/Material/MaterialSystem.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -20,8 +21,7 @@ namespace Elixir MaterialSystem(const GraphicsContext* context, uint32_t capacity); SMaterialFrameSnapshot BuildFrameSnapshot( - std::span> materials, - std::span> textures, + const MaterialRenderScene& scene, uint64_t submissionSerial ); diff --git a/Elixir/Tests/Engine/Material/MaterialRenderSceneTest.cpp b/Elixir/Tests/Engine/Material/MaterialRenderSceneTest.cpp new file mode 100644 index 00000000..81344f35 --- /dev/null +++ b/Elixir/Tests/Engine/Material/MaterialRenderSceneTest.cpp @@ -0,0 +1,21 @@ +#include +#include + +#include + +using namespace Elixir; + +TEST(MaterialRenderSceneTest, PreservesAnUnboundMaterialItem) +{ + MaterialRenderScene scene; + + scene.Add({ + .Pass = EMaterialPass::ParticleRibbon, + }); + + const auto items = scene.GetItems(); + + ASSERT_EQ(items.size(), 1); + EXPECT_EQ(items.front().Pass, EMaterialPass::ParticleRibbon); + EXPECT_FALSE(items.front().Material); +} \ No newline at end of file From 12cc7a24ecdf8f3fb81f502c98c24027def1e2f2 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Sun, 2 Aug 2026 23:02:42 -0300 Subject: [PATCH 26/89] refactor(material): execute particle material scenes Move particle material batching, pass preparation and draw recording into MaterialSystem. Aether now publishes immutable geometry data and no longer manipulates material shaders or pipelines. --- Elixir/Source/Engine/Aether/Renderer.cpp | 535 +++++------------- Elixir/Source/Engine/Aether/Renderer.h | 38 -- .../Engine/Material/MaterialRenderScene.cpp | 55 ++ .../Engine/Material/MaterialRenderScene.h | 69 +++ .../Engine/Material/MaterialRenderer.cpp | 12 + .../Source/Engine/Material/MaterialRenderer.h | 25 +- .../Source/Engine/Material/MaterialSystem.cpp | 172 +++++- .../Source/Engine/Material/MaterialSystem.h | 19 +- .../Material/MaterialRenderSceneTest.cpp | 31 + .../Engine/Material/MaterialRendererTest.cpp | 5 - 10 files changed, 506 insertions(+), 455 deletions(-) diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index bda705e5..f49929d1 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -112,18 +112,6 @@ namespace Elixir::Aether 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 @@ -260,16 +248,9 @@ namespace Elixir::Aether const auto simulationBatches = BuildSimulationBatches(submittedInstances); - auto renderBatches = BuildRenderBatches(submittedInstances, materialSnapshot); - PrepareMaterialBatches(renderBatches); - m_LastSubmissionMetrics.SimulationBatchCount = simulationBatches.size(); - m_LastSubmissionMetrics.RenderBatchCount = renderBatches.size(); m_LastSubmissionMetrics.SubmittedMaterialCount = materialSnapshot.MaterialCount; - for (const auto& batch : renderBatches) - m_LastSubmissionMetrics.SubmittedRenderItemCount += batch.Items.size(); - const auto cmd = m_GraphicsContext->GetSecondaryCommandBuffer(); cmd->Begin({ .ColorAttachment = m_GraphicsContext->GetRenderTarget(), @@ -295,8 +276,14 @@ namespace Elixir::Aether BeginRendering(cmd); - for (const auto& batch : renderBatches) - RenderBatch(cmd, batch); + const auto materialResult = m_MaterialSystem->Render( + cmd, + materialScene, + materialSnapshot + ); + + m_LastSubmissionMetrics.RenderBatchCount = materialResult.BatchCount; + m_LastSubmissionMetrics.SubmittedRenderItemCount = materialResult.DrawCount; EndRendering(cmd); } @@ -1010,242 +997,179 @@ namespace Elixir::Aether return batches; } - std::vector Renderer::BuildRenderBatches( - const std::vector& instances, - const SMaterialFrameSnapshot& materials - ) + MaterialRenderScene Renderer::BuildMaterialRenderScene( + 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; - - EMaterialPass materialPass; - if (!TryToGetParticleMaterialPass(emitter.RenderMode, materialPass)) - continue; - - const auto* material = emitter.Material.get(); - if (!material) - { - EE_CORE_ERROR( - "Aether emitter '{}' has no compiled material proxy.", - emitter.Name - ) - continue; - } - - uint32_t spriteIndex = m_MaterialSystem->GetFallbackTextureIndex(); - - if (emitter.RenderMode == EParticleRenderMode::Sprite) - spriteIndex = m_MaterialSystem->FindTextureIndex(emitter.SpriteTexture); - - const auto program = m_MaterialSystem->GetProgramKey( - materialPass, - *material - ); - - EE_CORE_ASSERT( - program, - "Resolved particle material must provide its shader permutation." - ) - if (!program) continue; - - const auto index = materials.Table->Find(*material); - EE_CORE_ASSERT(index, "Material frame snapshot is missing an emitter material.") - if (!index) continue; - - const SRenderBatchKey key{ - .ParticleStateLayout = instance.ParticleStateLayout, - .RenderMode = emitter.RenderMode, - .MaterialProgram = *program, - }; - - SRenderBatch* batch = nullptr; - - for (auto& candidate : batches) - { - if (candidate.Key != key) - continue; + MaterialRenderScene scene; - batch = &candidate; - break; - } + static const BufferLayout ribbonVertexLayout; - if (!batch) - { - batches.push_back({ - .Key = key, - }); - batch = &batches.back(); - } + struct SGeometryIndices + { + uint32_t Sprite = UINT32_MAX; + uint32_t Ribbon = UINT32_MAX; + uint32_t Mesh = UINT32_MAX; + }; - batch->Items.push_back({ - .Instance = &instance, - .Emitter = &emitter, - .Material = material, - .MaterialIndex = *index, - .SpriteIndex = spriteIndex, - .LocalEmitterIndex = emitterIndex, - }); - } - } + std::unordered_map geometries; - std::ranges::stable_sort(batches, [](const SRenderBatch& left, const SRenderBatch& right) + const auto getGeometry = [this, &scene, &geometries]( + const EParticleStateLayout layout + ) { - if (left.Key.ParticleStateLayout != right.Key.ParticleStateLayout) - { - return uint32_t(left.Key.ParticleStateLayout) < - uint32_t(right.Key.ParticleStateLayout); - } + const auto key = (uint32_t)layout; + if (const auto found = geometries.find(key); found != geometries.end()) + return found->second; - if (left.Key.RenderMode != right.Key.RenderMode) - { - return GetRenderModeOrder(left.Key.RenderMode) < - GetRenderModeOrder(right.Key.RenderMode); - } + const auto* runtime = FindParticleStateLayoutRuntime(layout); + EE_CORE_ASSERT(runtime, "Aether particle state layout runtime is missing.") - return std::less{}( - left.Key.MaterialProgram.Identity, - right.Key.MaterialProgram.Identity - ); - }); + const std::array constantBuffers{ + SMaterialConstantBufferBinding{ + .Name = "cbFrame", + .Buffer = m_FrameConstantBuffer, + }, + }; - return batches; - } + const std::array ribbonStorageBuffers{ + SMaterialStorageBufferBinding{ + .Name = "particles", + .Buffer = MaterialStorageBuffer{ runtime->ParticleStateBuffer }, + }, + SMaterialStorageBufferBinding{ + .Name = "emitters", + .Buffer = MaterialStorageBuffer{ m_EmitterBuffer }, + }, + }; - void Renderer::PrepareMaterialBatches(std::vector& batches) - { - static const BufferLayout ribbonVertexLayout; + const SGeometryIndices indices{ + .Sprite = scene.AddGeometry({ + .Pipeline = { + .VertexLayoutKey = (uint64_t)runtime->Key, + .VertexLayout = &runtime->SpriteVertexLayout, + }, + .ConstantBuffers = { constantBuffers.begin(), constantBuffers.end() }, + .VertexBuffers = { + { .Buffer = runtime->ParticleStateBuffer.get(), .Binding = 0 } + }, + }), + .Ribbon = scene.AddGeometry({ + .Pipeline = { + .VertexLayoutKey = (uint64_t)runtime->Key, + .VertexLayout = &ribbonVertexLayout, + }, + .ConstantBuffers = { constantBuffers.begin(), constantBuffers.end() }, + .StorageBuffers = { ribbonStorageBuffers.begin(), ribbonStorageBuffers.end() }, + }), + .Mesh = scene.AddGeometry({ + .Pipeline = { + .VertexLayoutKey = (uint64_t)runtime->Key, + .VertexLayout = &runtime->MeshVertexLayout, + }, + .ConstantBuffers = { constantBuffers.begin(), constantBuffers.end() }, + .VertexBuffers = { + { .Buffer = m_MeshVertexBuffer.get(), .Binding = 0 }, + { .Buffer = runtime->ParticleStateBuffer.get(), .Binding = 1 }, + }, + }), + }; - const std::array constantBuffers{ - SMaterialConstantBufferBinding{ - .Name = "cbFrame", - .Buffer = m_FrameConstantBuffer, - }, + geometries.emplace(key, indices); + return indices; }; - for (auto& batch : batches) + for (const auto& instance : instances) { - if (!batch.Key.MaterialProgram || batch.Items.empty()) - continue; - - const auto* runtime = FindParticleStateLayoutRuntime(batch.Key.ParticleStateLayout); - EE_CORE_ASSERT(runtime, "Aether particle state layout runtime is missing.") - if (!runtime) continue; - - const auto* material = batch.Items.front().Material; + const auto& emitters = instance.Instance->GetCompiledSystem().Emitters; + const auto geometry = getGeometry(instance.ParticleStateLayout); - switch (batch.Key.RenderMode) + for (uint32_t emitterIndex = 0; emitterIndex < emitters.size(); ++emitterIndex) { - case EParticleRenderMode::Sprite: - { - const SSpritePushConstants initial{ - .SpriteIndex = m_MaterialSystem->GetFallbackTextureIndex(), - }; - - batch.PreparedMaterial = m_MaterialSystem->PrepareMaterialPass({ - .Pass = EMaterialPass::ParticleSprite, - .Material = material, - .Pipeline = { - .VertexLayoutKey = uint64_t(runtime->Key), - .VertexLayout = &runtime->SpriteVertexLayout, - }, - .ExternalResources = { - .ConstantBuffers = constantBuffers, - }, - .InitialPushConstants = std::as_bytes(std::span{ &initial, size_t{ 1 }}), - }); - break; - } + const auto& emitter = emitters[emitterIndex]; + if (emitter.MaxParticles == 0) continue; - case EParticleRenderMode::Ribbon: - { - const SRibbonPushConstants initial{}; - - const std::array storageBuffers{ - SMaterialStorageBufferBinding{ - .Name = "particles", - .Buffer = MaterialStorageBuffer{ runtime->ParticleStateBuffer }, - }, - SMaterialStorageBufferBinding{ - .Name = "emitters", - .Buffer = MaterialStorageBuffer{ m_EmitterBuffer }, - }, - }; - - batch.PreparedMaterial = m_MaterialSystem->PrepareMaterialPass({ - .Pass = EMaterialPass::ParticleRibbon, - .Material = material, - .Pipeline = { - .VertexLayoutKey = uint64_t(runtime->Key), - .VertexLayout = &ribbonVertexLayout, - }, - .ExternalResources = { - .ConstantBuffers = constantBuffers, - .StorageBuffers = storageBuffers, - }, - .InitialPushConstants = std::as_bytes(std::span{ &initial, size_t{ 1 }}), - }); - break; - } + const auto worldTransform = GetParticleRenderTransform( + emitter, + *instance.Instance + ); - case EParticleRenderMode::Mesh: + switch (emitter.RenderMode) { - constexpr SMeshPushConstants initial{}; - - batch.PreparedMaterial = m_MaterialSystem->PrepareMaterialPass({ - .Pass = EMaterialPass::ParticleMesh, - .Material = material, - .Pipeline = { - .VertexLayoutKey = uint64_t(runtime->Key), - .VertexLayout = &runtime->MeshVertexLayout, - }, - .ExternalResources = { - .ConstantBuffers = constantBuffers, - }, - .InitialPushConstants = std::as_bytes(std::span{ &initial, size_t{ 1 }}), - }); - break; - } - } - } - } - - MaterialRenderScene Renderer::BuildMaterialRenderScene( - const std::vector& instances - ) const - { - MaterialRenderScene scene; + case EParticleRenderMode::Sprite: + { + const SSpritePushConstants constants{ + .WorldTransform = worldTransform, + }; - for (const auto& instance : instances) - { - const auto& emitters = instance.Instance->GetCompiledSystem().Emitters; + scene.Add({ + .Pass = EMaterialPass::ParticleSprite, + .Material = emitter.Material, + .AdditionalTexture = emitter.SpriteTexture, + .DebugName = emitter.Name, + .GeometryIndex = geometry.Sprite, + .PushConstants = SMaterialPushConstants::Create( + constants, + offsetof(SSpritePushConstants, MaterialIndex), + offsetof(SSpritePushConstants, SpriteIndex) + ), + .Draw = { + .VertexCount = 6, + .InstanceCount = emitter.MaxParticles, + .FirstInstance = instance.Allocation.Particles.Offset + + emitter.LocalParticleOffset, + }, + }); + break; + } - for (const auto& emitter : emitters) - { - if (emitter.MaxParticles == 0) - continue; + case EParticleRenderMode::Ribbon: + { + const SRibbonPushConstants constants{ + .WorldTransform = worldTransform, + .EmitterIndex = instance.Allocation.Emitters.Offset + emitterIndex, + .ParticleBaseOffset = instance.Allocation.Particles.Offset, + }; - EMaterialPass pass; - if (!TryToGetParticleMaterialPass(emitter.RenderMode, pass)) - continue; + scene.Add({ + .Pass = EMaterialPass::ParticleRibbon, + .Material = emitter.Material, + .DebugName = emitter.Name, + .GeometryIndex = geometry.Ribbon, + .PushConstants = SMaterialPushConstants::Create( + constants, + offsetof(SRibbonPushConstants, MaterialIndex) + ), + .Draw = { .VertexCount = emitter.MaxParticles * 6 }, + }); + break; + } - scene.Add({ - .Pass = pass, - .Material = emitter.Material, - .AdditionalTexture = emitter.RenderMode == EParticleRenderMode::Sprite - ? emitter.SpriteTexture - : Ref{}, + case EParticleRenderMode::Mesh: + { + const SMeshPushConstants constants{ + .WorldTransform = worldTransform, + }; - }); + scene.Add({ + .Pass = EMaterialPass::ParticleMesh, + .Material = emitter.Material, + .DebugName = emitter.Name, + .GeometryIndex = geometry.Mesh, + .PushConstants = SMaterialPushConstants::Create( + constants, + offsetof(SMeshPushConstants, MaterialIndex) + ), + .Draw = { + .VertexCount = m_MeshVertexCount, + .InstanceCount = emitter.MaxParticles, + .FirstInstance = instance.Allocation.Particles.Offset + + emitter.LocalParticleOffset, + }, + }); + break; + } + } } } @@ -1381,151 +1305,6 @@ namespace Elixir::Aether } } - void Renderer::RenderBatch(const Ref& cmd, const SRenderBatch& batch) - { - if (!batch.PreparedMaterial) - return; - - 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::Sprite: - { - const auto drawSprite = [ - &batch, - &particleBuffer - ](const Ref& cmd, const Ref& shader) - { - particleBuffer->BindAs(cmd); - - for (const auto& item : batch.Items) - { - const auto worldTransform = GetParticleRenderTransform( - *item.Emitter, - *item.Instance->Instance - ); - - const SSpritePushConstants pc{ - .WorldTransform = worldTransform, - .MaterialIndex = item.MaterialIndex, - .SpriteIndex = item.SpriteIndex, - }; - - shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - cmd->Draw( - 6, - item.Emitter->MaxParticles, - 0, - item.Instance->Allocation.Particles.Offset + item.Emitter->LocalParticleOffset - ); - } - }; - - m_MaterialSystem->DrawMaterial( - { - .CommandBuffer = cmd, - .MaterialPass = &*batch.PreparedMaterial, - }, - drawSprite - ); - - return; - } - - case EParticleRenderMode::Ribbon: - { - const auto drawRibbon = [ - &batch - ](const Ref& cmd, const Ref& shader) - { - for (const auto& item : batch.Items) - { - const auto worldTransform = GetParticleRenderTransform( - *item.Emitter, - *item.Instance->Instance - ); - - const SRibbonPushConstants pc{ - .WorldTransform = worldTransform, - .EmitterIndex = item.Instance->Allocation.Emitters.Offset + item.LocalEmitterIndex, - .ParticleBaseOffset = item.Instance->Allocation.Particles.Offset, - .MaterialIndex = item.MaterialIndex, - }; - - shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - cmd->Draw(item.Emitter->MaxParticles * 6); - } - }; - - m_MaterialSystem->DrawMaterial( - { - .CommandBuffer = cmd, - .MaterialPass = &*batch.PreparedMaterial, - }, - drawRibbon - ); - - return; - } - - case EParticleRenderMode::Mesh: - { - const auto drawMesh = [ - this, - &batch, - &particleBuffer - ](const Ref& cmd, const Ref& shader) - { - m_MeshVertexBuffer->Bind(cmd); - - // TODO: Enhance this api - particleBuffer->BindAs(cmd, std::span{}, 1, 1); - - for (const auto& item : batch.Items) - { - const auto worldTransform = GetParticleRenderTransform( - *item.Emitter, - *item.Instance->Instance - ); - - const SMeshPushConstants pc{ - .WorldTransform = worldTransform, - .MaterialIndex = item.MaterialIndex - }; - - shader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - cmd->Draw( - m_MeshVertexCount, - item.Emitter->MaxParticles, - 0, - item.Instance->Allocation.Particles.Offset + item.Emitter->LocalParticleOffset - ); - } - }; - - m_MaterialSystem->DrawMaterial( - { - .CommandBuffer = cmd, - .MaterialPass = &*batch.PreparedMaterial, - }, - drawMesh - ); - - 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; diff --git a/Elixir/Source/Engine/Aether/Renderer.h b/Elixir/Source/Engine/Aether/Renderer.h index d3b209f4..c9a083f5 100644 --- a/Elixir/Source/Engine/Aether/Renderer.h +++ b/Elixir/Source/Engine/Aether/Renderer.h @@ -221,32 +221,6 @@ namespace Elixir::Aether std::vector Instances; }; - struct SRenderBatchKey - { - EParticleStateLayout ParticleStateLayout = EParticleStateLayout::CoreV1; - EParticleRenderMode RenderMode = EParticleRenderMode::Sprite; - SMaterialProgramKey MaterialProgram; - - bool operator==(const SRenderBatchKey&) const = default; - }; - - struct SRenderItem - { - const SSubmittedSystemInstance* Instance = nullptr; - const SCompiledEmitter* Emitter = nullptr; - const MaterialRenderProxy* Material = nullptr; - uint32_t MaterialIndex = UINT32_MAX; - uint32_t SpriteIndex = UINT32_MAX; - uint32_t LocalEmitterIndex = 0; - }; - - struct SRenderBatch - { - SRenderBatchKey Key; - std::optional PreparedMaterial; - std::vector Items; - }; - SInstanceRecord* ResolveInstanceRecord(const SystemInstance& instance); void UploadCompiledSystem( const SystemInstance& instance, @@ -271,13 +245,6 @@ namespace Elixir::Aether const std::vector& instances ) const; - std::vector BuildRenderBatches( - const std::vector& instances, - const SMaterialFrameSnapshot& materials - ); - - void PrepareMaterialBatches(std::vector& batches); - MaterialRenderScene BuildMaterialRenderScene( const std::vector& instances ) const; @@ -287,11 +254,6 @@ namespace Elixir::Aether const SSimulationBatch& batch ); - void RenderBatch( - const Ref& cmd, - const SRenderBatch& batch - ); - void BarrierSchedulingBuffers(const Ref& cmd) const; void ClearParticleAllocation(const SSystemInstanceAllocation& allocation); diff --git a/Elixir/Source/Engine/Material/MaterialRenderScene.cpp b/Elixir/Source/Engine/Material/MaterialRenderScene.cpp index 0087e0f2..41473d88 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderScene.cpp +++ b/Elixir/Source/Engine/Material/MaterialRenderScene.cpp @@ -3,8 +3,63 @@ namespace Elixir { + /* SMaterialPushConstants */ + + std::array SMaterialPushConstants::Resolve( + const uint32_t materialIndex, + const uint32_t additionalTextureIndex + ) 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); + patch(AdditionalTextureIndexOffset, additionalTextureIndex); + + return resolved; + } + + /* MaterialRenderScene */ + + uint32_t MaterialRenderScene::AddGeometry(SMaterialRenderGeometry 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(SMaterialRenderItem item) { + EE_CORE_ASSERT( + item.GeometryIndex < m_Geometries.size(), + "Material render item references a unknown geometry." + ) + m_Items.push_back(std::move(item)); } + + const SMaterialRenderGeometry* MaterialRenderScene::FindGeometry(const uint32_t index) const + { + if (index >= m_Geometries.size()) + return nullptr; + + return &m_Geometries[index]; + } } diff --git a/Elixir/Source/Engine/Material/MaterialRenderScene.h b/Elixir/Source/Engine/Material/MaterialRenderScene.h index 4a3d3093..c5b1fc1f 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderScene.h +++ b/Elixir/Source/Engine/Material/MaterialRenderScene.h @@ -4,6 +4,67 @@ namespace Elixir { + struct SMaterialPushConstants + { + static constexpr uint32_t CAPACITY = 128; + static constexpr uint32_t NO_OFFSET = UINT32_MAX; + + std::array Data{}; + uint32_t Size = 0; + uint32_t MaterialIndexOffset = NO_OFFSET; + uint32_t AdditionalTextureIndexOffset = NO_OFFSET; + + template + static SMaterialPushConstants Create( + const T& value, + const uint32_t materialIndexOffset = NO_OFFSET, + const uint32_t additionalTextureIndexOffset = 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; + pc.AdditionalTextureIndexOffset = additionalTextureIndexOffset; + + return pc; + } + + std::array Resolve( + uint32_t materialIndex, + uint32_t additionalTextureIndex + ) const; + }; + + struct SMaterialVertexBufferBinding + { + const Buffer* Buffer = nullptr; + uint32_t Binding = 0; + }; + + struct SMaterialDrawCommand + { + uint32_t VertexCount = 0; + uint32_t InstanceCount = 1; + uint32_t FirstVertex = 0; + uint32_t FirstInstance = 0; + }; + + // Geometry resources are shared by all items that use a particle-state + // layout and render primitive in the current frame. + struct SMaterialRenderGeometry + { + SMaterialPipelineRequest Pipeline; + std::vector ConstantBuffers; + std::vector StorageBuffers; + std::vector VertexBuffers; + }; + // A frame-local material item. Geometry producers may temporarily expose // an additional texture while their legacy data is migrated into MaterialRenderProxy. struct SMaterialRenderItem @@ -11,6 +72,10 @@ namespace Elixir EMaterialPass Pass = EMaterialPass::ParticleSprite; Ref Material; Ref AdditionalTexture; + std::string_view DebugName; + uint32_t GeometryIndex = UINT32_MAX; + SMaterialPushConstants PushConstants; + SMaterialDrawCommand Draw; }; // Immutable after frame publication. MaterialSystem consumes this object @@ -18,11 +83,15 @@ namespace Elixir class ELIXIR_API MaterialRenderScene final { public: + uint32_t AddGeometry(SMaterialRenderGeometry geometry); void Add(SMaterialRenderItem item); + const SMaterialRenderGeometry* FindGeometry(uint32_t index) const; + std::span GetItems() const { return m_Items; } private: + std::vector m_Geometries; std::vector m_Items; }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/MaterialRenderer.cpp b/Elixir/Source/Engine/Material/MaterialRenderer.cpp index 4efd7c13..95c9ce14 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderer.cpp +++ b/Elixir/Source/Engine/Material/MaterialRenderer.cpp @@ -26,6 +26,18 @@ namespace Elixir return EMaterialUsage::ParticleSprite; } + uint32_t MaterialRenderer::GetPassOrder(const EMaterialPass pass) + { + switch (pass) + { + case EMaterialPass::ParticleSprite: return 2; + case EMaterialPass::ParticleRibbon: return 1; + case EMaterialPass::ParticleMesh: return 0; + } + + return UINT32_MAX; + } + std::optional MaterialRenderer::GetProgramKey( const EMaterialPass pass, const MaterialRenderProxy& material diff --git a/Elixir/Source/Engine/Material/MaterialRenderer.h b/Elixir/Source/Engine/Material/MaterialRenderer.h index 38f1d04f..773beaba 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderer.h +++ b/Elixir/Source/Engine/Material/MaterialRenderer.h @@ -75,17 +75,6 @@ namespace Elixir explicit operator bool() const { return Shader && Pipeline; } }; - struct SMaterialDrawRequest - { - Ref CommandBuffer; - const SPreparedMaterialPass* MaterialPass = nullptr; - - explicit operator bool() const - { - return CommandBuffer && MaterialPass && *MaterialPass; - } - }; - class ELIXIR_API MaterialRenderer final { public: @@ -96,6 +85,7 @@ namespace Elixir ); static EMaterialUsage GetUsage(EMaterialPass pass); + static uint32_t GetPassOrder(EMaterialPass pass); std::optional GetProgramKey( EMaterialPass pass, @@ -106,19 +96,6 @@ namespace Elixir const SMaterialPassRequest& request ); - template - requires std::invocable&, const Ref&> - void Draw(const SMaterialDrawRequest& request, T&& recordGeometry) const - { - if (!request) return; - - request.MaterialPass->Pipeline->Bind(request.CommandBuffer); - std::forward(recordGeometry)( - request.CommandBuffer, - request.MaterialPass->Shader - ); - } - private: enum class EDescriptorBindingType : uint8_t { diff --git a/Elixir/Source/Engine/Material/MaterialSystem.cpp b/Elixir/Source/Engine/Material/MaterialSystem.cpp index 8042526e..320c5067 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.cpp +++ b/Elixir/Source/Engine/Material/MaterialSystem.cpp @@ -3,6 +3,30 @@ namespace Elixir { + namespace + { + struct SMaterialBatchKey + { + EMaterialPass Pass = EMaterialPass::ParticleSprite; + uint32_t GeometryIndex = UINT32_MAX; + SMaterialProgramKey Program; + + bool operator==(const SMaterialBatchKey&) const = default; + }; + + struct SMaterialBatchItem + { + const SMaterialRenderItem* Item = nullptr; + uint32_t MaterialIndex = UINT32_MAX; + }; + + struct SMaterialBatch + { + SMaterialBatchKey Key; + std::vector Items; + }; + } + MaterialSystem::MaterialSystem(const GraphicsContext* context, const uint32_t capacity) : m_MaterialCapacity(capacity), m_FrameBuffer(DynamicStorageBuffer::Create( @@ -68,8 +92,152 @@ namespace Elixir return m_Renderer->Prepare(request); } - uint32_t MaterialSystem::FindTextureIndex(const Ref& texture) const + SMaterialRenderResult MaterialSystem::Render( + const Ref& cmd, + const MaterialRenderScene& scene, + const SMaterialFrameSnapshot& snapshot + ) const { - return m_Textures.Find(texture); + if (!cmd || !snapshot.Table) + return {}; + + // Batching + + std::vector batches; + + for (const auto& item : scene.GetItems()) + { + if (!item.Material) + { + EE_CORE_ERROR( + "Material render item '{}' has no compiled material proxy.", + item.DebugName + ) + continue; + } + + const auto* geometry = scene.FindGeometry(item.GeometryIndex); + EE_CORE_ASSERT(geometry, "Material render item geometry is unavailable.") + if (!geometry) continue; + + const auto program = GetProgramKey(item.Pass, *item.Material); + EE_CORE_ASSERT(program, "Material render item does not support its requested pass.") + if (!program) continue; + + const auto materialIndex = snapshot.Table->Find(*item.Material); + EE_CORE_ASSERT(materialIndex, "Material frame snapshot is missing a render item material.") + if (!materialIndex) continue; + + const SMaterialBatchKey key{ + .Pass = item.Pass, + .GeometryIndex = item.GeometryIndex, + .Program = *program, + }; + + auto batch = std::ranges::find_if(batches, [&key](const SMaterialBatch& candidate) + { + return candidate.Key == key; + }); + + if (batch == batches.end()) + { + batches.push_back({ .Key = key }); + batch = std::prev(batches.end()); + } + + batch->Items.push_back({ + .Item = &item, + .MaterialIndex = *materialIndex, + }); + } + + std::ranges::stable_sort(batches, [](const SMaterialBatch& left, const SMaterialBatch& right) + { + if (left.Key.Pass != right.Key.Pass) + { + return MaterialRenderer::GetPassOrder(left.Key.Pass) < + MaterialRenderer::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 + ); + }); + + SMaterialRenderResult result{}; + + for (const auto& batch : batches) + { + if (batch.Items.empty()) continue; + + const auto* geometry = scene.FindGeometry(batch.Key.GeometryIndex); + if (!geometry) continue; + + const auto& first = *batch.Items.front().Item; + const auto prepared = PrepareMaterialPass({ + .Pass = batch.Key.Pass, + .Material = first.Material.get(), + .Pipeline = geometry->Pipeline, + .ExternalResources = { + .ConstantBuffers = geometry->ConstantBuffers, + .StorageBuffers = geometry->StorageBuffers, + }, + .InitialPushConstants = std::span{ + first.PushConstants.Data.data(), + first.PushConstants.Size, + }, + }); + if (!prepared) continue; + + ++result.BatchCount; + + // Drawing + + prepared->Pipeline->Bind(cmd); + + for (const auto& binding : geometry->VertexBuffers) + { + cmd->BindBuffer( + binding.Buffer, + std::span{}, + 1, + binding.Binding + ); + } + + for (const auto& batchItem : batch.Items) + { + const auto& item = *batchItem.Item; + const auto textureIndex = item.AdditionalTexture + ? m_Textures.Find(item.AdditionalTexture) + : m_Textures.GetFallbackIndex(); + const auto constants = item.PushConstants.Resolve( + batchItem.MaterialIndex, + textureIndex + ); + + prepared->Shader->SetPushConstant( + cmd, + "pc", + const_cast(constants.data()), + item.PushConstants.Size + ); + + cmd->Draw( + item.Draw.VertexCount, + item.Draw.InstanceCount, + item.Draw.FirstVertex, + item.Draw.FirstInstance + ); + + ++result.DrawCount; + } + } + + return result; } } diff --git a/Elixir/Source/Engine/Material/MaterialSystem.h b/Elixir/Source/Engine/Material/MaterialSystem.h index 54d07259..2abefbbe 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.h +++ b/Elixir/Source/Engine/Material/MaterialSystem.h @@ -15,6 +15,12 @@ namespace Elixir uint64_t SubmissionSerial = 0; }; + struct SMaterialRenderResult + { + uint32_t BatchCount = 0; + uint32_t DrawCount = 0; + }; + class ELIXIR_API MaterialSystem final { public: @@ -34,18 +40,15 @@ namespace Elixir const SMaterialPassRequest& request ) const; - template - requires std::invocable&, const Ref&> - void DrawMaterial(const SMaterialDrawRequest& request, T&& recordGeometry) const - { - m_Renderer->Draw(request, std::forward(recordGeometry)); - } + SMaterialRenderResult Render( + const Ref& cmd, + const MaterialRenderScene& scene, + const SMaterialFrameSnapshot& snapshot + ) const; const Ref& GetFrameBuffer() const { return m_FrameBuffer; } const Ref& GetTextureSet() const { return m_Textures.GetTextureSet(); } const Ref& GetSampler() const { return m_Textures.GetSampler(); } - uint32_t GetFallbackTextureIndex() const { return m_Textures.GetFallbackIndex(); } - uint32_t FindTextureIndex(const Ref& texture) const; private: uint32_t m_MaterialCapacity = 0; diff --git a/Elixir/Tests/Engine/Material/MaterialRenderSceneTest.cpp b/Elixir/Tests/Engine/Material/MaterialRenderSceneTest.cpp index 81344f35..6ebc802f 100644 --- a/Elixir/Tests/Engine/Material/MaterialRenderSceneTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialRenderSceneTest.cpp @@ -8,9 +8,17 @@ using namespace Elixir; 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(); @@ -18,4 +26,27 @@ TEST(MaterialRenderSceneTest, PreservesAnUnboundMaterialItem) ASSERT_EQ(items.size(), 1); EXPECT_EQ(items.front().Pass, EMaterialPass::ParticleRibbon); EXPECT_FALSE(items.front().Material); +} + +TEST(MaterialRenderSceneTest, ResolvesLateMaterialAndTextureIndices) +{ + struct SPushConstants + { + uint32_t MaterialIndex = UINT32_MAX; + uint32_t TextureIndex = UINT32_MAX; + }; + + const auto constants = SMaterialPushConstants::Create( + SPushConstants{}, + offsetof(SPushConstants, MaterialIndex), + offsetof(SPushConstants, TextureIndex) + ); + + const auto resolved = constants.Resolve(17, 9); + + SPushConstants values{}; + Memory::Memcpy(&values, resolved.data(), sizeof(values)); + + EXPECT_EQ(values.MaterialIndex, 17); + EXPECT_EQ(values.TextureIndex, 9); } \ No newline at end of file diff --git a/Elixir/Tests/Engine/Material/MaterialRendererTest.cpp b/Elixir/Tests/Engine/Material/MaterialRendererTest.cpp index d584edbc..d9b16a42 100644 --- a/Elixir/Tests/Engine/Material/MaterialRendererTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialRendererTest.cpp @@ -18,9 +18,4 @@ TEST(MaterialRendererTest, MapsParticlePassesToMaterialUsages) MaterialRenderer::GetUsage(EMaterialPass::ParticleMesh), EMaterialUsage::ParticleMesh ); -} - -TEST(MaterialRendererTest, RejectsAndIncompleteDrawRequest) -{ - EXPECT_FALSE(static_cast(SMaterialDrawRequest{})); } \ No newline at end of file From 1b50263b4be595c3e405c0131156791305aa1d42 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Mon, 3 Aug 2026 13:51:30 -0300 Subject: [PATCH 27/89] refactor(material): author particle sprite appearance in materials Move legacy sprite texture selection into the default Sprite material graph. Expose BaseColor and Opacity as material channels and remove Aether texture bindings from the render contract. --- Elixir/Source/Engine/Aether/Effect.cpp | 19 ++++++------ Elixir/Source/Engine/Aether/Effect.h | 2 +- Elixir/Source/Engine/Aether/Emitter.cpp | 1 - Elixir/Source/Engine/Aether/Emitter.h | 5 ---- Elixir/Source/Engine/Aether/Renderer.cpp | 5 +--- Elixir/Source/Engine/Aether/System.h | 2 -- .../Source/Engine/Material/MaterialGraph.cpp | 18 ++++++++--- Elixir/Source/Engine/Material/MaterialGraph.h | 10 ++++--- .../Engine/Material/MaterialRenderScene.cpp | 7 ++--- .../Engine/Material/MaterialRenderScene.h | 11 ++----- .../Source/Engine/Material/MaterialSystem.cpp | 11 +------ .../Material/ParticleMaterialDefaults.cpp | 23 ++++++++++++++ .../Material/ParticleMaterialDefaults.h | 3 ++ .../Material/ParticleMaterialLibrary.cpp | 30 +++++++++++++++++-- .../Engine/Material/ParticleMaterialLibrary.h | 12 +++++++- .../Engine/Material/MaterialGraphTest.cpp | 22 ++++++++++++++ .../Material/MaterialRenderSceneTest.cpp | 9 ++---- .../Material/ParticleMaterialDefaultsTest.cpp | 7 +++++ Shaders/Material/Material.ps.hlsl | 18 ++++++----- Shaders/Material/ParticleMesh.ps.hlsl | 8 +++-- Shaders/Material/ParticleRibbon.ps.hlsl | 8 +++-- Shaders/Material/ParticleSprite.ps.hlsl | 13 ++++---- 22 files changed, 159 insertions(+), 85 deletions(-) diff --git a/Elixir/Source/Engine/Aether/Effect.cpp b/Elixir/Source/Engine/Aether/Effect.cpp index a81252a4..8a7d7799 100644 --- a/Elixir/Source/Engine/Aether/Effect.cpp +++ b/Elixir/Source/Engine/Aether/Effect.cpp @@ -62,7 +62,7 @@ namespace Elixir::Aether public: EffectParser( std::filesystem::path filepath, - const ParticleMaterialLibrary& materials + ParticleMaterialLibrary& materials ) : m_Filepath(std::move(filepath)), m_Materials(materials) {} @@ -875,9 +875,6 @@ namespace Elixir::Aether auto& emitter = system->AddEmitter(name, maxParticles, spawnRate.Value); emitter.SetRenderMode(renderMode); - emitter.SetMaterial( - m_Materials.GetDefault(GetParticleMaterialUsage(renderMode)) - ); if (HasField(json, "burst")) { @@ -913,11 +910,15 @@ namespace Elixir::Aether if (m_Failed) return; - if (!spriteTexture.empty()) + if (renderMode == EParticleRenderMode::Sprite && !spriteTexture.empty()) { const auto texture = TextureLoader::Load(spriteTexture); - const auto tex2d = std::static_pointer_cast(texture); - emitter.SetSpriteTexture(tex2d); + if (!texture) { Fail("Could not load sprite texture '{}'.", spriteTexture); return; } + emitter.SetMaterial(m_Materials.GetDefaultSprite(texture)); + } + else + { + emitter.SetMaterial(m_Materials.GetDefault(GetParticleMaterialUsage(renderMode))); } if (!spawnRate.Param.empty()) @@ -931,7 +932,7 @@ namespace Elixir::Aether } std::filesystem::path m_Filepath; - const ParticleMaterialLibrary& m_Materials; + ParticleMaterialLibrary& m_Materials; bool m_Failed = false; }; @@ -976,7 +977,7 @@ namespace Elixir::Aether Ref LoadEffectFile( const std::filesystem::path& filepath, - const ParticleMaterialLibrary& materials + ParticleMaterialLibrary& materials ) { od::parser parser; diff --git a/Elixir/Source/Engine/Aether/Effect.h b/Elixir/Source/Engine/Aether/Effect.h index eee84cce..8c9e0ebb 100644 --- a/Elixir/Source/Engine/Aether/Effect.h +++ b/Elixir/Source/Engine/Aether/Effect.h @@ -11,6 +11,6 @@ namespace Elixir::Aether { ELIXIR_API Ref LoadEffectFile( const std::filesystem::path& filepath, - const ParticleMaterialLibrary& materials + ParticleMaterialLibrary& materials ); } \ No newline at end of file diff --git a/Elixir/Source/Engine/Aether/Emitter.cpp b/Elixir/Source/Engine/Aether/Emitter.cpp index e0c3b6c6..c901a399 100644 --- a/Elixir/Source/Engine/Aether/Emitter.cpp +++ b/Elixir/Source/Engine/Aether/Emitter.cpp @@ -36,7 +36,6 @@ namespace Elixir::Aether 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(); diff --git a/Elixir/Source/Engine/Aether/Emitter.h b/Elixir/Source/Engine/Aether/Emitter.h index 1400c142..4937e091 100644 --- a/Elixir/Source/Engine/Aether/Emitter.h +++ b/Elixir/Source/Engine/Aether/Emitter.h @@ -17,7 +17,6 @@ namespace Elixir::Aether std::string Name; EParticleRenderMode RenderMode = EParticleRenderMode::Sprite; EParticleSimulationSpace SimulationSpace = EParticleSimulationSpace::World; - Ref SpriteTexture; // Immutable material state captured while the system is compiled. // It is safe to read for the full render submission. @@ -113,9 +112,6 @@ namespace Elixir::Aether const std::string& GetName() const { return m_Name; } uint32_t GetMaxParticles() const { return m_MaxParticles; } - const Ref& GetSpriteTexture() const { return m_SpriteTexture; } - void SetSpriteTexture(const Ref& texture) { m_SpriteTexture = texture; } - const Ref& GetMaterial() const { return m_Material; } void SetMaterial(Ref material) { m_Material = std::move(material); } @@ -140,7 +136,6 @@ namespace Elixir::Aether std::string m_Name; EParticleRenderMode m_RenderMode = EParticleRenderMode::Sprite; EParticleSimulationSpace m_SimulationSpace = EParticleSimulationSpace::World; - Ref m_SpriteTexture; Ref m_Material; uint32_t m_MaxParticles; diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index f49929d1..37fb3f92 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -15,7 +15,6 @@ namespace Elixir::Aether { glm::mat4 WorldTransform{ 1.0f }; uint32_t MaterialIndex = UINT32_MAX; - uint32_t SpriteIndex = 0; }; struct SMeshPushConstants @@ -1105,13 +1104,11 @@ namespace Elixir::Aether scene.Add({ .Pass = EMaterialPass::ParticleSprite, .Material = emitter.Material, - .AdditionalTexture = emitter.SpriteTexture, .DebugName = emitter.Name, .GeometryIndex = geometry.Sprite, .PushConstants = SMaterialPushConstants::Create( constants, - offsetof(SSpritePushConstants, MaterialIndex), - offsetof(SSpritePushConstants, SpriteIndex) + offsetof(SSpritePushConstants, MaterialIndex) ), .Draw = { .VertexCount = 6, diff --git a/Elixir/Source/Engine/Aether/System.h b/Elixir/Source/Engine/Aether/System.h index ef86a6bc..71c33a57 100644 --- a/Elixir/Source/Engine/Aether/System.h +++ b/Elixir/Source/Engine/Aether/System.h @@ -38,8 +38,6 @@ namespace Elixir::Aether std::vector Curves; std::vector ColorCurves; - std::vector SpriteTextures; - uint32_t TotalMaxParticles = 0; }; diff --git a/Elixir/Source/Engine/Material/MaterialGraph.cpp b/Elixir/Source/Engine/Material/MaterialGraph.cpp index 18df70b1..327dd95c 100644 --- a/Elixir/Source/Engine/Material/MaterialGraph.cpp +++ b/Elixir/Source/Engine/Material/MaterialGraph.cpp @@ -23,10 +23,11 @@ namespace Elixir 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"; - case EMaterialChannel::Normal: return "Normal"; } return "BaseColor"; @@ -106,7 +107,8 @@ namespace Elixir ) { const bool scalarChannel = channel == EMaterialChannel::Metallic || - channel == EMaterialChannel::Roughness; + channel == EMaterialChannel::Roughness || + channel == EMaterialChannel::Opacity; if (scalarChannel) return from == EMaterialGraphValueType::Float ? expr : "(" + expr + ").x"; @@ -281,8 +283,16 @@ namespace Elixir const std::string uv = node.Inputs.empty() || node.Inputs[0] < 0 ? "input.TexCoord" : Widen(A(0), AT(0), EMaterialGraphValueType::Float2); - expr = "(" + idx + " == 0xFFFFFFFFu ? float3(1.0, 1.0, 1.0) : SampleTex(" + idx + ", " + uv + "))";; - type = EMaterialGraphValueType::Float3; + expr = "(" + idx + " == 0xFFFFFFFFu ? float4(1.0, 1.0, 1.0, 1.0) : SampleTex(" + idx + ", " + uv + "))"; + type = EMaterialGraphValueType::Float4; + break; + } + case EMaterialNodeType::ComponentMask: + { + static constexpr std::array components{ ".x", ".y", ".z", ".w" }; + const auto component = std::min(node.ComponentIndex, uint32_t(components.size() - 1)); + expr = "(" + A(0) + ")" + components[component]; + type = EMaterialGraphValueType::Float; break; } case EMaterialNodeType::Time: diff --git a/Elixir/Source/Engine/Material/MaterialGraph.h b/Elixir/Source/Engine/Material/MaterialGraph.h index da7ffd00..0d7482fe 100644 --- a/Elixir/Source/Engine/Material/MaterialGraph.h +++ b/Elixir/Source/Engine/Material/MaterialGraph.h @@ -15,6 +15,7 @@ namespace Elixir Parameter, // a named material-instance parameters (mat.) TexCoord, // input.TexCoord TextureSample, // sample a bound texture at a UV (input 0) + ComponentMask, // Select one component from a vector input. Time, // seconds since start (cbFrame.start) Sine, // sin(a) Panner, // uv + Time * speed (speed from ConstantValue.xy) @@ -33,7 +34,7 @@ namespace Elixir // The surface output a channel drives. enum class EMaterialChannel : uint8_t { - BaseColor, Metallic, Roughness, Emissive, Normal + BaseColor, Normal, Metallic, Roughness, Opacity, Emissive }; struct SMaterialGraphBindings @@ -56,9 +57,10 @@ namespace Elixir std::vector DefaultInputs; // Per-type payload. - glm::vec4 ConstantValue{ 0.0f }; // Constant - std::string ParameterName; // Parameter -> mat. - std::string TextureParameterName; // TextureSample -> material texture parameter + glm::vec4 ConstantValue{ 0.0f }; // Constant + std::string ParameterName; // Parameter -> mat. + std::string TextureParameterName; // TextureSample -> material texture parameter + uint32_t ComponentIndex = 0; // ComponentMask: x, y, z or w. }; // A node graph describing a material's surface. Compiles to an HLSL body that diff --git a/Elixir/Source/Engine/Material/MaterialRenderScene.cpp b/Elixir/Source/Engine/Material/MaterialRenderScene.cpp index 41473d88..45df4fbe 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderScene.cpp +++ b/Elixir/Source/Engine/Material/MaterialRenderScene.cpp @@ -5,10 +5,8 @@ namespace Elixir { /* SMaterialPushConstants */ - std::array SMaterialPushConstants::Resolve( - const uint32_t materialIndex, - const uint32_t additionalTextureIndex - ) const + std::array + SMaterialPushConstants::Resolve(const uint32_t materialIndex) const { auto resolved = Data; @@ -25,7 +23,6 @@ namespace Elixir }; patch(MaterialIndexOffset, materialIndex); - patch(AdditionalTextureIndexOffset, additionalTextureIndex); return resolved; } diff --git a/Elixir/Source/Engine/Material/MaterialRenderScene.h b/Elixir/Source/Engine/Material/MaterialRenderScene.h index c5b1fc1f..1c6f3ea0 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderScene.h +++ b/Elixir/Source/Engine/Material/MaterialRenderScene.h @@ -12,13 +12,11 @@ namespace Elixir std::array Data{}; uint32_t Size = 0; uint32_t MaterialIndexOffset = NO_OFFSET; - uint32_t AdditionalTextureIndexOffset = NO_OFFSET; template static SMaterialPushConstants Create( const T& value, - const uint32_t materialIndexOffset = NO_OFFSET, - const uint32_t additionalTextureIndexOffset = NO_OFFSET + const uint32_t materialIndexOffset = NO_OFFSET ) { EE_CORE_ASSERT( @@ -30,15 +28,11 @@ namespace Elixir Memory::Memcpy(pc.Data.data(), &value, sizeof(T)); pc.Size = sizeof(T); pc.MaterialIndexOffset = materialIndexOffset; - pc.AdditionalTextureIndexOffset = additionalTextureIndexOffset; return pc; } - std::array Resolve( - uint32_t materialIndex, - uint32_t additionalTextureIndex - ) const; + std::array Resolve(uint32_t materialIndex) const; }; struct SMaterialVertexBufferBinding @@ -71,7 +65,6 @@ namespace Elixir { EMaterialPass Pass = EMaterialPass::ParticleSprite; Ref Material; - Ref AdditionalTexture; std::string_view DebugName; uint32_t GeometryIndex = UINT32_MAX; SMaterialPushConstants PushConstants; diff --git a/Elixir/Source/Engine/Material/MaterialSystem.cpp b/Elixir/Source/Engine/Material/MaterialSystem.cpp index 320c5067..7a0bfe41 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.cpp +++ b/Elixir/Source/Engine/Material/MaterialSystem.cpp @@ -61,9 +61,6 @@ namespace Elixir const auto& material = item.Material; if (material) table->Add(*material); - - if (item.AdditionalTexture) - m_Textures.Resolve(item.AdditionalTexture); } if (!table->GetData().empty()) @@ -212,13 +209,7 @@ namespace Elixir for (const auto& batchItem : batch.Items) { const auto& item = *batchItem.Item; - const auto textureIndex = item.AdditionalTexture - ? m_Textures.Find(item.AdditionalTexture) - : m_Textures.GetFallbackIndex(); - const auto constants = item.PushConstants.Resolve( - batchItem.MaterialIndex, - textureIndex - ); + const auto constants = item.PushConstants.Resolve(batchItem.MaterialIndex); prepared->Shader->SetPushConstant( cmd, diff --git a/Elixir/Source/Engine/Material/ParticleMaterialDefaults.cpp b/Elixir/Source/Engine/Material/ParticleMaterialDefaults.cpp index 1e97a3d1..91cabe84 100644 --- a/Elixir/Source/Engine/Material/ParticleMaterialDefaults.cpp +++ b/Elixir/Source/Engine/Material/ParticleMaterialDefaults.cpp @@ -32,6 +32,29 @@ namespace Elixir "A default particle material must enable its particle usage." ) + if (usage == EMaterialUsage::ParticleSprite) + { + material->DefineParameter(std::string(DEFAULT_SPRITE_TEXTURE_PARAMETER), { + .Kind = EMaterialParameterKind::Texture, + .DefaultValue = SMaterialParam::MakeTexture(nullptr), + }); + + MaterialGraph graph; + const auto texture = graph.AddNode({ + .Type = EMaterialNodeType::TextureSample, + .TextureParameterName = std::string(DEFAULT_SPRITE_TEXTURE_PARAMETER), + }); + const auto alpha = graph.AddNode({ + .Type = EMaterialNodeType::ComponentMask, + .Inputs = { int32_t(texture) }, + .ComponentIndex = 3, + }); + graph.SetChannel(EMaterialChannel::BaseColor, texture); + graph.SetChannel(EMaterialChannel::Opacity, alpha); + + material->SetGraph(std::move(graph)); + } + return material; } } diff --git a/Elixir/Source/Engine/Material/ParticleMaterialDefaults.h b/Elixir/Source/Engine/Material/ParticleMaterialDefaults.h index 43678452..5447dc92 100644 --- a/Elixir/Source/Engine/Material/ParticleMaterialDefaults.h +++ b/Elixir/Source/Engine/Material/ParticleMaterialDefaults.h @@ -4,6 +4,9 @@ namespace Elixir { + // Authoring detail of the engine default Sprite graph. It is not a renderer ABI. + inline constexpr std::string_view DEFAULT_SPRITE_TEXTURE_PARAMETER = "SpriteTexture"; + // Creates the engine-owned source material used when a particle emitter // has no authored material proxy for a supported particle usage. Ref CreateDefaultParticleMaterial(EMaterialUsage usage); diff --git a/Elixir/Source/Engine/Material/ParticleMaterialLibrary.cpp b/Elixir/Source/Engine/Material/ParticleMaterialLibrary.cpp index 38561df5..1b211598 100644 --- a/Elixir/Source/Engine/Material/ParticleMaterialLibrary.cpp +++ b/Elixir/Source/Engine/Material/ParticleMaterialLibrary.cpp @@ -34,7 +34,7 @@ namespace Elixir ) if (!proxy) continue; - m_Defaults[GetSlot(usage)] = proxy; + m_Defaults[GetSlot(usage)] = { source, compiled.Material, proxy }; } } @@ -42,11 +42,37 @@ namespace Elixir const EMaterialUsage usage ) const { - const auto& material = m_Defaults[GetSlot(usage)]; + const auto& material = m_Defaults[GetSlot(usage)].Proxy; EE_CORE_ASSERT( material, "Particle material library default is unavailable." ) return material; } + + Ref ParticleMaterialLibrary::GetDefaultSprite( + const Ref& texture + ) + { + if (!texture) return GetDefault(EMaterialUsage::ParticleSprite); + + const auto found = m_SpriteProxies.find(texture.get()); + if (found != m_SpriteProxies.end()) + return found->second; + + const auto& source = m_Defaults[GetSlot(EMaterialUsage::ParticleSprite)]; + const auto instance = CreateRef(source.Source); + + const bool result = instance->SetTexture( + std::string(DEFAULT_SPRITE_TEXTURE_PARAMETER), + texture + ); + EE_CORE_ASSERT(result, "Default Sprite material parameter is unavailable.") + + const auto proxy = instance->CreateRenderProxy(source.Compiled); + EE_CORE_ASSERT(proxy, "Default Sprite material proxy creation failed.") + + m_SpriteProxies.emplace(texture.get(), proxy); + return proxy; + } } diff --git a/Elixir/Source/Engine/Material/ParticleMaterialLibrary.h b/Elixir/Source/Engine/Material/ParticleMaterialLibrary.h index c5093662..66ec9f59 100644 --- a/Elixir/Source/Engine/Material/ParticleMaterialLibrary.h +++ b/Elixir/Source/Engine/Material/ParticleMaterialLibrary.h @@ -14,12 +14,22 @@ namespace Elixir const Ref& GetDefault(EMaterialUsage usage) const; + Ref GetDefaultSprite(const Ref& texture); + private: + struct SDefaultMaterial + { + Ref Source; + Ref Compiled; + Ref Proxy; + }; + static constexpr size_t GetSlot(const EMaterialUsage usage) { return static_cast(usage); } - std::array, 3> m_Defaults; + std::array m_Defaults; + std::unordered_map> m_SpriteProxies; }; } \ No newline at end of file diff --git a/Elixir/Tests/Engine/Material/MaterialGraphTest.cpp b/Elixir/Tests/Engine/Material/MaterialGraphTest.cpp index 5e65734b..9112c354 100644 --- a/Elixir/Tests/Engine/Material/MaterialGraphTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialGraphTest.cpp @@ -71,4 +71,26 @@ TEST(MaterialGraphTest, ScalarChannelsAndSharedNode) 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({ + .Type = EMaterialNodeType::TextureSample, + .TextureParameterName = "Albedo", + }); + const auto alpha = graph.AddNode({ + .Type = EMaterialNodeType::ComponentMask, + .Inputs = { int32_t(texture) }, + .ComponentIndex = 3, + }); + 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); } \ No newline at end of file diff --git a/Elixir/Tests/Engine/Material/MaterialRenderSceneTest.cpp b/Elixir/Tests/Engine/Material/MaterialRenderSceneTest.cpp index 6ebc802f..8f895856 100644 --- a/Elixir/Tests/Engine/Material/MaterialRenderSceneTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialRenderSceneTest.cpp @@ -28,25 +28,22 @@ TEST(MaterialRenderSceneTest, PreservesAnUnboundMaterialItem) EXPECT_FALSE(items.front().Material); } -TEST(MaterialRenderSceneTest, ResolvesLateMaterialAndTextureIndices) +TEST(MaterialRenderSceneTest, ResolvesLateMaterialIndex) { struct SPushConstants { uint32_t MaterialIndex = UINT32_MAX; - uint32_t TextureIndex = UINT32_MAX; }; const auto constants = SMaterialPushConstants::Create( SPushConstants{}, - offsetof(SPushConstants, MaterialIndex), - offsetof(SPushConstants, TextureIndex) + offsetof(SPushConstants, MaterialIndex) ); - const auto resolved = constants.Resolve(17, 9); + const auto resolved = constants.Resolve(17); SPushConstants values{}; Memory::Memcpy(&values, resolved.data(), sizeof(values)); EXPECT_EQ(values.MaterialIndex, 17); - EXPECT_EQ(values.TextureIndex, 9); } \ No newline at end of file diff --git a/Elixir/Tests/Engine/Material/ParticleMaterialDefaultsTest.cpp b/Elixir/Tests/Engine/Material/ParticleMaterialDefaultsTest.cpp index 671b5c60..71d76363 100644 --- a/Elixir/Tests/Engine/Material/ParticleMaterialDefaultsTest.cpp +++ b/Elixir/Tests/Engine/Material/ParticleMaterialDefaultsTest.cpp @@ -18,4 +18,11 @@ TEST(ParticleMaterialDefaultsTest, CreatesAValidMaterialForEachParticleUsage) EXPECT_TRUE(material->SupportsUsage(usage)); EXPECT_TRUE(material->ValidateGraph()); } +} + +TEST(ParticleMaterialDefaultsTest, AuthorsSpriteTextureIntoBaseColorAndOpacity) +{ + const auto material = CreateDefaultParticleMaterial(EMaterialUsage::ParticleSprite); + ASSERT_NE(material->FindParameter(std::string(DEFAULT_SPRITE_TEXTURE_PARAMETER)), nullptr); + EXPECT_TRUE(material->ValidateGraph()); } \ No newline at end of file diff --git a/Shaders/Material/Material.ps.hlsl b/Shaders/Material/Material.ps.hlsl index 144a9081..40a1f6b0 100644 --- a/Shaders/Material/Material.ps.hlsl +++ b/Shaders/Material/Material.ps.hlsl @@ -58,18 +58,19 @@ struct PSInput struct Surface { - float3 BaseColor; - float3 Normal; // tangent-space perturbation - float Metallic; - float Roughness; - float3 Emissive; + float3 BaseColor; + float3 Normal; + float Metallic; + float Roughness; + float Opacity; + float3 Emissive; }; static const uint NO_TEXTURE = 0xFFFFFFFFu; -float3 SampleTex(uint index, float2 uv) +float4 SampleTex(uint index, float2 uv) { - return textures[index].Sample(texSampler, uv).rgb; + return textures[index].Sample(texSampler, uv); } float2 DirToEquirect(float3 dir) @@ -114,6 +115,7 @@ float4 main(PSInput input) : SV_Target0 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__ @@ -140,5 +142,5 @@ float4 main(PSInput input) : SV_Target0 // Tone mapping color = ACESFilm(color); - return float4(color, 1.0f); + 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 index 036c56d3..0b3208de 100644 --- a/Shaders/Material/ParticleMesh.ps.hlsl +++ b/Shaders/Material/ParticleMesh.ps.hlsl @@ -53,12 +53,13 @@ struct Surface float3 Normal; float Metallic; float Roughness; + float Opacity; float3 Emissive; }; -float3 SampleTex(uint index, float2 uv) +float4 SampleTex(uint index, float2 uv) { - return sprites[index].Sample(spriteSampler, uv).rgb; + return sprites[index].Sample(spriteSampler, uv); } float4 main(PSInput input) : SV_Target0 @@ -70,6 +71,7 @@ float4 main(PSInput input) : SV_Target0 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__ @@ -84,5 +86,5 @@ float4 main(PSInput input) : SV_Target0 surface.Emissive + (RIM_COLOR * rim * 0.35f); - return float4(litColor, 1.0f); + return float4(litColor, surface.Opacity); } \ No newline at end of file diff --git a/Shaders/Material/ParticleRibbon.ps.hlsl b/Shaders/Material/ParticleRibbon.ps.hlsl index f39e8b71..d034e78f 100644 --- a/Shaders/Material/ParticleRibbon.ps.hlsl +++ b/Shaders/Material/ParticleRibbon.ps.hlsl @@ -51,12 +51,13 @@ struct Surface float3 Normal; float Metallic; float Roughness; + float Opacity; float3 Emissive; }; -float3 SampleTex(uint index, float2 uv) +float4 SampleTex(uint index, float2 uv) { - return sprites[index].Sample(spriteSampler, uv).rgb; + return sprites[index].Sample(spriteSampler, uv); } float4 main(PSInput input) : SV_Target0 @@ -70,6 +71,7 @@ float4 main(PSInput input) : SV_Target0 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__ @@ -82,5 +84,5 @@ float4 main(PSInput input) : SV_Target0 const float3 color = surface.BaseColor + surface.Emissive; // return float4(color, input.Color.a * edgeFade); - return float4(color, 1.0f); + return float4(color, surface.Opacity); } \ No newline at end of file diff --git a/Shaders/Material/ParticleSprite.ps.hlsl b/Shaders/Material/ParticleSprite.ps.hlsl index febab53b..569063a8 100644 --- a/Shaders/Material/ParticleSprite.ps.hlsl +++ b/Shaders/Material/ParticleSprite.ps.hlsl @@ -30,7 +30,6 @@ struct MaterialPushConstants { float4x4 WorldTransform; uint MaterialIndex; - uint SpriteIndex; }; [[vk::push_constant]] @@ -49,12 +48,13 @@ struct Surface float3 Normal; float Metallic; float Roughness; + float Opacity; float3 Emissive; }; -float3 SampleTex(uint index, float2 uv) +float4 SampleTex(uint index, float2 uv) { - return sprites[index].Sample(spriteSampler, uv).rgb; + return sprites[index].Sample(spriteSampler, uv); } float4 main(PSInput input) : SV_Target0 @@ -66,13 +66,10 @@ float4 main(PSInput input) : SV_Target0 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 float4 sprite = sprites[pc.SpriteIndex].Sample(spriteSampler, input.TexCoord); - const float3 color = input.Color.rgb * sprite.rgb * surface.BaseColor + surface.Emissive; - const float alpha = input.Color.a * sprite.a; - - return float4(color, alpha); + return float4(surface.BaseColor + surface.Emissive, surface.Opacity); } \ No newline at end of file From 8746dae362a999c3345d76f2741b17b563e28534 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Mon, 3 Aug 2026 20:19:49 -0300 Subject: [PATCH 28/89] refactor(material): author particle effect materials from assets Resolve effect material colors and emission into immutable graph constants during import. Move particle material compilation and caching into Engine/Material and migrate VFX assets to explicit material definitions. --- Assets/VFX/FireAndFireworks.json | 20 ++-- Assets/VFX/RainStorm.json | 4 +- Assets/VFX/RibbonGarden.json | 3 + Assets/VFX/RibbonVortex.json | 5 +- Elixir/Source/Engine/Aether/Effect.cpp | 95 ++++++++++++++-- Elixir/Source/Engine/Aether/System.h | 1 + .../Material/ParticleMaterialDefaults.cpp | 47 ++++++-- .../Material/ParticleMaterialDefaults.h | 6 +- .../Material/ParticleMaterialDescription.h | 18 +++ .../Material/ParticleMaterialLibrary.cpp | 104 ++++++++---------- .../Engine/Material/ParticleMaterialLibrary.h | 31 +++--- .../Material/ParticleMaterialDefaultsTest.cpp | 21 +++- 12 files changed, 246 insertions(+), 109 deletions(-) create mode 100644 Elixir/Source/Engine/Material/ParticleMaterialDescription.h 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/Elixir/Source/Engine/Aether/Effect.cpp b/Elixir/Source/Engine/Aether/Effect.cpp index 8a7d7799..35556302 100644 --- a/Elixir/Source/Engine/Aether/Effect.cpp +++ b/Elixir/Source/Engine/Aether/Effect.cpp @@ -861,13 +861,83 @@ 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); + } + + SParticleMaterialDescription ParseMaterial( + od::object& json, + const EParticleRenderMode renderMode, + const Emitter& emitter, + const System& system + ) + { + SParticleMaterialDescription desc{ + .Usage = GetParticleMaterialUsage(renderMode), + }; + + auto field = json["material"]; + if (field.error()) + { + Fail("there is no material."); + return desc; + } + + od::object material; + if (field.get_object().get(material)) + { + Fail("'material' must be an object."); + return desc; + } + + 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); + + if (renderMode == EParticleRenderMode::Sprite) + { + const auto texturePath = ParseString(material, "texture", ""); + if (!texturePath.empty()) + { + desc.Texture = TextureLoader::Load(texturePath); + if (!desc.Texture) + Fail("Could not load material texture '{}'.", texturePath); + } + } + + 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"); @@ -876,6 +946,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; @@ -910,16 +983,16 @@ namespace Elixir::Aether if (m_Failed) return; - if (renderMode == EParticleRenderMode::Sprite && !spriteTexture.empty()) - { - const auto texture = TextureLoader::Load(spriteTexture); - if (!texture) { Fail("Could not load sprite texture '{}'.", spriteTexture); return; } - emitter.SetMaterial(m_Materials.GetDefaultSprite(texture)); - } - else - { - emitter.SetMaterial(m_Materials.GetDefault(GetParticleMaterialUsage(renderMode))); - } + const auto material = ParseMaterial( + json, + renderMode, + emitter, + *system + ); + + if (m_Failed) return; + + emitter.SetMaterial(m_Materials.Create(material)); if (!spawnRate.Param.empty()) emitter.SetSpawnRateParamName(spawnRate.Param); diff --git a/Elixir/Source/Engine/Aether/System.h b/Elixir/Source/Engine/Aether/System.h index 71c33a57..84f91d0c 100644 --- a/Elixir/Source/Engine/Aether/System.h +++ b/Elixir/Source/Engine/Aether/System.h @@ -59,6 +59,7 @@ namespace Elixir::Aether SCompiledSystem Compile() const; ParameterStore& GetParameters() { return m_Parameters; } + const ParameterStore& GetParameters() const { return m_Parameters; } CurveStore& GetCurves() { return m_Curves; } ColorCurveStore& GetColorCurves() { return m_ColorCurves; } diff --git a/Elixir/Source/Engine/Material/ParticleMaterialDefaults.cpp b/Elixir/Source/Engine/Material/ParticleMaterialDefaults.cpp index 91cabe84..9e7f3dd3 100644 --- a/Elixir/Source/Engine/Material/ParticleMaterialDefaults.cpp +++ b/Elixir/Source/Engine/Material/ParticleMaterialDefaults.cpp @@ -21,40 +21,69 @@ namespace Elixir } } - Ref CreateDefaultParticleMaterial(const EMaterialUsage usage) + Ref CreateParticleMaterial(const SParticleMaterialDescription& desc) { - const auto name = GetDefaultParticleMaterialName(usage); + const auto name = GetDefaultParticleMaterialName(desc.Usage); const auto material = CreateRef(name); - const bool usageWasEnabled = material->SetUsage(usage, true); + const bool usageWasEnabled = material->SetUsage(desc.Usage, true); EE_CORE_ASSERT( usageWasEnabled, "A default particle material must enable its particle usage." ) - if (usage == EMaterialUsage::ParticleSprite) + MaterialGraph graph; + const auto baseColor = graph.AddNode({ + .Type = EMaterialNodeType::Constant, + .OutputType = EMaterialGraphValueType::Float3, + .ConstantValue = { desc.BaseColor, 0.0f }, + }); + const auto opacity = graph.AddNode({ + .Type = EMaterialNodeType::Constant, + .OutputType = EMaterialGraphValueType::Float, + .ConstantValue = { desc.Opacity, 0.0f, 0.0f, 0.0f }, + }); + const auto emissive = graph.AddNode({ + .Type = EMaterialNodeType::Constant, + .OutputType = EMaterialGraphValueType::Float3, + .ConstantValue = { desc.Emissive, 0.0f }, + }); + + graph.SetChannel(EMaterialChannel::BaseColor, baseColor); + graph.SetChannel(EMaterialChannel::Opacity, opacity); + graph.SetChannel(EMaterialChannel::Emissive, emissive); + + if (desc.Usage == EMaterialUsage::ParticleSprite) { material->DefineParameter(std::string(DEFAULT_SPRITE_TEXTURE_PARAMETER), { .Kind = EMaterialParameterKind::Texture, .DefaultValue = SMaterialParam::MakeTexture(nullptr), }); - MaterialGraph graph; const auto texture = graph.AddNode({ .Type = EMaterialNodeType::TextureSample, .TextureParameterName = std::string(DEFAULT_SPRITE_TEXTURE_PARAMETER), }); - const auto alpha = graph.AddNode({ + const auto textureOpacity = graph.AddNode({ .Type = EMaterialNodeType::ComponentMask, .Inputs = { int32_t(texture) }, .ComponentIndex = 3, }); - graph.SetChannel(EMaterialChannel::BaseColor, texture); - graph.SetChannel(EMaterialChannel::Opacity, alpha); + const auto texturedBaseColor = graph.AddNode({ + .Type = EMaterialNodeType::Multiply, + .Inputs = { int32_t(baseColor), int32_t(texture) }, + }); + const auto texturedOpacity = graph.AddNode({ + .Type = EMaterialNodeType::Multiply, + .Inputs = { int32_t(opacity), int32_t(textureOpacity) }, + }); + graph.SetChannel(EMaterialChannel::BaseColor, texturedBaseColor); + graph.SetChannel(EMaterialChannel::Opacity, texturedOpacity); - material->SetGraph(std::move(graph)); } + material->SetGraph(std::move(graph)); + return material; } } diff --git a/Elixir/Source/Engine/Material/ParticleMaterialDefaults.h b/Elixir/Source/Engine/Material/ParticleMaterialDefaults.h index 5447dc92..4c240289 100644 --- a/Elixir/Source/Engine/Material/ParticleMaterialDefaults.h +++ b/Elixir/Source/Engine/Material/ParticleMaterialDefaults.h @@ -1,13 +1,13 @@ #pragma once -#include +#include namespace Elixir { - // Authoring detail of the engine default Sprite graph. It is not a renderer ABI. + // Authoring detail of the engine Sprite graph. It is not a renderer ABI. inline constexpr std::string_view DEFAULT_SPRITE_TEXTURE_PARAMETER = "SpriteTexture"; // Creates the engine-owned source material used when a particle emitter // has no authored material proxy for a supported particle usage. - Ref CreateDefaultParticleMaterial(EMaterialUsage usage); + Ref CreateParticleMaterial(const SParticleMaterialDescription& desc); } \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/ParticleMaterialDescription.h b/Elixir/Source/Engine/Material/ParticleMaterialDescription.h new file mode 100644 index 00000000..ab38c168 --- /dev/null +++ b/Elixir/Source/Engine/Material/ParticleMaterialDescription.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +namespace Elixir +{ + // Immutable authoring values used to create one particle material. + // They are resolved before this layer is called and become graph constants. + struct SParticleMaterialDescription + { + EMaterialUsage Usage = EMaterialUsage::ParticleSprite; + glm::vec3 BaseColor{ 1.0f }; + float Opacity = 1.0f; + glm::vec3 Emissive{ 0.0f }; + Ref Texture; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/ParticleMaterialLibrary.cpp b/Elixir/Source/Engine/Material/ParticleMaterialLibrary.cpp index 1b211598..0800c387 100644 --- a/Elixir/Source/Engine/Material/ParticleMaterialLibrary.cpp +++ b/Elixir/Source/Engine/Material/ParticleMaterialLibrary.cpp @@ -8,71 +8,63 @@ namespace Elixir { ParticleMaterialLibrary::ParticleMaterialLibrary(const ShaderLoader* shaderLoader) - { - for (const auto usage : { - EMaterialUsage::ParticleSprite, - EMaterialUsage::ParticleRibbon, - EMaterialUsage::ParticleMesh, - }) - { - const auto source = CreateDefaultParticleMaterial(usage); - const auto compiled = MaterialCompiler::Compile(shaderLoader, *source); - - EE_CORE_ASSERT( - compiled, - "Default particle material compilation failed: {}", - compiled.Diagnostics - ) - if (!compiled) continue; - - const auto instance = CreateRef(source); - const auto proxy = instance->CreateRenderProxy(compiled.Material); - - EE_CORE_ASSERT( - proxy, - "Default particle material render proxy creation failed." - ) - if (!proxy) continue; - - m_Defaults[GetSlot(usage)] = { source, compiled.Material, proxy }; - } - } - - const Ref& ParticleMaterialLibrary::GetDefault( - const EMaterialUsage usage - ) const - { - const auto& material = m_Defaults[GetSlot(usage)].Proxy; - EE_CORE_ASSERT( - material, - "Particle material library default is unavailable." - ) - return material; - } + : m_ShaderLoader(shaderLoader) {} - Ref ParticleMaterialLibrary::GetDefaultSprite( - const Ref& texture + Ref ParticleMaterialLibrary::Create( + const SParticleMaterialDescription& desc ) { - if (!texture) return GetDefault(EMaterialUsage::ParticleSprite); + const SMaterialKey key{ + .Usage = desc.Usage, + .BaseColor = desc.BaseColor, + .Opacity = desc.Opacity, + .Emissive = desc.Emissive, + .TextureIdentity = desc.Texture.get(), + }; - const auto found = m_SpriteProxies.find(texture.get()); - if (found != m_SpriteProxies.end()) + const auto found = m_Proxies.find(key); + if (found != m_Proxies.end()) return found->second; - const auto& source = m_Defaults[GetSlot(EMaterialUsage::ParticleSprite)]; - const auto instance = CreateRef(source.Source); + const auto& source = CreateParticleMaterial(desc); + const auto compiled = MaterialCompiler::Compile(m_ShaderLoader, *source); + EE_CORE_ASSERT(compiled, "Particle material compilation failed: {}", compiled.Diagnostics) + if (!compiled) return nullptr; - const bool result = instance->SetTexture( - std::string(DEFAULT_SPRITE_TEXTURE_PARAMETER), - texture - ); - EE_CORE_ASSERT(result, "Default Sprite material parameter is unavailable.") + const auto instance = CreateRef(source); + if (desc.Usage == EMaterialUsage::ParticleSprite) + { + const bool bound = instance->SetTexture( + std::string(DEFAULT_SPRITE_TEXTURE_PARAMETER), + desc.Texture + ); + EE_CORE_ASSERT(bound, "Particle Sprite texture parameter is unavailable.") + } - const auto proxy = instance->CreateRenderProxy(source.Compiled); - EE_CORE_ASSERT(proxy, "Default Sprite material proxy creation failed.") + auto proxy = instance->CreateRenderProxy(compiled.Material); + EE_CORE_ASSERT(proxy, "Particle material proxy creation failed.") + if (!proxy) return nullptr; - m_SpriteProxies.emplace(texture.get(), proxy); + m_Proxies.emplace(key, proxy); return proxy; } + + size_t ParticleMaterialLibrary::SMaterialKeyHasher::operator()(const SMaterialKey& key) const + { + size_t hash = Hash::Hash(uint32_t(key.Usage)); + const auto mix = [&hash](const size_t value) + { + hash ^= value + 0x9e3779b9 + (hash << 6) + (hash >> 2); + }; + + mix(Hash::Hash(key.BaseColor.x)); + mix(Hash::Hash(key.BaseColor.y)); + mix(Hash::Hash(key.BaseColor.z)); + mix(Hash::Hash(key.Opacity)); + mix(Hash::Hash(key.Emissive.x)); + mix(Hash::Hash(key.Emissive.y)); + mix(Hash::Hash(key.Emissive.z)); + mix(Hash::Hash(key.TextureIdentity)); + return hash; + } } diff --git a/Elixir/Source/Engine/Material/ParticleMaterialLibrary.h b/Elixir/Source/Engine/Material/ParticleMaterialLibrary.h index 66ec9f59..c836dc6a 100644 --- a/Elixir/Source/Engine/Material/ParticleMaterialLibrary.h +++ b/Elixir/Source/Engine/Material/ParticleMaterialLibrary.h @@ -1,35 +1,38 @@ #pragma once #include +#include #include namespace Elixir { - // Owns compiled built-in particle material proxies. This is an asset - // service: MaterialSystem never chooses a fallback while rendering. + // Owns compiled particle material proxies. This is an asset service: + // MaterialSystem never chooses or interprets a material while rendering. class ELIXIR_API ParticleMaterialLibrary final { public: explicit ParticleMaterialLibrary(const ShaderLoader* shaderLoader); - const Ref& GetDefault(EMaterialUsage usage) const; - - Ref GetDefaultSprite(const Ref& texture); + Ref Create(const SParticleMaterialDescription& desc); private: - struct SDefaultMaterial + struct SMaterialKey { - Ref Source; - Ref Compiled; - Ref Proxy; + EMaterialUsage Usage = EMaterialUsage::ParticleSprite; + glm::vec3 BaseColor{ 1.0f }; + float Opacity = 1.0f; + glm::vec3 Emissive{ 0.0f }; + const Texture* TextureIdentity = nullptr; + + bool operator==(const SMaterialKey&) const = default; }; - static constexpr size_t GetSlot(const EMaterialUsage usage) + struct SMaterialKeyHasher { - return static_cast(usage); - } + size_t operator()(const SMaterialKey& key) const; + }; - std::array m_Defaults; - std::unordered_map> m_SpriteProxies; + const ShaderLoader* m_ShaderLoader = nullptr; + std::unordered_map, SMaterialKeyHasher> m_Proxies; }; } \ No newline at end of file diff --git a/Elixir/Tests/Engine/Material/ParticleMaterialDefaultsTest.cpp b/Elixir/Tests/Engine/Material/ParticleMaterialDefaultsTest.cpp index 71d76363..aa8a1d0b 100644 --- a/Elixir/Tests/Engine/Material/ParticleMaterialDefaultsTest.cpp +++ b/Elixir/Tests/Engine/Material/ParticleMaterialDefaultsTest.cpp @@ -4,7 +4,7 @@ using namespace Elixir; -TEST(ParticleMaterialDefaultsTest, CreatesAValidMaterialForEachParticleUsage) +TEST(ParticleMaterialDefaultsTest, AuthorsConstantsForEachParticleUsage) { for (const auto usage: { EMaterialUsage::ParticleSprite, @@ -12,17 +12,32 @@ TEST(ParticleMaterialDefaultsTest, CreatesAValidMaterialForEachParticleUsage) EMaterialUsage::ParticleMesh, }) { - const auto material = CreateDefaultParticleMaterial(usage); + const auto material = CreateParticleMaterial({ + .Usage = usage, + .BaseColor = { 0.25f, 0.5f, 0.75f }, + .Opacity = 0.4f, + .Emissive = { 1.5f, 0.2f, 0.1f }, + }); ASSERT_TRUE(material); EXPECT_TRUE(material->SupportsUsage(usage)); EXPECT_TRUE(material->ValidateGraph()); + + const auto hlsl = material->GetGraph().GenerateHLSL(); + EXPECT_NE(hlsl.find("surface.BaseColor"), std::string::npos); + EXPECT_NE(hlsl.find("0.400000"), std::string::npos); + EXPECT_NE(hlsl.find("surface.Opacity"), std::string::npos); + EXPECT_NE(hlsl.find("surface.Emissive"), std::string::npos); } } TEST(ParticleMaterialDefaultsTest, AuthorsSpriteTextureIntoBaseColorAndOpacity) { - const auto material = CreateDefaultParticleMaterial(EMaterialUsage::ParticleSprite); + const auto material = CreateParticleMaterial({ + .Usage = EMaterialUsage::ParticleSprite, + .BaseColor = { 0.8f, 0.4f, 0.2f }, + .Opacity = 0.6f, + }); ASSERT_NE(material->FindParameter(std::string(DEFAULT_SPRITE_TEXTURE_PARAMETER)), nullptr); EXPECT_TRUE(material->ValidateGraph()); } \ No newline at end of file From 896995aba1458ac1f72717fd170b6a6e6a880d86 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Tue, 4 Aug 2026 00:11:50 -0300 Subject: [PATCH 29/89] refactor(material): centralize material system composition Move MaterialSystem ownership to Application and inject it into Aether. Keep the frame material capacity in central configuration instead of particle pool limits. --- Dissolve/Source/Dissolve.cpp | 6 +++++- .../Engine/Aether/ParticleResourcePool.h | 1 - Elixir/Source/Engine/Aether/Renderer.cpp | 12 +++++++----- Elixir/Source/Engine/Aether/Renderer.h | 10 ++++++++-- Elixir/Source/Engine/Core/Application.cpp | 18 ++++++++++++++++++ Elixir/Source/Engine/Core/Application.h | 8 +++++++- .../Source/Engine/Material/MaterialSystem.cpp | 17 ++++++++++++++--- Elixir/Source/Engine/Material/MaterialSystem.h | 10 +++++++++- 8 files changed, 68 insertions(+), 14 deletions(-) diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index e04fff97..927ebe92 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -65,7 +65,11 @@ Dissolve::Dissolve() shader->BindConstantBuffer("cbFrame", m_FrameConstantBuffer); - m_ParticlesRenderer = CreateScope(m_GraphicsContext.get(), m_ShaderLoader.get()); + m_ParticlesRenderer = CreateScope( + m_GraphicsContext.get(), + m_ShaderLoader.get(), + GetMaterialSystem() + ); particleMaterialLibrary = CreateScope(m_ShaderLoader.get()); diff --git a/Elixir/Source/Engine/Aether/ParticleResourcePool.h b/Elixir/Source/Engine/Aether/ParticleResourcePool.h index 4f348af9..b4a1018a 100644 --- a/Elixir/Source/Engine/Aether/ParticleResourcePool.h +++ b/Elixir/Source/Engine/Aether/ParticleResourcePool.h @@ -21,7 +21,6 @@ namespace Elixir::Aether uint32_t OpCapacity = 65'536; uint32_t ParameterCapacity = 16'384; uint32_t TriggerTargetCapacity = 4'096; - uint32_t MaterialCapacity = 4'096; uint32_t TriggerEventCapacityPerEmitter = 64; }; diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index 37fb3f92..e39b5c68 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -1,7 +1,8 @@ #include "epch.h" #include "Renderer.h" -#include "Engine/Graphics/CommandBuffer.h" +#include +#include namespace Elixir::Aether { @@ -145,11 +146,12 @@ namespace Elixir::Aether Renderer::Renderer( const GraphicsContext* context, const ShaderLoader* shaderLoader, + MaterialSystem& materialSystem, const SParticlePoolLimits& limits ) : m_ParticlePoolLimits(limits), m_ParticleStateLayouts(m_ParticlePoolLimits.ParticleCapacity), m_ParticleResourcePool(m_ParticlePoolLimits, m_ParticleStateLayouts), - m_MaterialSystem(CreateRef(context, limits.MaterialCapacity)), + m_MaterialSystem(materialSystem), m_GraphicsContext(context) { static_assert(sizeof(SGPUParticleState) == PARTICLE_STATE_CORE_V1_STRIDE); @@ -240,7 +242,7 @@ namespace Elixir::Aether return; const auto materialScene = BuildMaterialRenderScene(submittedInstances); - const auto materialSnapshot = m_MaterialSystem->BuildFrameSnapshot( + const auto materialSnapshot = m_MaterialSystem.BuildFrameSnapshot( materialScene, m_SubmissionSerial ); @@ -275,7 +277,7 @@ namespace Elixir::Aether BeginRendering(cmd); - const auto materialResult = m_MaterialSystem->Render( + const auto materialResult = m_MaterialSystem.Render( cmd, materialScene, materialSnapshot @@ -996,7 +998,7 @@ namespace Elixir::Aether return batches; } - MaterialRenderScene Renderer::BuildMaterialRenderScene( + MaterialRenderScene Renderer:: BuildMaterialRenderScene( const std::vector& instances ) const { diff --git a/Elixir/Source/Engine/Aether/Renderer.h b/Elixir/Source/Engine/Aether/Renderer.h index c9a083f5..0bcfe8fd 100644 --- a/Elixir/Source/Engine/Aether/Renderer.h +++ b/Elixir/Source/Engine/Aether/Renderer.h @@ -6,10 +6,15 @@ #include #include #include -#include #include #include +namespace Elixir +{ + class MaterialSystem; + class MaterialRenderScene; +} + namespace Elixir::Aether { struct alignas(16) SFrameData @@ -148,6 +153,7 @@ namespace Elixir::Aether Renderer( const GraphicsContext* context, const ShaderLoader* shaderLoader, + MaterialSystem& materialSystem, const SParticlePoolLimits& limits = {} ); @@ -317,7 +323,7 @@ namespace Elixir::Aether Ref m_ParameterBuffer; Ref m_ParamsBuffer; - Ref m_MaterialSystem; + MaterialSystem& m_MaterialSystem; uint32_t m_MeshVertexCount = 0; Ref m_MeshVertexBuffer; diff --git a/Elixir/Source/Engine/Core/Application.cpp b/Elixir/Source/Engine/Core/Application.cpp index b743077b..7099dde9 100644 --- a/Elixir/Source/Engine/Core/Application.cpp +++ b/Elixir/Source/Engine/Core/Application.cpp @@ -12,6 +12,7 @@ #include #include #include +#include namespace Elixir { @@ -44,6 +45,11 @@ namespace Elixir m_Window->GetFramebufferExtent() // TODO: Get from Ctx->GetRenderTargetExtent().. ); + m_MaterialSystem = CreateScope( + m_GraphicsContext.get(), + SMaterialSystemConfig{ .InitialFrameCapacity = 256 } + ); + const auto buttonBg = TextureLoader::Load("./Assets/Button_Background.png"); const auto panel = CreateRef(); @@ -199,6 +205,18 @@ 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; + } + 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..c37ca262 100644 --- a/Elixir/Source/Engine/Core/Application.h +++ b/Elixir/Source/Engine/Core/Application.h @@ -14,6 +14,8 @@ namespace Elixir { namespace GUI { class TextBlock; } + class MaterialSystem; + class ELIXIR_API Application { public: @@ -26,7 +28,10 @@ namespace Elixir virtual void OnRender(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; static Application& Get() { return *s_Application; } @@ -40,6 +45,7 @@ namespace Elixir Scope m_ShaderLoader; Scope m_GUIManager; + Scope m_MaterialSystem; Timer m_Timer; FrameProfiler m_Profiler; diff --git a/Elixir/Source/Engine/Material/MaterialSystem.cpp b/Elixir/Source/Engine/Material/MaterialSystem.cpp index 7a0bfe41..828093eb 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.cpp +++ b/Elixir/Source/Engine/Material/MaterialSystem.cpp @@ -25,13 +25,24 @@ namespace Elixir SMaterialBatchKey Key; std::vector Items; }; + + uint32_t GetInitialFrameCapacity(const SMaterialSystemConfig config) + { + EE_CORE_ASSERT( + config.InitialFrameCapacity != 0, + "Material system frame capacity must be greater than zero." + ) + return config.InitialFrameCapacity; + } } - MaterialSystem::MaterialSystem(const GraphicsContext* context, const uint32_t capacity) - : m_MaterialCapacity(capacity), + MaterialSystem::MaterialSystem( + const GraphicsContext* context, + SMaterialSystemConfig config + ) : m_MaterialCapacity(GetInitialFrameCapacity(config)), m_FrameBuffer(DynamicStorageBuffer::Create( context, - sizeof(SMaterialFrameData) * capacity) + sizeof(SMaterialFrameData) * m_MaterialCapacity) ), m_Textures(context), m_Renderer(CreateScope( diff --git a/Elixir/Source/Engine/Material/MaterialSystem.h b/Elixir/Source/Engine/Material/MaterialSystem.h index 2abefbbe..f290cba8 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.h +++ b/Elixir/Source/Engine/Material/MaterialSystem.h @@ -8,6 +8,11 @@ namespace Elixir { + struct SMaterialSystemConfig + { + uint32_t InitialFrameCapacity = 256; + }; + struct SMaterialFrameSnapshot { Ref Table; @@ -24,7 +29,10 @@ namespace Elixir class ELIXIR_API MaterialSystem final { public: - MaterialSystem(const GraphicsContext* context, uint32_t capacity); + MaterialSystem( + const GraphicsContext* context, + SMaterialSystemConfig config + ); SMaterialFrameSnapshot BuildFrameSnapshot( const MaterialRenderScene& scene, From 479b0d3ae67a46836b61673b836758ba4138060f Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Tue, 4 Aug 2026 11:57:49 -0300 Subject: [PATCH 30/89] refactor(material): add global material library Register default source materials and cache compiled results by material revision. Add the radial gradient exponential node for the default sprite opacity. --- Elixir/Source/Engine/Core/Application.cpp | 22 ++++- Elixir/Source/Engine/Core/Application.h | 8 +- .../Engine/Material/DefaultMaterials.cpp | 92 +++++++++++++++++++ .../Source/Engine/Material/DefaultMaterials.h | 12 +++ Elixir/Source/Engine/Material/Material.cpp | 7 ++ Elixir/Source/Engine/Material/Material.h | 11 ++- .../Source/Engine/Material/MaterialGraph.cpp | 25 +++++ Elixir/Source/Engine/Material/MaterialGraph.h | 43 +++++---- .../Engine/Material/MaterialLibrary.cpp | 68 ++++++++++++++ .../Source/Engine/Material/MaterialLibrary.h | 36 ++++++++ .../Engine/Material/MaterialGraphTest.cpp | 22 +++++ .../Engine/Material/MaterialLibraryTest.cpp | 31 +++++++ Elixir/Tests/Engine/Material/MaterialTest.cpp | 12 +++ 13 files changed, 362 insertions(+), 27 deletions(-) create mode 100644 Elixir/Source/Engine/Material/DefaultMaterials.cpp create mode 100644 Elixir/Source/Engine/Material/DefaultMaterials.h create mode 100644 Elixir/Source/Engine/Material/MaterialLibrary.cpp create mode 100644 Elixir/Source/Engine/Material/MaterialLibrary.h create mode 100644 Elixir/Tests/Engine/Material/MaterialLibraryTest.cpp diff --git a/Elixir/Source/Engine/Core/Application.cpp b/Elixir/Source/Engine/Core/Application.cpp index 7099dde9..6e4f0f1f 100644 --- a/Elixir/Source/Engine/Core/Application.cpp +++ b/Elixir/Source/Engine/Core/Application.cpp @@ -13,6 +13,7 @@ #include #include #include +#include namespace Elixir { @@ -38,6 +39,12 @@ namespace Elixir TextureLoader::Initialize(m_GraphicsContext.get()); FontManager::Initialize(m_GraphicsContext.get()); + m_MaterialLibrary = CreateScope(m_ShaderLoader.get()); + m_MaterialSystem = CreateScope( + m_GraphicsContext.get(), + SMaterialSystemConfig{ .InitialFrameCapacity = 256 } + ); + m_GUIManager = CreateScope(); m_GUIManager->Initialize( m_GraphicsContext.get(), @@ -45,11 +52,6 @@ namespace Elixir m_Window->GetFramebufferExtent() // TODO: Get from Ctx->GetRenderTargetExtent().. ); - m_MaterialSystem = CreateScope( - m_GraphicsContext.get(), - SMaterialSystemConfig{ .InitialFrameCapacity = 256 } - ); - const auto buttonBg = TextureLoader::Load("./Assets/Button_Background.png"); const auto panel = CreateRef(); @@ -217,6 +219,16 @@ namespace Elixir return *m_MaterialSystem; } + MaterialLibrary& Application::GetMaterialLibrary() + { + return *m_MaterialLibrary; + } + + const MaterialLibrary& Application::GetMaterialLibrary() const + { + return *m_MaterialLibrary; + } + 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 c37ca262..f8177a5e 100644 --- a/Elixir/Source/Engine/Core/Application.h +++ b/Elixir/Source/Engine/Core/Application.h @@ -15,6 +15,7 @@ namespace Elixir namespace GUI { class TextBlock; } class MaterialSystem; + class MaterialLibrary; class ELIXIR_API Application { @@ -33,6 +34,9 @@ namespace Elixir MaterialSystem& GetMaterialSystem(); const MaterialSystem& GetMaterialSystem() const; + MaterialLibrary& GetMaterialLibrary(); + const MaterialLibrary& GetMaterialLibrary() const; + static Application& Get() { return *s_Application; } protected: @@ -45,7 +49,9 @@ namespace Elixir Scope m_ShaderLoader; Scope m_GUIManager; + Scope m_MaterialSystem; + Scope m_MaterialLibrary; Timer m_Timer; FrameProfiler m_Profiler; @@ -63,4 +69,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/Material/DefaultMaterials.cpp b/Elixir/Source/Engine/Material/DefaultMaterials.cpp new file mode 100644 index 00000000..d5f27d91 --- /dev/null +++ b/Elixir/Source/Engine/Material/DefaultMaterials.cpp @@ -0,0 +1,92 @@ +#include "epch.h" +#include "DefaultMaterials.h" + +namespace Elixir +{ + 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({ + .Type = EMaterialNodeType::Constant, + .OutputType = EMaterialGraphValueType::Float3, + .ConstantValue = { 1.0f, 1.0f, 1.0f, 0.0f }, + }); + const auto opacity = graph.AddNode({ + .Type = EMaterialNodeType::RadialGradientExponential, + .OutputType = EMaterialGraphValueType::Float, + .RadialGradientCenter = { 0.5f, 0.5f }, + .RadialGradientRadius = 0.5f, + .RadialGradientExponent = 2.0f, + }); + graph.SetChannel(EMaterialChannel::BaseColor, baseColor); + 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({ + .Type = EMaterialNodeType::Constant, + .OutputType = EMaterialGraphValueType::Float3, + .ConstantValue = { 1.0f, 1.0f, 1.0f, 0.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({ + .Type = EMaterialNodeType::Checkerboard, + .OutputType = EMaterialGraphValueType::Float3, + .ConstantValue = { 8.0f, 0.0f, 0.0f, 0.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/Material/DefaultMaterials.h b/Elixir/Source/Engine/Material/DefaultMaterials.h new file mode 100644 index 00000000..1788b28d --- /dev/null +++ b/Elixir/Source/Engine/Material/DefaultMaterials.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace Elixir +{ + inline constexpr size_t DEFAULT_MATERIAL_COUNT = (size_t)EMaterialUsage::Count; + + using DefaultMaterialArray = std::array, DEFAULT_MATERIAL_COUNT>; + + DefaultMaterialArray CreateDefaultMaterials(); +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Material.cpp b/Elixir/Source/Engine/Material/Material.cpp index f91ff2d6..12b69d10 100644 --- a/Elixir/Source/Engine/Material/Material.cpp +++ b/Elixir/Source/Engine/Material/Material.cpp @@ -1,8 +1,15 @@ #include "epch.h" #include "Material.h" +#include + namespace Elixir { + Ref Material::CreateInstance() + { + return CreateRef(shared_from_this()); + } + void Material::SetGraph(MaterialGraph graph) { m_Graph = std::move(graph); diff --git a/Elixir/Source/Engine/Material/Material.h b/Elixir/Source/Engine/Material/Material.h index baead1e7..e3e982a7 100644 --- a/Elixir/Source/Engine/Material/Material.h +++ b/Elixir/Source/Engine/Material/Material.h @@ -5,13 +5,16 @@ namespace Elixir { + class MaterialInstance; + // A renderer-specific shader permutation supported by a Surface material. // It does not change the material domain or graph outputs. enum class EMaterialUsage : uint8_t { ParticleSprite = 0, ParticleRibbon, - ParticleMesh + ParticleMesh, + Count }; enum class EMaterialParameterKind : uint8_t @@ -68,11 +71,13 @@ namespace Elixir // A material template: a named set of parameters with default values (the schema // shared by all of its instances). The shading itself is provided by the renderer's shader; // a Material describes the parameters that feed it. - class ELIXIR_API Material + class ELIXIR_API Material : public std::enable_shared_from_this { public: explicit Material(std::string name) : m_Name(std::move(name)) {} + Ref CreateInstance(); + void SetGraph(MaterialGraph graph); const MaterialGraph& GetGraph() const { return m_Graph; } @@ -117,4 +122,4 @@ namespace Elixir { return 1u << static_cast(usage); } -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/Material/MaterialGraph.cpp b/Elixir/Source/Engine/Material/MaterialGraph.cpp index 327dd95c..fee4e66a 100644 --- a/Elixir/Source/Engine/Material/MaterialGraph.cpp +++ b/Elixir/Source/Engine/Material/MaterialGraph.cpp @@ -313,6 +313,31 @@ namespace Elixir type = EMaterialGraphValueType::Float2; break; } + case EMaterialNodeType::Checkerboard: + { + const std::string uv = node.Inputs.empty() || node.Inputs[0] < 0 + ? "input.TexCoord" + : Widen(A(0), AT(0), EMaterialGraphValueType::Float2); + const std::string scale = Num(std::max(node.ConstantValue.x, 1.0f)); + expr = "(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))"; + type = EMaterialGraphValueType::Float3; + break; + } + case EMaterialNodeType::RadialGradientExponential: + { + const std::string uv = node.Inputs.empty() || node.Inputs[0] < 0 + ? "input.TexCoord" + : Widen(A(0), AT(0), EMaterialGraphValueType::Float2); + + const std::string center = "float2(" + Num(node.RadialGradientCenter.x) + ", " + Num(node.RadialGradientCenter.y) + ")"; + const std::string radius = Num(std::max(node.RadialGradientRadius, 0.0001f)); + const std::string exponent = Num(std::max(node.RadialGradientExponent, 0.0001f)); + + expr = "pow(saturate(1.0 - length((" + uv + " - " + center + ") / " + radius + ")), " + exponent + ")"; + type = EMaterialGraphValueType::Float; + + break; + } case EMaterialNodeType::Multiply: binOp("*"); break; diff --git a/Elixir/Source/Engine/Material/MaterialGraph.h b/Elixir/Source/Engine/Material/MaterialGraph.h index 0d7482fe..8ee81f13 100644 --- a/Elixir/Source/Engine/Material/MaterialGraph.h +++ b/Elixir/Source/Engine/Material/MaterialGraph.h @@ -11,24 +11,26 @@ namespace Elixir // The kind of computation a node performs. The codegen switches on this. enum class EMaterialNodeType : uint8_t { - Constant, // a literal value - Parameter, // a named material-instance parameters (mat.) - TexCoord, // input.TexCoord - TextureSample, // sample a bound texture at a UV (input 0) - ComponentMask, // Select one component from a vector input. - Time, // seconds since start (cbFrame.start) - Sine, // sin(a) - Panner, // uv + Time * speed (speed from ConstantValue.xy) - Multiply, // a * b - Add, // a + b - Subtract, // a - b - Divide, // a / b - Power, // pow(a, b) - Dot, // dot(a, b) -> scalar - Lerp, // lerp(a, b, t) - OneMinus, // 1 - a - Saturate, // saturate(a - Fresnel, // schlick fresnel from N,V + Constant, // a literal value + Parameter, // a named material-instance parameters (mat.) + TexCoord, // input.TexCoord + TextureSample, // sample a bound texture at a UV (input 0) + ComponentMask, // Select one component from a vector input. + Time, // seconds since start (cbFrame.start) + Sine, // sin(a) + Panner, // uv + Time * speed (speed from ConstantValue.xy) + Checkerboard, // procedural two-color checkerboard from UV + RadialGradientExponential, // pow(saturate(1 - distance / radius), exponent) + Multiply, // a * b + Add, // a + b + Subtract, // a - b + Divide, // a / b + Power, // pow(a, b) + Dot, // dot(a, b) -> scalar + Lerp, // lerp(a, b, t) + OneMinus, // 1 - a + Saturate, // saturate(a + Fresnel, // schlick fresnel from N,V }; // The surface output a channel drives. @@ -61,6 +63,11 @@ namespace Elixir std::string ParameterName; // Parameter -> mat. std::string TextureParameterName; // TextureSample -> material texture parameter uint32_t ComponentIndex = 0; // ComponentMask: x, y, z or w. + + // RadialGradientExponential + glm::vec2 RadialGradientCenter{ 0.5f }; + float RadialGradientRadius = 0.5f; + float RadialGradientExponent = 1.0f; }; // A node graph describing a material's surface. Compiles to an HLSL body that diff --git a/Elixir/Source/Engine/Material/MaterialLibrary.cpp b/Elixir/Source/Engine/Material/MaterialLibrary.cpp new file mode 100644 index 00000000..05e97dda --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialLibrary.cpp @@ -0,0 +1,68 @@ +#include "epch.h" +#include "MaterialLibrary.h" + +#include + +namespace Elixir +{ + MaterialLibrary::MaterialLibrary(const ShaderLoader* shaderLoader) + : m_ShaderLoader(shaderLoader), + 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 MaterialLibrary::Register(const Ref& material) + { + if (!material || material->GetName().empty()) return false; + return m_Materials.emplace(material->GetName(), material).second; + } + + Ref MaterialLibrary::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& MaterialLibrary::GetDefault(const EMaterialUsage usage) const + { + return m_Defaults[GetDefaultSlot(usage)]; + } + + Ref MaterialLibrary::GetCompiledMaterial( + const Ref& material + ) + { + if (!material) return nullptr; + + auto& entry = m_CompiledMaterials[material.get()]; + if (entry.Material && entry.Revision == material->GetRevision()) + return entry.Material; + + const auto result = MaterialCompiler::Compile(m_ShaderLoader, *material); + if (!result) + { + EE_CORE_ERROR( + "Material '{}' compilation failed: {}", + material->GetName(), + result.Diagnostics + ) + return nullptr; + } + + entry.Revision = material->GetRevision(); + entry.Material = result.Material; + return entry.Material; + } + + size_t MaterialLibrary::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/Material/MaterialLibrary.h b/Elixir/Source/Engine/Material/MaterialLibrary.h new file mode 100644 index 00000000..80db1535 --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialLibrary.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +namespace Elixir +{ + class ShaderLoader; + struct SCompiledMaterial; + + // Application-owned registry and compiled-material cache. + class ELIXIR_API MaterialLibrary final + { + public: + explicit MaterialLibrary(const ShaderLoader* shaderLoader); + + bool Register(const Ref& material); + Ref Find(std::string_view name) const; + const Ref& GetDefault(EMaterialUsage usage) const; + + Ref GetCompiledMaterial(const Ref& material); + + private: + struct SCompiledEntry + { + uint32_t Revision = 0; + Ref Material; + }; + + static size_t GetDefaultSlot(EMaterialUsage usage); + + const ShaderLoader* m_ShaderLoader = nullptr; + DefaultMaterialArray m_Defaults; + std::unordered_map> m_Materials; + std::unordered_map m_CompiledMaterials; + }; +} \ No newline at end of file diff --git a/Elixir/Tests/Engine/Material/MaterialGraphTest.cpp b/Elixir/Tests/Engine/Material/MaterialGraphTest.cpp index 9112c354..392fd11a 100644 --- a/Elixir/Tests/Engine/Material/MaterialGraphTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialGraphTest.cpp @@ -93,4 +93,26 @@ TEST(MaterialGraphTest, RoutesTextureAlphaToOpacity) 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({ + .Type = EMaterialNodeType::RadialGradientExponential, + .OutputType = EMaterialGraphValueType::Float, + .RadialGradientCenter = { 0.25f, 0.75f }, + .RadialGradientRadius = 0.4f, + .RadialGradientExponent = 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); } \ No newline at end of file diff --git a/Elixir/Tests/Engine/Material/MaterialLibraryTest.cpp b/Elixir/Tests/Engine/Material/MaterialLibraryTest.cpp new file mode 100644 index 00000000..de402aee --- /dev/null +++ b/Elixir/Tests/Engine/Material/MaterialLibraryTest.cpp @@ -0,0 +1,31 @@ +#include + +#include + +using namespace Elixir; + +TEST(MaterialLibraryTest, RegistersAndFindsDefaultMaterials) +{ + const MaterialLibrary library{ nullptr }; + + for (const auto usage : { + EMaterialUsage::ParticleSprite, + EMaterialUsage::ParticleRibbon, + EMaterialUsage::ParticleMesh + }) + { + const auto& material = library.GetDefault(usage); + ASSERT_TRUE(material); + EXPECT_EQ(library.Find(material->GetName()), material); + EXPECT_TRUE(material->SupportsUsage(usage)); + EXPECT_TRUE(material->ValidateGraph()); + EXPECT_TRUE(material->GetParameters().empty()); + } +} + +TEST(MaterialLibraryTest, RejectsDuplicateMaterialNames) +{ + MaterialLibrary library{ nullptr }; + EXPECT_TRUE(library.Register(CreateRef("Game.Custom"))); + EXPECT_FALSE(library.Register(CreateRef("Game.Custom"))); +} \ No newline at end of file diff --git a/Elixir/Tests/Engine/Material/MaterialTest.cpp b/Elixir/Tests/Engine/Material/MaterialTest.cpp index 20847e06..2df97120 100644 --- a/Elixir/Tests/Engine/Material/MaterialTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialTest.cpp @@ -61,4 +61,16 @@ TEST(MaterialTest, ValidatesTextureSampleAgainstTextureParameter) .DefaultValue = SMaterialParam::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); } \ No newline at end of file From d75bf41ba29bb6b6d880d89ef5258e35ecaf8d2e Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Tue, 4 Aug 2026 12:06:34 -0300 Subject: [PATCH 31/89] fix(material): handle sentinel material usage Return the unsupported fallback for the Count sentinel in material usage switches. --- Elixir/Source/Engine/Material/MaterialCompiler.h | 6 +++--- Elixir/Source/Engine/Material/ParticleMaterialDefaults.cpp | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Elixir/Source/Engine/Material/MaterialCompiler.h b/Elixir/Source/Engine/Material/MaterialCompiler.h index 03d537b1..e33d55a9 100644 --- a/Elixir/Source/Engine/Material/MaterialCompiler.h +++ b/Elixir/Source/Engine/Material/MaterialCompiler.h @@ -39,10 +39,10 @@ namespace Elixir return ParticleRibbonShader; case EMaterialUsage::ParticleMesh: return ParticleMeshShader; + default: + static const Ref unsupportedUsageShader; + return unsupportedUsageShader; } - - static const Ref unsupportedUsageShader; - return unsupportedUsageShader; } }; diff --git a/Elixir/Source/Engine/Material/ParticleMaterialDefaults.cpp b/Elixir/Source/Engine/Material/ParticleMaterialDefaults.cpp index 9e7f3dd3..80900cb8 100644 --- a/Elixir/Source/Engine/Material/ParticleMaterialDefaults.cpp +++ b/Elixir/Source/Engine/Material/ParticleMaterialDefaults.cpp @@ -15,9 +15,9 @@ namespace Elixir return "Engine.DefaultParticleRibbon"; case EMaterialUsage::ParticleMesh: return "Engine.DefaultParticleMesh"; + default: + return "Engine.DefaultParticle"; } - - return "Engine.DefaultParticle"; } } From e604b7838ae81a108896960fba3aaf0e666c3f42 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Tue, 4 Aug 2026 17:10:43 -0300 Subject: [PATCH 32/89] refactor(aether): centralize particle material resolution Move particle-material authoring into Aether and publish immutable proxies through MaterialSystem. Remove the legacy particle material library and direct compilation from Dissolve. --- Dissolve/Source/Dissolve.cpp | 83 +++++++---------- Elixir/Source/Engine/Aether/Effect.cpp | 72 ++++----------- Elixir/Source/Engine/Aether/Effect.h | 10 +-- Elixir/Source/Engine/Aether/Emitter.cpp | 41 ++++----- Elixir/Source/Engine/Aether/Emitter.h | 30 +++++-- .../Aether/ParticleMaterialDefinition.h | 15 ++++ .../Engine/Aether/ParticleMaterialFactory.cpp | 90 +++++++++++++++++++ .../Engine/Aether/ParticleMaterialFactory.h | 16 ++++ Elixir/Source/Engine/Aether/System.cpp | 40 ++++++++- Elixir/Source/Engine/Aether/System.h | 9 +- Elixir/Source/Engine/Core/Application.cpp | 1 + .../Engine/Material/MaterialInstance.cpp | 9 -- .../Source/Engine/Material/MaterialInstance.h | 7 -- .../Source/Engine/Material/MaterialResolver.h | 18 ++++ .../Source/Engine/Material/MaterialSystem.cpp | 16 +++- .../Source/Engine/Material/MaterialSystem.h | 13 ++- .../Material/ParticleMaterialDefaults.cpp | 89 ------------------ .../Material/ParticleMaterialDefaults.h | 13 --- .../Material/ParticleMaterialDescription.h | 18 ---- .../Material/ParticleMaterialLibrary.cpp | 70 --------------- .../Engine/Material/ParticleMaterialLibrary.h | 38 -------- Elixir/Tests/Engine/Aether/SystemTest.cpp | 78 ++++++++-------- .../Material/MaterialFrameTableTest.cpp | 6 +- .../Material/MaterialRenderProxyTest.cpp | 4 +- .../Material/ParticleMaterialDefaultsTest.cpp | 43 --------- 25 files changed, 348 insertions(+), 481 deletions(-) create mode 100644 Elixir/Source/Engine/Aether/ParticleMaterialDefinition.h create mode 100644 Elixir/Source/Engine/Aether/ParticleMaterialFactory.cpp create mode 100644 Elixir/Source/Engine/Aether/ParticleMaterialFactory.h create mode 100644 Elixir/Source/Engine/Material/MaterialResolver.h delete mode 100644 Elixir/Source/Engine/Material/ParticleMaterialDefaults.cpp delete mode 100644 Elixir/Source/Engine/Material/ParticleMaterialDefaults.h delete mode 100644 Elixir/Source/Engine/Material/ParticleMaterialDescription.h delete mode 100644 Elixir/Source/Engine/Material/ParticleMaterialLibrary.cpp delete mode 100644 Elixir/Source/Engine/Material/ParticleMaterialLibrary.h delete mode 100644 Elixir/Tests/Engine/Material/ParticleMaterialDefaultsTest.cpp diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index 927ebe92..2d9470ee 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -7,8 +7,8 @@ #include #include -#include -#include +#include +#include Ref pipeline; Scope m_ParticlesRenderer; @@ -17,8 +17,6 @@ std::array, 2> m_ParticleSystems; std::array, 2> m_ParticleSystemInstances; Ref graphMaterial; -Ref compiledGraphMaterial; -Scope particleMaterialLibrary; Dissolve::Dissolve() { @@ -71,16 +69,17 @@ Dissolve::Dissolve() GetMaterialSystem() ); - particleMaterialLibrary = CreateScope(m_ShaderLoader.get()); + m_ParticleSystems[0] = Aether::LoadEffectFile("./Assets/VFX/FireAndFireworks.json"); + EE_CORE_ASSERT( + m_ParticleSystems[0]->ResolveMaterialInstances(GetMaterialLibrary()), + "Could not resolve FireAndFireworks materials." + ) - m_ParticleSystems[0] = Aether::LoadEffectFile( - "./Assets/VFX/FireAndFireworks.json", - *particleMaterialLibrary - ); - m_ParticleSystems[1] = Aether::LoadEffectFile( - "./Assets/VFX/RibbonVortex.json", - *particleMaterialLibrary - ); + m_ParticleSystems[1] = Aether::LoadEffectFile("./Assets/VFX/RibbonVortex.json"); + EE_CORE_ASSERT( + m_ParticleSystems[1]->ResolveMaterialInstances(GetMaterialLibrary()), + "Could not resolve RibbonVortex materials." + ) { MaterialGraph graph; @@ -123,37 +122,23 @@ Dissolve::Dissolve() graph.SetChannel(EMaterialChannel::BaseColor, graph.AddNode(baseColor)); graphMaterial->SetGraph(std::move(graph)); + EE_CORE_ASSERT(GetMaterialLibrary().Register(graphMaterial), "GraphMaterial must be unique.") - const auto result = MaterialCompiler::Compile(m_ShaderLoader.get(), *graphMaterial); + 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 (result) + if (auto* emitter = m_ParticleSystems[0]->FindEmitter("FlameCore")) { - compiledGraphMaterial = result.Material; - - auto instance = CreateRef(graphMaterial); - EE_CORE_ASSERT( - instance->SetVector("Tint", { 1.0f, 0.35f, 0.1f, 1.0f }), - "Dissolve graph material tint override must match its schema." - ) - - const auto proxy = instance->CreateRenderProxy(compiledGraphMaterial); - EE_CORE_ASSERT( - proxy, - "Dissolve graph material render proxy must match the compiled schema." - ) - - if (auto* emitter = m_ParticleSystems[0]->FindEmitter("FlameCore")) - { - emitter->SetMaterial(proxy); - EE_CORE_INFO("Published graph material to the FlameCore particle emitter.") - } - else - { - EE_CORE_ERROR("Dissolve particle emitter 'FlameCore' was not found.") - } + emitter->SetMaterial(instance); + EE_CORE_INFO("Published graph material to the FlameCore particle emitter.") } else - EE_CORE_ERROR("Node-graph material compilation failed: {}", result.Diagnostics) + { + EE_CORE_ERROR("Dissolve particle emitter 'FlameCore' was not found.") + } } { @@ -221,41 +206,33 @@ Dissolve::Dissolve() //graph1.SetChannel(EMaterialChannel::Emissive, graph1.AddNode(glow)); ribbonMaterial->SetGraph(std::move(graph1)); + EE_CORE_ASSERT(GetMaterialLibrary().Register(ribbonMaterial), "RibbonEnergy must be unique.") - const auto compileResult = MaterialCompiler::Compile( - m_ShaderLoader.get(), - *ribbonMaterial - ); - EE_CORE_ASSERT(compileResult, "Ribbon material compilation failed.") - - const auto instance = CreateRef(ribbonMaterial); + 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." ) - const auto proxy = instance->CreateRenderProxy(compileResult.Material); - EE_CORE_ASSERT(proxy, "Ribbon material proxy creation failed.") - if (auto* emitter = m_ParticleSystems[1]->FindEmitter("PathRibbon")) { - emitter->SetMaterial(proxy); + emitter->SetMaterial(instance); EE_CORE_INFO("Published graph material to the PathRibbon particle emitter.") } if (auto* emitter = m_ParticleSystems[1]->FindEmitter("CrystalShards")) { - emitter->SetMaterial(proxy); + emitter->SetMaterial(instance); EE_CORE_INFO("Published graph material to the CrystalShards particle emitter.") } } m_ParticleSystemInstances[0] = CreateScope( - CreateRef(m_ParticleSystems[0]->Compile()) + CreateRef(m_ParticleSystems[0]->Compile(GetMaterialSystem())) ); m_ParticleSystemInstances[1] = CreateScope( - CreateRef(m_ParticleSystems[1]->Compile()) + CreateRef(m_ParticleSystems[1]->Compile(GetMaterialSystem())) ); m_GraphicsContext->SetClearColor({ 0.015f, 0.025f, 0.06f, 1.0f }); diff --git a/Elixir/Source/Engine/Aether/Effect.cpp b/Elixir/Source/Engine/Aether/Effect.cpp index 35556302..8d7fe13c 100644 --- a/Elixir/Source/Engine/Aether/Effect.cpp +++ b/Elixir/Source/Engine/Aether/Effect.cpp @@ -2,12 +2,10 @@ #include "Effect.h" #include - -#include -#include - -#include #include +#include + +#include namespace Elixir::Aether { @@ -60,11 +58,8 @@ namespace Elixir::Aether class EffectParser { public: - EffectParser( - std::filesystem::path filepath, - ParticleMaterialLibrary& materials - ) : m_Filepath(std::move(filepath)), - m_Materials(materials) {} + explicit EffectParser(std::filesystem::path filepath) + : m_Filepath(std::move(filepath)) {} Ref Parse(od::object& root); @@ -877,29 +872,22 @@ namespace Elixir::Aether return emitter.GetParameters().GetFloat4(field.Param, systemValue); } - SParticleMaterialDescription ParseMaterial( + std::optional ParseMaterial( od::object& json, - const EParticleRenderMode renderMode, const Emitter& emitter, const System& system ) { - SParticleMaterialDescription desc{ - .Usage = GetParticleMaterialUsage(renderMode), - }; - auto field = json["material"]; - if (field.error()) - { - Fail("there is no material."); - return desc; - } + if (field.error()) return std::nullopt; + + SParticleMaterialDefinition definition{}; od::object material; if (field.get_object().get(material)) { Fail("'material' must be an object."); - return desc; + return std::nullopt; } const auto color = ResolveMaterialColor( @@ -914,22 +902,12 @@ namespace Elixir::Aether system ); - desc.BaseColor = glm::vec3(color); - desc.Opacity = color.w; - desc.Emissive = glm::vec3(emissive); + definition.BaseColor = glm::vec3(color); + definition.Opacity = color.w; + definition.Emissive = glm::vec3(emissive); + definition.BaseColorTexturePath = ParseString(material, "texture"); - if (renderMode == EParticleRenderMode::Sprite) - { - const auto texturePath = ParseString(material, "texture", ""); - if (!texturePath.empty()) - { - desc.Texture = TextureLoader::Load(texturePath); - if (!desc.Texture) - Fail("Could not load material texture '{}'.", texturePath); - } - } - - return desc; + return definition; } void ParseEmitter(const Ref& system, od::object& json) @@ -983,16 +961,8 @@ namespace Elixir::Aether if (m_Failed) return; - const auto material = ParseMaterial( - json, - renderMode, - emitter, - *system - ); - - if (m_Failed) return; - - emitter.SetMaterial(m_Materials.Create(material)); + if (const auto material = ParseMaterial(json, emitter, *system)) + emitter.SetMaterialDefinition(std::move(*material)); if (!spawnRate.Param.empty()) emitter.SetSpawnRateParamName(spawnRate.Param); @@ -1005,7 +975,6 @@ namespace Elixir::Aether } std::filesystem::path m_Filepath; - ParticleMaterialLibrary& m_Materials; bool m_Failed = false; }; @@ -1048,10 +1017,7 @@ namespace Elixir::Aether } } - Ref LoadEffectFile( - const std::filesystem::path& filepath, - ParticleMaterialLibrary& materials - ) + Ref LoadEffectFile(const std::filesystem::path& filepath) { od::parser parser; @@ -1078,7 +1044,7 @@ namespace Elixir::Aether return nullptr; } - EffectParser effectParser{ filepath, materials }; + EffectParser effectParser{ filepath }; return effectParser.Parse(root); } } diff --git a/Elixir/Source/Engine/Aether/Effect.h b/Elixir/Source/Engine/Aether/Effect.h index 8c9e0ebb..ff74c561 100644 --- a/Elixir/Source/Engine/Aether/Effect.h +++ b/Elixir/Source/Engine/Aether/Effect.h @@ -2,15 +2,7 @@ #include -namespace Elixir -{ - class ParticleMaterialLibrary; -} - namespace Elixir::Aether { - ELIXIR_API Ref LoadEffectFile( - const std::filesystem::path& filepath, - ParticleMaterialLibrary& materials - ); + ELIXIR_API Ref LoadEffectFile(const std::filesystem::path& filepath); } \ No newline at end of file diff --git a/Elixir/Source/Engine/Aether/Emitter.cpp b/Elixir/Source/Engine/Aether/Emitter.cpp index c901a399..80664af4 100644 --- a/Elixir/Source/Engine/Aether/Emitter.cpp +++ b/Elixir/Source/Engine/Aether/Emitter.cpp @@ -25,10 +25,22 @@ namespace Elixir::Aether m_TriggerDelaySeconds = delaySeconds; } + void Emitter::SetMaterial(const Ref& material) + { + if (!material) + { + EE_CORE_ERROR("Trying to set a null material to emitter.") + return; + } + + SetMaterial(material->CreateInstance()); + } + SCompiledEmitter Emitter::Compile( const ParameterStore& paramStore, const std::vector& params, - std::vector& ops + std::vector& ops, + MaterialResolver& materialResolver ) const { SCompiledEmitter emitter; @@ -51,33 +63,16 @@ namespace Elixir::Aether if (m_Material) { - EMaterialUsage usage; - - switch (m_RenderMode) - { - case EParticleRenderMode::Sprite: - usage = EMaterialUsage::ParticleSprite; - break; - case EParticleRenderMode::Ribbon: - usage = EMaterialUsage::ParticleRibbon; - break; - case EParticleRenderMode::Mesh: - usage = EMaterialUsage::ParticleMesh; - break; - } - - const auto& material = m_Material->GetCompiledMaterial(); - if (!material || !material->SupportsUsage(usage)) - { + const auto proxy = materialResolver.Resolve(m_Material); + if (!proxy || !proxy->GetCompiledMaterial()->SupportsUsage( + GetParticleMaterialUsage(m_RenderMode) + )) EE_CORE_ERROR( "Aether emitter '{}' material does not support its render mode.", m_Name ) - } else - { - emitter.Material = m_Material; - } + emitter.Material = proxy; } for (const auto& module : m_SpawnModules) diff --git a/Elixir/Source/Engine/Aether/Emitter.h b/Elixir/Source/Engine/Aether/Emitter.h index 4937e091..c2ddf1f1 100644 --- a/Elixir/Source/Engine/Aether/Emitter.h +++ b/Elixir/Source/Engine/Aether/Emitter.h @@ -5,7 +5,10 @@ #include #include #include -#include +#include +#include +#include +#include namespace Elixir::Aether { @@ -18,8 +21,7 @@ namespace Elixir::Aether EParticleRenderMode RenderMode = EParticleRenderMode::Sprite; EParticleSimulationSpace SimulationSpace = EParticleSimulationSpace::World; - // Immutable material state captured while the system is compiled. - // It is safe to read for the full render submission. + // Immutable GPU material state published by System::Compile. Ref Material; float SpawnRatePerSecond = 1.0f; @@ -94,6 +96,7 @@ namespace Elixir::Aether return ref; } + EParticleRenderMode GetRenderMode() const { return m_RenderMode; } void SetRenderMode(const EParticleRenderMode mode) { m_RenderMode = mode; } EParticleSimulationSpace GetSimulationSpace() const { return m_SimulationSpace; } @@ -106,14 +109,26 @@ namespace Elixir::Aether SCompiledEmitter Compile( const ParameterStore& paramStore, const std::vector& params, - std::vector& ops + std::vector& ops, + MaterialResolver& materialResolver ) const; const std::string& GetName() const { return m_Name; } uint32_t GetMaxParticles() const { return m_MaxParticles; } - const Ref& GetMaterial() const { return m_Material; } - void SetMaterial(Ref material) { m_Material = std::move(material); } + const std::optional& GetMaterialDefinition() const + { + return m_MaterialDefinition; + } + + void SetMaterialDefinition(SParticleMaterialDefinition definition) + { + m_MaterialDefinition = std::move(definition); + } + + const Ref& GetMaterial() const { return m_Material; } + void SetMaterial(const Ref& material); + void SetMaterial(Ref material) { m_Material = std::move(material); } uint32_t GetBurstCount() const { return m_BurstCount; } float GetBurstIntervalSeconds() const { return m_BurstIntervalSeconds; } @@ -136,7 +151,8 @@ namespace Elixir::Aether std::string m_Name; EParticleRenderMode m_RenderMode = EParticleRenderMode::Sprite; EParticleSimulationSpace m_SimulationSpace = EParticleSimulationSpace::World; - Ref m_Material; + std::optional m_MaterialDefinition; + Ref m_Material; uint32_t m_MaxParticles; std::vector> m_SpawnModules; diff --git a/Elixir/Source/Engine/Aether/ParticleMaterialDefinition.h b/Elixir/Source/Engine/Aether/ParticleMaterialDefinition.h new file mode 100644 index 00000000..f179dd77 --- /dev/null +++ b/Elixir/Source/Engine/Aether/ParticleMaterialDefinition.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +namespace Elixir::Aether +{ + // Serialized authoring data local to the Aether effect format. + struct SParticleMaterialDefinition + { + glm::vec3 BaseColor{ 1.0f }; + float Opacity = 1.0f; + glm::vec3 Emissive{ 0.0f }; + std::string BaseColorTexturePath; + }; +} diff --git a/Elixir/Source/Engine/Aether/ParticleMaterialFactory.cpp b/Elixir/Source/Engine/Aether/ParticleMaterialFactory.cpp new file mode 100644 index 00000000..43589cd0 --- /dev/null +++ b/Elixir/Source/Engine/Aether/ParticleMaterialFactory.cpp @@ -0,0 +1,90 @@ +#include "epch.h" +#include "ParticleMaterialFactory.h" + +#include + +namespace Elixir::Aether +{ + EMaterialUsage GetParticleMaterialUsage(const EParticleRenderMode mode) + { + switch (mode) + { + case EParticleRenderMode::Sprite: return EMaterialUsage::ParticleSprite; + case EParticleRenderMode::Ribbon: return EMaterialUsage::ParticleRibbon; + case EParticleRenderMode::Mesh: return EMaterialUsage::ParticleMesh; + } + + return EMaterialUsage::ParticleSprite; + } + + Ref CreateParticleMaterial( + std::string name, + const EParticleRenderMode renderMode, + const SParticleMaterialDefinition& definition + ) + { + const auto material = CreateRef(std::move(name)); + + const auto result = material->SetUsage(GetParticleMaterialUsage(renderMode), true); + EE_CORE_ASSERT(result, "Particle material usage must be enabled.") + + MaterialGraph graph; + + const auto baseColor = graph.AddNode({ + .Type = EMaterialNodeType::Constant, + .OutputType = EMaterialGraphValueType::Float3, + .ConstantValue = { definition.BaseColor, 0.0f }, + }); + graph.SetChannel(EMaterialChannel::BaseColor, baseColor); + + const auto opacity = graph.AddNode({ + .Type = EMaterialNodeType::Constant, + .OutputType = EMaterialGraphValueType::Float, + .ConstantValue = { definition.Opacity, 0.0f, 0.0f, 0.0f }, + }); + graph.SetChannel(EMaterialChannel::Opacity, opacity); + + const auto emissive = graph.AddNode({ + .Type = EMaterialNodeType::Constant, + .OutputType = EMaterialGraphValueType::Float3, + .ConstantValue = { definition.Emissive, 0.0f }, + }); + graph.SetChannel(EMaterialChannel::Emissive, emissive); + + if (renderMode == EParticleRenderMode::Sprite && !definition.BaseColorTexturePath.empty()) + { + constexpr auto texParam = "BaseColorTexture"; + const auto tex = TextureLoader::Load(definition.BaseColorTexturePath); + material->DefineParameter(texParam, { + .Kind = EMaterialParameterKind::Texture, + .DefaultValue = SMaterialParam::MakeTexture(tex), + }); + + const auto texture = graph.AddNode({ + .Type = EMaterialNodeType::TextureSample, + .TextureParameterName = texParam, + }); + + const auto alpha = graph.AddNode({ + .Type = EMaterialNodeType::ComponentMask, + .Inputs = { int32_t(texture) }, + .ComponentIndex = 3 + }); + + const auto baseColorMul = graph.AddNode({ + .Type = EMaterialNodeType::Multiply, + .Inputs = { int32_t(baseColor), int32_t(texture) }, + }); + graph.SetChannel(EMaterialChannel::BaseColor, baseColorMul); + + const auto opacityMul = graph.AddNode({ + .Type = EMaterialNodeType::Multiply, + .Inputs = { int32_t(opacity), int32_t(alpha) }, + }); + graph.SetChannel(EMaterialChannel::Opacity, opacityMul); + } + + material->SetGraph(std::move(graph)); + return material; + } +} diff --git a/Elixir/Source/Engine/Aether/ParticleMaterialFactory.h b/Elixir/Source/Engine/Aether/ParticleMaterialFactory.h new file mode 100644 index 00000000..45cf8c89 --- /dev/null +++ b/Elixir/Source/Engine/Aether/ParticleMaterialFactory.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include + +namespace Elixir::Aether +{ + EMaterialUsage GetParticleMaterialUsage(const EParticleRenderMode mode); + + Ref CreateParticleMaterial( + std::string name, + EParticleRenderMode renderMode, + const SParticleMaterialDefinition& definition + ); +} diff --git a/Elixir/Source/Engine/Aether/System.cpp b/Elixir/Source/Engine/Aether/System.cpp index 37bd68d7..ceb4ed10 100644 --- a/Elixir/Source/Engine/Aether/System.cpp +++ b/Elixir/Source/Engine/Aether/System.cpp @@ -1,6 +1,9 @@ #include "epch.h" #include "System.h" +#include +#include + namespace Elixir::Aether { System::System(const std::string& name) : m_Name(name) {} @@ -26,7 +29,35 @@ namespace Elixir::Aether return nullptr; } - SCompiledSystem System::Compile() const + bool System::ResolveMaterialInstances(MaterialLibrary& materials) + { + for (const auto& emitter : m_Emitters) + { + Ref material; + if (const auto& definition = emitter->GetMaterialDefinition()) + { + const auto name = "Aether." + m_UUID.ToString() + "." + emitter->GetName(); + material = CreateParticleMaterial(name, emitter->GetRenderMode(), *definition); + if (!materials.Register(material)) + { + EE_CORE_ERROR("Aether material '{}' is already registered.", name) + return false; + } + } + else + { + const auto usage = GetParticleMaterialUsage(emitter->GetRenderMode()); + material = materials.GetDefault(usage); + } + + emitter->SetMaterial(material); + if (!emitter->GetMaterial()) return false; + } + + return true; + } + + SCompiledSystem System::Compile(MaterialResolver& materialResolver) const { SCompiledSystem system; system.SourceId = m_UUID; @@ -91,7 +122,12 @@ 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, + materialResolver + ); compiled.LocalParticleOffset = localParticleOffset; localParticleOffset += compiled.MaxParticles; diff --git a/Elixir/Source/Engine/Aether/System.h b/Elixir/Source/Engine/Aether/System.h index 84f91d0c..46c024f8 100644 --- a/Elixir/Source/Engine/Aether/System.h +++ b/Elixir/Source/Engine/Aether/System.h @@ -5,6 +5,12 @@ #include #include +namespace Elixir +{ + class MaterialLibrary; + class MaterialResolver; +} + namespace Elixir::Aether { struct SCompiledTriggerTarget @@ -56,7 +62,8 @@ namespace Elixir::Aether Emitter* FindEmitter(std::string_view name) const; - SCompiledSystem Compile() const; + bool ResolveMaterialInstances(MaterialLibrary& materials); + SCompiledSystem Compile(MaterialResolver& materialResolver) const; ParameterStore& GetParameters() { return m_Parameters; } const ParameterStore& GetParameters() const { return m_Parameters; } diff --git a/Elixir/Source/Engine/Core/Application.cpp b/Elixir/Source/Engine/Core/Application.cpp index 6e4f0f1f..97d9437a 100644 --- a/Elixir/Source/Engine/Core/Application.cpp +++ b/Elixir/Source/Engine/Core/Application.cpp @@ -42,6 +42,7 @@ namespace Elixir m_MaterialLibrary = CreateScope(m_ShaderLoader.get()); m_MaterialSystem = CreateScope( m_GraphicsContext.get(), + *m_MaterialLibrary, SMaterialSystemConfig{ .InitialFrameCapacity = 256 } ); diff --git a/Elixir/Source/Engine/Material/MaterialInstance.cpp b/Elixir/Source/Engine/Material/MaterialInstance.cpp index 5fac58a9..6ea22180 100644 --- a/Elixir/Source/Engine/Material/MaterialInstance.cpp +++ b/Elixir/Source/Engine/Material/MaterialInstance.cpp @@ -1,8 +1,6 @@ #include "epch.h" #include "MaterialInstance.h" -#include "MaterialRenderProxy.h" - namespace Elixir { bool MaterialInstance::SetScalar(const std::string& name, const float value) @@ -38,13 +36,6 @@ namespace Elixir return param ? param->Texture : nullptr; } - Ref MaterialInstance::CreateRenderProxy( - Ref material - ) const - { - return MaterialRenderProxy::Create(std::move(material), *this); - } - const SMaterialParam* MaterialInstance::GetResolvedParameter(const std::string& name) const { return Resolve(name); diff --git a/Elixir/Source/Engine/Material/MaterialInstance.h b/Elixir/Source/Engine/Material/MaterialInstance.h index dfbbaa2a..a6a58ab8 100644 --- a/Elixir/Source/Engine/Material/MaterialInstance.h +++ b/Elixir/Source/Engine/Material/MaterialInstance.h @@ -4,9 +4,6 @@ namespace Elixir { - struct SCompiledMaterial; - class MaterialRenderProxy; - class ELIXIR_API MaterialInstance { public: @@ -24,10 +21,6 @@ namespace Elixir const Ref& GetParent() const { return m_Parent; } uint32_t GetRevision() const { return m_Revision; } - Ref CreateRenderProxy( - Ref material - ) const; - const SMaterialParam* GetResolvedParameter(const std::string& name) const; private: diff --git a/Elixir/Source/Engine/Material/MaterialResolver.h b/Elixir/Source/Engine/Material/MaterialResolver.h new file mode 100644 index 00000000..2d0e245e --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialResolver.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +namespace Elixir +{ + // Boundary used by scene compilers to publish immutable GPU material state. + class ELIXIR_API MaterialResolver + { + public: + virtual ~MaterialResolver() = default; + + virtual Ref Resolve( + const Ref& instance + ) = 0; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/MaterialSystem.cpp b/Elixir/Source/Engine/Material/MaterialSystem.cpp index 828093eb..2639d592 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.cpp +++ b/Elixir/Source/Engine/Material/MaterialSystem.cpp @@ -1,6 +1,8 @@ #include "epch.h" #include "MaterialSystem.h" +#include "MaterialLibrary.h" + namespace Elixir { namespace @@ -38,13 +40,15 @@ namespace Elixir MaterialSystem::MaterialSystem( const GraphicsContext* context, - SMaterialSystemConfig config + MaterialLibrary& materials, + const SMaterialSystemConfig config ) : m_MaterialCapacity(GetInitialFrameCapacity(config)), m_FrameBuffer(DynamicStorageBuffer::Create( context, sizeof(SMaterialFrameData) * m_MaterialCapacity) ), m_Textures(context), + m_Materials(materials), m_Renderer(CreateScope( context, m_FrameBuffer, @@ -242,4 +246,14 @@ namespace Elixir return result; } + + Ref MaterialSystem::Resolve( + const Ref& instance + ) + { + if (!instance || !instance->GetParent()) return nullptr; + + const auto compiled = m_Materials.GetCompiledMaterial(instance->GetParent()); + return compiled ? MaterialRenderProxy::Create(compiled, *instance) : nullptr; + } } diff --git a/Elixir/Source/Engine/Material/MaterialSystem.h b/Elixir/Source/Engine/Material/MaterialSystem.h index f290cba8..4a1cd780 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.h +++ b/Elixir/Source/Engine/Material/MaterialSystem.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -8,6 +9,8 @@ namespace Elixir { + class MaterialLibrary; + struct SMaterialSystemConfig { uint32_t InitialFrameCapacity = 256; @@ -26,11 +29,12 @@ namespace Elixir uint32_t DrawCount = 0; }; - class ELIXIR_API MaterialSystem final + class ELIXIR_API MaterialSystem final : public MaterialResolver { public: MaterialSystem( const GraphicsContext* context, + MaterialLibrary& materials, SMaterialSystemConfig config ); @@ -54,6 +58,10 @@ namespace Elixir const SMaterialFrameSnapshot& snapshot ) const; + Ref Resolve( + const Ref& instance + ) override; + const Ref& GetFrameBuffer() const { return m_FrameBuffer; } const Ref& GetTextureSet() const { return m_Textures.GetTextureSet(); } const Ref& GetSampler() const { return m_Textures.GetSampler(); } @@ -62,6 +70,7 @@ namespace Elixir uint32_t m_MaterialCapacity = 0; Ref m_FrameBuffer; MaterialTextureRegistry m_Textures; + MaterialLibrary& m_Materials; Scope m_Renderer; }; -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/Material/ParticleMaterialDefaults.cpp b/Elixir/Source/Engine/Material/ParticleMaterialDefaults.cpp deleted file mode 100644 index 80900cb8..00000000 --- a/Elixir/Source/Engine/Material/ParticleMaterialDefaults.cpp +++ /dev/null @@ -1,89 +0,0 @@ -#include "epch.h" -#include "ParticleMaterialDefaults.h" - -namespace Elixir -{ - namespace - { - std::string GetDefaultParticleMaterialName(const EMaterialUsage usage) - { - switch (usage) - { - case EMaterialUsage::ParticleSprite: - return "Engine.DefaultParticleSprite"; - case EMaterialUsage::ParticleRibbon: - return "Engine.DefaultParticleRibbon"; - case EMaterialUsage::ParticleMesh: - return "Engine.DefaultParticleMesh"; - default: - return "Engine.DefaultParticle"; - } - } - } - - Ref CreateParticleMaterial(const SParticleMaterialDescription& desc) - { - const auto name = GetDefaultParticleMaterialName(desc.Usage); - const auto material = CreateRef(name); - - const bool usageWasEnabled = material->SetUsage(desc.Usage, true); - EE_CORE_ASSERT( - usageWasEnabled, - "A default particle material must enable its particle usage." - ) - - MaterialGraph graph; - const auto baseColor = graph.AddNode({ - .Type = EMaterialNodeType::Constant, - .OutputType = EMaterialGraphValueType::Float3, - .ConstantValue = { desc.BaseColor, 0.0f }, - }); - const auto opacity = graph.AddNode({ - .Type = EMaterialNodeType::Constant, - .OutputType = EMaterialGraphValueType::Float, - .ConstantValue = { desc.Opacity, 0.0f, 0.0f, 0.0f }, - }); - const auto emissive = graph.AddNode({ - .Type = EMaterialNodeType::Constant, - .OutputType = EMaterialGraphValueType::Float3, - .ConstantValue = { desc.Emissive, 0.0f }, - }); - - graph.SetChannel(EMaterialChannel::BaseColor, baseColor); - graph.SetChannel(EMaterialChannel::Opacity, opacity); - graph.SetChannel(EMaterialChannel::Emissive, emissive); - - if (desc.Usage == EMaterialUsage::ParticleSprite) - { - material->DefineParameter(std::string(DEFAULT_SPRITE_TEXTURE_PARAMETER), { - .Kind = EMaterialParameterKind::Texture, - .DefaultValue = SMaterialParam::MakeTexture(nullptr), - }); - - const auto texture = graph.AddNode({ - .Type = EMaterialNodeType::TextureSample, - .TextureParameterName = std::string(DEFAULT_SPRITE_TEXTURE_PARAMETER), - }); - const auto textureOpacity = graph.AddNode({ - .Type = EMaterialNodeType::ComponentMask, - .Inputs = { int32_t(texture) }, - .ComponentIndex = 3, - }); - const auto texturedBaseColor = graph.AddNode({ - .Type = EMaterialNodeType::Multiply, - .Inputs = { int32_t(baseColor), int32_t(texture) }, - }); - const auto texturedOpacity = graph.AddNode({ - .Type = EMaterialNodeType::Multiply, - .Inputs = { int32_t(opacity), int32_t(textureOpacity) }, - }); - graph.SetChannel(EMaterialChannel::BaseColor, texturedBaseColor); - graph.SetChannel(EMaterialChannel::Opacity, texturedOpacity); - - } - - material->SetGraph(std::move(graph)); - - return material; - } -} diff --git a/Elixir/Source/Engine/Material/ParticleMaterialDefaults.h b/Elixir/Source/Engine/Material/ParticleMaterialDefaults.h deleted file mode 100644 index 4c240289..00000000 --- a/Elixir/Source/Engine/Material/ParticleMaterialDefaults.h +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once - -#include - -namespace Elixir -{ - // Authoring detail of the engine Sprite graph. It is not a renderer ABI. - inline constexpr std::string_view DEFAULT_SPRITE_TEXTURE_PARAMETER = "SpriteTexture"; - - // Creates the engine-owned source material used when a particle emitter - // has no authored material proxy for a supported particle usage. - Ref CreateParticleMaterial(const SParticleMaterialDescription& desc); -} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/ParticleMaterialDescription.h b/Elixir/Source/Engine/Material/ParticleMaterialDescription.h deleted file mode 100644 index ab38c168..00000000 --- a/Elixir/Source/Engine/Material/ParticleMaterialDescription.h +++ /dev/null @@ -1,18 +0,0 @@ -#pragma once - -#include -#include - -namespace Elixir -{ - // Immutable authoring values used to create one particle material. - // They are resolved before this layer is called and become graph constants. - struct SParticleMaterialDescription - { - EMaterialUsage Usage = EMaterialUsage::ParticleSprite; - glm::vec3 BaseColor{ 1.0f }; - float Opacity = 1.0f; - glm::vec3 Emissive{ 0.0f }; - Ref Texture; - }; -} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/ParticleMaterialLibrary.cpp b/Elixir/Source/Engine/Material/ParticleMaterialLibrary.cpp deleted file mode 100644 index 0800c387..00000000 --- a/Elixir/Source/Engine/Material/ParticleMaterialLibrary.cpp +++ /dev/null @@ -1,70 +0,0 @@ -#include "epch.h" -#include "ParticleMaterialLibrary.h" -#include "ParticleMaterialDefaults.h" - -#include -#include - -namespace Elixir -{ - ParticleMaterialLibrary::ParticleMaterialLibrary(const ShaderLoader* shaderLoader) - : m_ShaderLoader(shaderLoader) {} - - Ref ParticleMaterialLibrary::Create( - const SParticleMaterialDescription& desc - ) - { - const SMaterialKey key{ - .Usage = desc.Usage, - .BaseColor = desc.BaseColor, - .Opacity = desc.Opacity, - .Emissive = desc.Emissive, - .TextureIdentity = desc.Texture.get(), - }; - - const auto found = m_Proxies.find(key); - if (found != m_Proxies.end()) - return found->second; - - const auto& source = CreateParticleMaterial(desc); - const auto compiled = MaterialCompiler::Compile(m_ShaderLoader, *source); - EE_CORE_ASSERT(compiled, "Particle material compilation failed: {}", compiled.Diagnostics) - if (!compiled) return nullptr; - - const auto instance = CreateRef(source); - if (desc.Usage == EMaterialUsage::ParticleSprite) - { - const bool bound = instance->SetTexture( - std::string(DEFAULT_SPRITE_TEXTURE_PARAMETER), - desc.Texture - ); - EE_CORE_ASSERT(bound, "Particle Sprite texture parameter is unavailable.") - } - - auto proxy = instance->CreateRenderProxy(compiled.Material); - EE_CORE_ASSERT(proxy, "Particle material proxy creation failed.") - if (!proxy) return nullptr; - - m_Proxies.emplace(key, proxy); - return proxy; - } - - size_t ParticleMaterialLibrary::SMaterialKeyHasher::operator()(const SMaterialKey& key) const - { - size_t hash = Hash::Hash(uint32_t(key.Usage)); - const auto mix = [&hash](const size_t value) - { - hash ^= value + 0x9e3779b9 + (hash << 6) + (hash >> 2); - }; - - mix(Hash::Hash(key.BaseColor.x)); - mix(Hash::Hash(key.BaseColor.y)); - mix(Hash::Hash(key.BaseColor.z)); - mix(Hash::Hash(key.Opacity)); - mix(Hash::Hash(key.Emissive.x)); - mix(Hash::Hash(key.Emissive.y)); - mix(Hash::Hash(key.Emissive.z)); - mix(Hash::Hash(key.TextureIdentity)); - return hash; - } -} diff --git a/Elixir/Source/Engine/Material/ParticleMaterialLibrary.h b/Elixir/Source/Engine/Material/ParticleMaterialLibrary.h deleted file mode 100644 index c836dc6a..00000000 --- a/Elixir/Source/Engine/Material/ParticleMaterialLibrary.h +++ /dev/null @@ -1,38 +0,0 @@ -#pragma once - -#include -#include -#include - -namespace Elixir -{ - // Owns compiled particle material proxies. This is an asset service: - // MaterialSystem never chooses or interprets a material while rendering. - class ELIXIR_API ParticleMaterialLibrary final - { - public: - explicit ParticleMaterialLibrary(const ShaderLoader* shaderLoader); - - Ref Create(const SParticleMaterialDescription& desc); - - private: - struct SMaterialKey - { - EMaterialUsage Usage = EMaterialUsage::ParticleSprite; - glm::vec3 BaseColor{ 1.0f }; - float Opacity = 1.0f; - glm::vec3 Emissive{ 0.0f }; - const Texture* TextureIdentity = nullptr; - - bool operator==(const SMaterialKey&) const = default; - }; - - struct SMaterialKeyHasher - { - size_t operator()(const SMaterialKey& key) const; - }; - - const ShaderLoader* m_ShaderLoader = nullptr; - std::unordered_map, SMaterialKeyHasher> m_Proxies; - }; -} \ No newline at end of file diff --git a/Elixir/Tests/Engine/Aether/SystemTest.cpp b/Elixir/Tests/Engine/Aether/SystemTest.cpp index a8c14e6b..eeebf741 100644 --- a/Elixir/Tests/Engine/Aether/SystemTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemTest.cpp @@ -3,10 +3,32 @@ #include #include #include +#include +#include using namespace Elixir; using namespace Elixir::Aether; +namespace +{ + class TestMaterialResolver : public MaterialResolver + { + public: + Ref Resolve(const Ref& instance) override + { + if (!instance || !instance->GetParent()) return nullptr; + const auto result = MaterialCompiler::Build(*instance->GetParent()); + return result ? MaterialRenderProxy::Create(result.Material, *instance) : nullptr; + } + }; + + SCompiledSystem Compile(const System& system) + { + TestMaterialResolver resolver; + return system.Compile(resolver); + } +} + TEST(AetherSystemTest, CompilePreservesEmitterSimulationSpace) { System system{ "Simulation space contract" }; @@ -14,7 +36,7 @@ TEST(AetherSystemTest, CompilePreservesEmitterSimulationSpace) 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); @@ -28,7 +50,7 @@ TEST(AetherSystemTest, CompileAssignsContiguousLocalEmitterParticleOffsets) 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); @@ -47,7 +69,7 @@ TEST(AetherSystemTest, CompileResolvesTriggerEmitterByCompiledIndex) 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); @@ -70,7 +92,7 @@ TEST(AetherSystemTest, CompileExposesOnlyAuthoredParameters) 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"); @@ -104,20 +126,14 @@ TEST(AetherSystemTest, CompileSnapshotsParticleSpriteMaterialForRenderData) .DefaultValue = SMaterialParam::MakeVector({ 1.0f, 1.0f, 1.0f, 1.0f }), })); - const auto instance = CreateRef(material); + const auto instance = material->CreateInstance(); ASSERT_TRUE(instance->SetVector("Tint", { 0.25f, 0.5f, 0.75f, 1.0f })); System system{ "Material snapshot contract" }; auto& emitter = system.AddEmitter("Smoke", 8, 0.0f); - const auto compiledMaterial = MaterialCompiler::Build(*material); - ASSERT_TRUE(compiledMaterial); - - const auto firstProxy = instance->CreateRenderProxy(compiledMaterial.Material); - ASSERT_TRUE(firstProxy); - - emitter.SetMaterial(firstProxy); - const auto first = system.Compile(); + emitter.SetMaterial(instance); + const auto first = Compile(system); ASSERT_EQ(first.Emitters.size(), 1); ASSERT_TRUE(first.Emitters[0].Material); @@ -129,11 +145,8 @@ TEST(AetherSystemTest, CompileSnapshotsParticleSpriteMaterialForRenderData) ASSERT_TRUE(instance->SetVector("Tint", { 0.75f, 0.5f, 0.25f, 1.0f })); - const auto secondProxy = instance->CreateRenderProxy(compiledMaterial.Material); - ASSERT_TRUE(secondProxy); - - emitter.SetMaterial(secondProxy); - const auto second = system.Compile(); + emitter.SetMaterial(instance); + const auto second = Compile(system); ASSERT_TRUE(second.Emitters[0].Material); EXPECT_FLOAT_EQ(first.Emitters[0].Material->GetValues()[0].x, 0.25f); @@ -145,19 +158,14 @@ TEST(AetherSystemTest, CompileSnapshotsParticleRibbonMaterialForRenderData) const auto material = CreateRef("Particle ribbon"); ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleRibbon, true)); - const auto instance = CreateRef(material); - const auto compiledMaterial = MaterialCompiler::Build(*material); - ASSERT_TRUE(compiledMaterial); - - const auto proxy = instance->CreateRenderProxy(compiledMaterial.Material); - ASSERT_TRUE(proxy); - System system{ "Ribbon material snapshot contract" }; auto& emitter = system.AddEmitter("Ribbon", 8, 0.0f); emitter.SetRenderMode(EParticleRenderMode::Ribbon); - emitter.SetMaterial(proxy); - const auto compiled = system.Compile(); + 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); @@ -171,20 +179,14 @@ TEST(AetherSystemTest, CompileSnapshotsParticleMeshMaterialForRenderData) const auto material = CreateRef("Particle mesh"); material->SetUsage(EMaterialUsage::ParticleMesh, true); - const auto instance = CreateRef(material); - - const auto compiledMaterial = MaterialCompiler::Build(*material); - ASSERT_TRUE(compiledMaterial); - - const auto proxy = instance->CreateRenderProxy(compiledMaterial.Material); - ASSERT_TRUE(proxy); - System system{ "Mesh material" }; auto& emitter = system.AddEmitter("Mesh", 8, 0.0f); emitter.SetRenderMode(EParticleRenderMode::Mesh); - emitter.SetMaterial(proxy); - const auto compiled = system.Compile(); + 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); @@ -198,7 +200,7 @@ TEST(AetherSystemTest, KeepsAnEmitterWithoutAnExplicitMaterialUnbound) System system{ "Explicit material contract" }; system.AddEmitter("Smoke", 8, 0.0f); - const auto compiled = system.Compile(); + const auto compiled = Compile(system); ASSERT_EQ(compiled.Emitters.size(), 1); EXPECT_FALSE(compiled.Emitters[0].Material); diff --git a/Elixir/Tests/Engine/Material/MaterialFrameTableTest.cpp b/Elixir/Tests/Engine/Material/MaterialFrameTableTest.cpp index 7d1c1e54..f049aa8e 100644 --- a/Elixir/Tests/Engine/Material/MaterialFrameTableTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialFrameTableTest.cpp @@ -54,7 +54,7 @@ TEST(MaterialFrameTableTest, DeduplicatesAProxyAndPreserveItsValues) const auto compiled = MaterialCompiler::Build(*material); ASSERT_TRUE(compiled); - const auto proxy = instance->CreateRenderProxy(compiled.Material); + const auto proxy = MaterialRenderProxy::Create(compiled.Material, *instance); ASSERT_TRUE(proxy); MaterialFrameTable table( @@ -90,7 +90,7 @@ TEST(MaterialFrameTableTest, RejectsAUniqueProxyPastCapacity) const auto compiled = MaterialCompiler::Build(*material); ASSERT_TRUE(compiled); - const auto proxy = instance->CreateRenderProxy(compiled.Material); + const auto proxy = MaterialRenderProxy::Create(compiled.Material, *instance); ASSERT_TRUE(proxy); EXPECT_FALSE(table.Add(*proxy)); } @@ -109,7 +109,7 @@ TEST(MaterialFrameTableTest, ResolvesAuthoredTextureSlots) const auto compiled = MaterialCompiler::Build(*material); ASSERT_TRUE(compiled); - const auto proxy = instance->CreateRenderProxy(compiled.Material); + const auto proxy = MaterialRenderProxy::Create(compiled.Material, *instance); ASSERT_TRUE(proxy); uint32_t resolveCount = 0; diff --git a/Elixir/Tests/Engine/Material/MaterialRenderProxyTest.cpp b/Elixir/Tests/Engine/Material/MaterialRenderProxyTest.cpp index 44bbc1e0..e164ca77 100644 --- a/Elixir/Tests/Engine/Material/MaterialRenderProxyTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialRenderProxyTest.cpp @@ -19,7 +19,7 @@ TEST(MaterialRenderProxyTest, ResolvesOverridesIntoAnImmutableSnapshot) MaterialInstance instance(material); ASSERT_TRUE(instance.SetVector("Tint", { 0.2f, 0.4f, 0.6, 1.0f })); - const auto proxy = instance.CreateRenderProxy(compiled); + 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)); @@ -43,5 +43,5 @@ TEST(MaterialRenderProxyTest, RejectsACompiledMaterialForAnOldSchema) )); MaterialInstance instance(material); - EXPECT_FALSE(instance.CreateRenderProxy(compiled)); + EXPECT_FALSE(MaterialRenderProxy::Create(compiled, instance)); } \ No newline at end of file diff --git a/Elixir/Tests/Engine/Material/ParticleMaterialDefaultsTest.cpp b/Elixir/Tests/Engine/Material/ParticleMaterialDefaultsTest.cpp deleted file mode 100644 index aa8a1d0b..00000000 --- a/Elixir/Tests/Engine/Material/ParticleMaterialDefaultsTest.cpp +++ /dev/null @@ -1,43 +0,0 @@ -#include - -#include - -using namespace Elixir; - -TEST(ParticleMaterialDefaultsTest, AuthorsConstantsForEachParticleUsage) -{ - for (const auto usage: { - EMaterialUsage::ParticleSprite, - EMaterialUsage::ParticleRibbon, - EMaterialUsage::ParticleMesh, - }) - { - const auto material = CreateParticleMaterial({ - .Usage = usage, - .BaseColor = { 0.25f, 0.5f, 0.75f }, - .Opacity = 0.4f, - .Emissive = { 1.5f, 0.2f, 0.1f }, - }); - - ASSERT_TRUE(material); - EXPECT_TRUE(material->SupportsUsage(usage)); - EXPECT_TRUE(material->ValidateGraph()); - - const auto hlsl = material->GetGraph().GenerateHLSL(); - EXPECT_NE(hlsl.find("surface.BaseColor"), std::string::npos); - EXPECT_NE(hlsl.find("0.400000"), std::string::npos); - EXPECT_NE(hlsl.find("surface.Opacity"), std::string::npos); - EXPECT_NE(hlsl.find("surface.Emissive"), std::string::npos); - } -} - -TEST(ParticleMaterialDefaultsTest, AuthorsSpriteTextureIntoBaseColorAndOpacity) -{ - const auto material = CreateParticleMaterial({ - .Usage = EMaterialUsage::ParticleSprite, - .BaseColor = { 0.8f, 0.4f, 0.2f }, - .Opacity = 0.6f, - }); - ASSERT_NE(material->FindParameter(std::string(DEFAULT_SPRITE_TEXTURE_PARAMETER)), nullptr); - EXPECT_TRUE(material->ValidateGraph()); -} \ No newline at end of file From e63195376897efabda2508b1a0a018ad334ce813 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Wed, 5 Aug 2026 02:06:15 -0300 Subject: [PATCH 33/89] refactor(material): move compiled cache to renderer Keep raw material assets and defaults in MaterialRegistry. Make MaterialRenderer own revision-aware compiled material caching. --- Dissolve/Source/Dissolve.cpp | 10 +- Elixir/Source/Engine/Aether/System.cpp | 4 +- Elixir/Source/Engine/Aether/System.h | 4 +- Elixir/Source/Engine/Core/Application.cpp | 14 +-- Elixir/Source/Engine/Core/Application.h | 8 +- .../Material/MaterialCompilationCache.cpp | 45 +++++++++ .../Material/MaterialCompilationCache.h | 30 ++++++ .../Engine/Material/MaterialLibrary.cpp | 68 ------------- .../Source/Engine/Material/MaterialLibrary.h | 36 ------- .../Engine/Material/MaterialRegistry.cpp | 41 ++++++++ .../Source/Engine/Material/MaterialRegistry.h | 24 +++++ .../Engine/Material/MaterialRenderer.cpp | 96 +++++++++++-------- .../Source/Engine/Material/MaterialRenderer.h | 26 ++--- .../Source/Engine/Material/MaterialSystem.cpp | 13 +-- .../Source/Engine/Material/MaterialSystem.h | 5 +- .../Material/MaterialCompilationCacheTest.cpp | 27 ++++++ .../Engine/Material/MaterialLibraryTest.cpp | 31 ------ .../Engine/Material/MaterialRegistryTest.cpp | 31 ++++++ 18 files changed, 294 insertions(+), 219 deletions(-) create mode 100644 Elixir/Source/Engine/Material/MaterialCompilationCache.cpp create mode 100644 Elixir/Source/Engine/Material/MaterialCompilationCache.h delete mode 100644 Elixir/Source/Engine/Material/MaterialLibrary.cpp delete mode 100644 Elixir/Source/Engine/Material/MaterialLibrary.h create mode 100644 Elixir/Source/Engine/Material/MaterialRegistry.cpp create mode 100644 Elixir/Source/Engine/Material/MaterialRegistry.h create mode 100644 Elixir/Tests/Engine/Material/MaterialCompilationCacheTest.cpp delete mode 100644 Elixir/Tests/Engine/Material/MaterialLibraryTest.cpp create mode 100644 Elixir/Tests/Engine/Material/MaterialRegistryTest.cpp diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index 2d9470ee..127471bb 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -8,7 +8,7 @@ #include #include #include -#include +#include Ref pipeline; Scope m_ParticlesRenderer; @@ -71,13 +71,13 @@ Dissolve::Dissolve() m_ParticleSystems[0] = Aether::LoadEffectFile("./Assets/VFX/FireAndFireworks.json"); EE_CORE_ASSERT( - m_ParticleSystems[0]->ResolveMaterialInstances(GetMaterialLibrary()), + m_ParticleSystems[0]->ResolveMaterialInstances(GetMaterialRegistry()), "Could not resolve FireAndFireworks materials." ) m_ParticleSystems[1] = Aether::LoadEffectFile("./Assets/VFX/RibbonVortex.json"); EE_CORE_ASSERT( - m_ParticleSystems[1]->ResolveMaterialInstances(GetMaterialLibrary()), + m_ParticleSystems[1]->ResolveMaterialInstances(GetMaterialRegistry()), "Could not resolve RibbonVortex materials." ) @@ -122,7 +122,7 @@ Dissolve::Dissolve() graph.SetChannel(EMaterialChannel::BaseColor, graph.AddNode(baseColor)); graphMaterial->SetGraph(std::move(graph)); - EE_CORE_ASSERT(GetMaterialLibrary().Register(graphMaterial), "GraphMaterial must be unique.") + EE_CORE_ASSERT(GetMaterialRegistry().Register(graphMaterial), "GraphMaterial must be unique.") auto instance = graphMaterial->CreateInstance(); EE_CORE_ASSERT( @@ -206,7 +206,7 @@ Dissolve::Dissolve() //graph1.SetChannel(EMaterialChannel::Emissive, graph1.AddNode(glow)); ribbonMaterial->SetGraph(std::move(graph1)); - EE_CORE_ASSERT(GetMaterialLibrary().Register(ribbonMaterial), "RibbonEnergy must be unique.") + EE_CORE_ASSERT(GetMaterialRegistry().Register(ribbonMaterial), "RibbonEnergy must be unique.") const auto instance = ribbonMaterial->CreateInstance(); EE_CORE_ASSERT( diff --git a/Elixir/Source/Engine/Aether/System.cpp b/Elixir/Source/Engine/Aether/System.cpp index ceb4ed10..1145765e 100644 --- a/Elixir/Source/Engine/Aether/System.cpp +++ b/Elixir/Source/Engine/Aether/System.cpp @@ -1,7 +1,7 @@ #include "epch.h" #include "System.h" -#include +#include #include namespace Elixir::Aether @@ -29,7 +29,7 @@ namespace Elixir::Aether return nullptr; } - bool System::ResolveMaterialInstances(MaterialLibrary& materials) + bool System::ResolveMaterialInstances(MaterialRegistry& materials) const { for (const auto& emitter : m_Emitters) { diff --git a/Elixir/Source/Engine/Aether/System.h b/Elixir/Source/Engine/Aether/System.h index 46c024f8..199a0955 100644 --- a/Elixir/Source/Engine/Aether/System.h +++ b/Elixir/Source/Engine/Aether/System.h @@ -7,7 +7,7 @@ namespace Elixir { - class MaterialLibrary; + class MaterialRegistry; class MaterialResolver; } @@ -62,7 +62,7 @@ namespace Elixir::Aether Emitter* FindEmitter(std::string_view name) const; - bool ResolveMaterialInstances(MaterialLibrary& materials); + bool ResolveMaterialInstances(MaterialRegistry& materials) const; SCompiledSystem Compile(MaterialResolver& materialResolver) const; ParameterStore& GetParameters() { return m_Parameters; } diff --git a/Elixir/Source/Engine/Core/Application.cpp b/Elixir/Source/Engine/Core/Application.cpp index 97d9437a..441356c3 100644 --- a/Elixir/Source/Engine/Core/Application.cpp +++ b/Elixir/Source/Engine/Core/Application.cpp @@ -13,7 +13,7 @@ #include #include #include -#include +#include namespace Elixir { @@ -39,10 +39,10 @@ namespace Elixir TextureLoader::Initialize(m_GraphicsContext.get()); FontManager::Initialize(m_GraphicsContext.get()); - m_MaterialLibrary = CreateScope(m_ShaderLoader.get()); + m_MaterialRegistry = CreateScope(); m_MaterialSystem = CreateScope( m_GraphicsContext.get(), - *m_MaterialLibrary, + m_ShaderLoader.get(), SMaterialSystemConfig{ .InitialFrameCapacity = 256 } ); @@ -220,14 +220,14 @@ namespace Elixir return *m_MaterialSystem; } - MaterialLibrary& Application::GetMaterialLibrary() + MaterialRegistry& Application::GetMaterialRegistry() { - return *m_MaterialLibrary; + return *m_MaterialRegistry; } - const MaterialLibrary& Application::GetMaterialLibrary() const + const MaterialRegistry& Application::GetMaterialRegistry() const { - return *m_MaterialLibrary; + return *m_MaterialRegistry; } bool Application::OnWindowClose(WindowCloseEvent& event) diff --git a/Elixir/Source/Engine/Core/Application.h b/Elixir/Source/Engine/Core/Application.h index f8177a5e..a044fded 100644 --- a/Elixir/Source/Engine/Core/Application.h +++ b/Elixir/Source/Engine/Core/Application.h @@ -15,7 +15,7 @@ namespace Elixir namespace GUI { class TextBlock; } class MaterialSystem; - class MaterialLibrary; + class MaterialRegistry; class ELIXIR_API Application { @@ -34,8 +34,8 @@ namespace Elixir MaterialSystem& GetMaterialSystem(); const MaterialSystem& GetMaterialSystem() const; - MaterialLibrary& GetMaterialLibrary(); - const MaterialLibrary& GetMaterialLibrary() const; + MaterialRegistry& GetMaterialRegistry(); + const MaterialRegistry& GetMaterialRegistry() const; static Application& Get() { return *s_Application; } @@ -51,7 +51,7 @@ namespace Elixir Scope m_GUIManager; Scope m_MaterialSystem; - Scope m_MaterialLibrary; + Scope m_MaterialRegistry; Timer m_Timer; FrameProfiler m_Profiler; diff --git a/Elixir/Source/Engine/Material/MaterialCompilationCache.cpp b/Elixir/Source/Engine/Material/MaterialCompilationCache.cpp new file mode 100644 index 00000000..9361ecaf --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialCompilationCache.cpp @@ -0,0 +1,45 @@ +#include "epch.h" +#include "MaterialCompilationCache.h" + +#include + +namespace Elixir +{ + MaterialCompilationCache::MaterialCompilationCache(const ShaderLoader* shaderLoader) + : m_ShaderLoader(shaderLoader) {} + + Ref MaterialCompilationCache::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 + ? MaterialCompiler::Compile(m_ShaderLoader, *material) + : MaterialCompiler::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/Material/MaterialCompilationCache.h b/Elixir/Source/Engine/Material/MaterialCompilationCache.h new file mode 100644 index 00000000..b7c76e8f --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialCompilationCache.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +namespace Elixir +{ + class ShaderLoader; + + // Renderer-owned, GraphicsContext-scoped cache for compiled material programs. + class ELIXIR_API MaterialCompilationCache final + { + public: + explicit MaterialCompilationCache(const ShaderLoader* shaderLoader); + + Ref GetOrCompile(const Ref& material); + + private: + struct SEntry + { + Ref Source; + uint32_t Revision = 0; + Ref Compiled; + }; + + const ShaderLoader* m_ShaderLoader = nullptr; + std::unordered_map m_Entries; + std::mutex m_Mutex; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/MaterialLibrary.cpp b/Elixir/Source/Engine/Material/MaterialLibrary.cpp deleted file mode 100644 index 05e97dda..00000000 --- a/Elixir/Source/Engine/Material/MaterialLibrary.cpp +++ /dev/null @@ -1,68 +0,0 @@ -#include "epch.h" -#include "MaterialLibrary.h" - -#include - -namespace Elixir -{ - MaterialLibrary::MaterialLibrary(const ShaderLoader* shaderLoader) - : m_ShaderLoader(shaderLoader), - 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 MaterialLibrary::Register(const Ref& material) - { - if (!material || material->GetName().empty()) return false; - return m_Materials.emplace(material->GetName(), material).second; - } - - Ref MaterialLibrary::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& MaterialLibrary::GetDefault(const EMaterialUsage usage) const - { - return m_Defaults[GetDefaultSlot(usage)]; - } - - Ref MaterialLibrary::GetCompiledMaterial( - const Ref& material - ) - { - if (!material) return nullptr; - - auto& entry = m_CompiledMaterials[material.get()]; - if (entry.Material && entry.Revision == material->GetRevision()) - return entry.Material; - - const auto result = MaterialCompiler::Compile(m_ShaderLoader, *material); - if (!result) - { - EE_CORE_ERROR( - "Material '{}' compilation failed: {}", - material->GetName(), - result.Diagnostics - ) - return nullptr; - } - - entry.Revision = material->GetRevision(); - entry.Material = result.Material; - return entry.Material; - } - - size_t MaterialLibrary::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/Material/MaterialLibrary.h b/Elixir/Source/Engine/Material/MaterialLibrary.h deleted file mode 100644 index 80db1535..00000000 --- a/Elixir/Source/Engine/Material/MaterialLibrary.h +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include - -namespace Elixir -{ - class ShaderLoader; - struct SCompiledMaterial; - - // Application-owned registry and compiled-material cache. - class ELIXIR_API MaterialLibrary final - { - public: - explicit MaterialLibrary(const ShaderLoader* shaderLoader); - - bool Register(const Ref& material); - Ref Find(std::string_view name) const; - const Ref& GetDefault(EMaterialUsage usage) const; - - Ref GetCompiledMaterial(const Ref& material); - - private: - struct SCompiledEntry - { - uint32_t Revision = 0; - Ref Material; - }; - - static size_t GetDefaultSlot(EMaterialUsage usage); - - const ShaderLoader* m_ShaderLoader = nullptr; - DefaultMaterialArray m_Defaults; - std::unordered_map> m_Materials; - std::unordered_map m_CompiledMaterials; - }; -} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/MaterialRegistry.cpp b/Elixir/Source/Engine/Material/MaterialRegistry.cpp new file mode 100644 index 00000000..750d17d7 --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialRegistry.cpp @@ -0,0 +1,41 @@ +#include "epch.h" +#include "MaterialRegistry.h" + +#include + +namespace Elixir +{ + 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/Material/MaterialRegistry.h b/Elixir/Source/Engine/Material/MaterialRegistry.h new file mode 100644 index 00000000..b710ce3f --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialRegistry.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +namespace Elixir +{ + + // Application-owned registry for raw material assets and defaults. + class ELIXIR_API MaterialRegistry final + { + public: + MaterialRegistry(); + + bool Register(const Ref& material); + Ref Find(std::string_view name) const; + const Ref& GetDefault(EMaterialUsage usage) const; + + private: + static size_t GetDefaultSlot(EMaterialUsage usage); + + DefaultMaterialArray m_Defaults; + std::unordered_map> m_Materials; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/MaterialRenderer.cpp b/Elixir/Source/Engine/Material/MaterialRenderer.cpp index 95c9ce14..16fe2113 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderer.cpp +++ b/Elixir/Source/Engine/Material/MaterialRenderer.cpp @@ -8,40 +8,61 @@ namespace Elixir MaterialRenderer::MaterialRenderer( const GraphicsContext* context, Ref frameBuffer, - const MaterialTextureRegistry& textures + const MaterialTextureRegistry& textures, + const ShaderLoader* shaderLoader ) : m_FrameBuffer(std::move(frameBuffer)), m_Textures(textures), + m_CompilationCache(shaderLoader), m_Context(context) {} - EMaterialUsage MaterialRenderer::GetUsage(EMaterialPass pass) + std::optional MaterialRenderer::Prepare( + const SMaterialPassRequest& request + ) { - switch (pass) + if (!request.Material || !request.Pipeline.VertexLayout) + 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()) { - case EMaterialPass::ParticleSprite: return EMaterialUsage::ParticleSprite; - case EMaterialPass::ParticleRibbon: return EMaterialUsage::ParticleRibbon; - case EMaterialPass::ParticleMesh: return EMaterialUsage::ParticleMesh; + shader->SetPushConstant( + "pc", + const_cast(static_cast(request.InitialPushConstants.data())), + request.InitialPushConstants.size() + ); } - EE_CORE_ASSERT(false, "Material pass does not have a material usage.") - return EMaterialUsage::ParticleSprite; + return SPreparedMaterialPass{ + .Shader = shader, + .Pipeline = GetPipeline(request.Pass, shader, request.Pipeline), + }; } - uint32_t MaterialRenderer::GetPassOrder(const EMaterialPass pass) + Ref MaterialRenderer::Resolve( + const Ref& instance + ) { - switch (pass) - { - case EMaterialPass::ParticleSprite: return 2; - case EMaterialPass::ParticleRibbon: return 1; - case EMaterialPass::ParticleMesh: return 0; - } + if (!instance || !instance->GetParent()) return nullptr; - return UINT32_MAX; + const auto compiled = m_CompilationCache.GetOrCompile(instance->GetParent()); + return compiled + ? MaterialRenderProxy::Create(compiled, *instance) + : nullptr; } std::optional MaterialRenderer::GetProgramKey( const EMaterialPass pass, const MaterialRenderProxy& material - ) const + ) { const auto usage = GetUsage(pass); const auto compiled = material.GetCompiledMaterial(); @@ -55,36 +76,29 @@ namespace Elixir return SMaterialProgramKey{ .Identity = shader.get() }; } - std::optional MaterialRenderer::Prepare( - const SMaterialPassRequest& request - ) + EMaterialUsage MaterialRenderer::GetUsage(EMaterialPass pass) { - if (!request.Material || !request.Pipeline.VertexLayout) - 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)); + switch (pass) + { + case EMaterialPass::ParticleSprite: return EMaterialUsage::ParticleSprite; + case EMaterialPass::ParticleRibbon: return EMaterialUsage::ParticleRibbon; + case EMaterialPass::ParticleMesh: return EMaterialUsage::ParticleMesh; + } - if (!BindDescriptorResources(shader, request)) - return std::nullopt; + EE_CORE_ASSERT(false, "Material pass does not have a material usage.") + return EMaterialUsage::ParticleSprite; + } - if (!request.InitialPushConstants.empty()) + uint32_t MaterialRenderer::GetPassOrder(const EMaterialPass pass) + { + switch (pass) { - shader->SetPushConstant( - "pc", - const_cast(static_cast(request.InitialPushConstants.data())), - request.InitialPushConstants.size() - ); + case EMaterialPass::ParticleSprite: return 2; + case EMaterialPass::ParticleRibbon: return 1; + case EMaterialPass::ParticleMesh: return 0; } - return SPreparedMaterialPass{ - .Shader = shader, - .Pipeline = GetPipeline(request.Pass, shader, request.Pipeline), - }; + return UINT32_MAX; } Ref MaterialRenderer::GetPipeline( diff --git a/Elixir/Source/Engine/Material/MaterialRenderer.h b/Elixir/Source/Engine/Material/MaterialRenderer.h index 773beaba..d723bc91 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderer.h +++ b/Elixir/Source/Engine/Material/MaterialRenderer.h @@ -1,17 +1,17 @@ #pragma once -#include -#include -#include #include #include #include #include #include +#include namespace Elixir { + class ShaderLoader; + enum class EMaterialPass : uint8_t { ParticleSprite, @@ -81,21 +81,24 @@ namespace Elixir MaterialRenderer( const GraphicsContext* context, Ref frameBuffer, - const MaterialTextureRegistry& textures + const MaterialTextureRegistry& textures, + const ShaderLoader* shaderLoader ); - static EMaterialUsage GetUsage(EMaterialPass pass); - static uint32_t GetPassOrder(EMaterialPass pass); + std::optional Prepare( + const SMaterialPassRequest& request + ); + + Ref Resolve(const Ref& instance); - std::optional GetProgramKey( + static std::optional GetProgramKey( EMaterialPass pass, const MaterialRenderProxy& material - ) const; - - std::optional Prepare( - const SMaterialPassRequest& request ); + static EMaterialUsage GetUsage(EMaterialPass pass); + static uint32_t GetPassOrder(EMaterialPass pass); + private: enum class EDescriptorBindingType : uint8_t { @@ -158,6 +161,7 @@ namespace Elixir const MaterialTextureRegistry& m_Textures; std::unordered_map, SPipelineKeyHasher> m_Pipelines; std::unordered_map m_DescriptorBindings; + MaterialCompilationCache m_CompilationCache; const GraphicsContext* m_Context = nullptr; }; diff --git a/Elixir/Source/Engine/Material/MaterialSystem.cpp b/Elixir/Source/Engine/Material/MaterialSystem.cpp index 2639d592..a8e4aa6d 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.cpp +++ b/Elixir/Source/Engine/Material/MaterialSystem.cpp @@ -1,8 +1,6 @@ #include "epch.h" #include "MaterialSystem.h" -#include "MaterialLibrary.h" - namespace Elixir { namespace @@ -40,7 +38,7 @@ namespace Elixir MaterialSystem::MaterialSystem( const GraphicsContext* context, - MaterialLibrary& materials, + const ShaderLoader* shaderLoader, const SMaterialSystemConfig config ) : m_MaterialCapacity(GetInitialFrameCapacity(config)), m_FrameBuffer(DynamicStorageBuffer::Create( @@ -48,11 +46,11 @@ namespace Elixir sizeof(SMaterialFrameData) * m_MaterialCapacity) ), m_Textures(context), - m_Materials(materials), m_Renderer(CreateScope( context, m_FrameBuffer, - m_Textures + m_Textures, + shaderLoader )) {} SMaterialFrameSnapshot MaterialSystem::BuildFrameSnapshot( @@ -251,9 +249,6 @@ namespace Elixir const Ref& instance ) { - if (!instance || !instance->GetParent()) return nullptr; - - const auto compiled = m_Materials.GetCompiledMaterial(instance->GetParent()); - return compiled ? MaterialRenderProxy::Create(compiled, *instance) : nullptr; + return m_Renderer->Resolve(instance); } } diff --git a/Elixir/Source/Engine/Material/MaterialSystem.h b/Elixir/Source/Engine/Material/MaterialSystem.h index 4a1cd780..8a625987 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.h +++ b/Elixir/Source/Engine/Material/MaterialSystem.h @@ -9,7 +9,7 @@ namespace Elixir { - class MaterialLibrary; + class ShaderLoader; struct SMaterialSystemConfig { @@ -34,7 +34,7 @@ namespace Elixir public: MaterialSystem( const GraphicsContext* context, - MaterialLibrary& materials, + const ShaderLoader* shaderLoader, SMaterialSystemConfig config ); @@ -70,7 +70,6 @@ namespace Elixir uint32_t m_MaterialCapacity = 0; Ref m_FrameBuffer; MaterialTextureRegistry m_Textures; - MaterialLibrary& m_Materials; Scope m_Renderer; }; } diff --git a/Elixir/Tests/Engine/Material/MaterialCompilationCacheTest.cpp b/Elixir/Tests/Engine/Material/MaterialCompilationCacheTest.cpp new file mode 100644 index 00000000..13b5e74b --- /dev/null +++ b/Elixir/Tests/Engine/Material/MaterialCompilationCacheTest.cpp @@ -0,0 +1,27 @@ +#include + +#include + +using namespace Elixir; + +TEST(MaterialCompilationCacheTest, ReusesACompiledMaterialUntilTheSourceRevisionChanges) +{ + MaterialCompilationCache 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)); +} \ No newline at end of file diff --git a/Elixir/Tests/Engine/Material/MaterialLibraryTest.cpp b/Elixir/Tests/Engine/Material/MaterialLibraryTest.cpp deleted file mode 100644 index de402aee..00000000 --- a/Elixir/Tests/Engine/Material/MaterialLibraryTest.cpp +++ /dev/null @@ -1,31 +0,0 @@ -#include - -#include - -using namespace Elixir; - -TEST(MaterialLibraryTest, RegistersAndFindsDefaultMaterials) -{ - const MaterialLibrary library{ nullptr }; - - for (const auto usage : { - EMaterialUsage::ParticleSprite, - EMaterialUsage::ParticleRibbon, - EMaterialUsage::ParticleMesh - }) - { - const auto& material = library.GetDefault(usage); - ASSERT_TRUE(material); - EXPECT_EQ(library.Find(material->GetName()), material); - EXPECT_TRUE(material->SupportsUsage(usage)); - EXPECT_TRUE(material->ValidateGraph()); - EXPECT_TRUE(material->GetParameters().empty()); - } -} - -TEST(MaterialLibraryTest, RejectsDuplicateMaterialNames) -{ - MaterialLibrary library{ nullptr }; - EXPECT_TRUE(library.Register(CreateRef("Game.Custom"))); - EXPECT_FALSE(library.Register(CreateRef("Game.Custom"))); -} \ No newline at end of file diff --git a/Elixir/Tests/Engine/Material/MaterialRegistryTest.cpp b/Elixir/Tests/Engine/Material/MaterialRegistryTest.cpp new file mode 100644 index 00000000..23a31a11 --- /dev/null +++ b/Elixir/Tests/Engine/Material/MaterialRegistryTest.cpp @@ -0,0 +1,31 @@ +#include + +#include + +using namespace Elixir; + +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"))); +} \ No newline at end of file From d659fab79d735a5e6d69cf550af6d90e5043673a Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Wed, 5 Aug 2026 07:40:07 -0300 Subject: [PATCH 34/89] refactor(aether): extract effect material resolution Move effect material authoring resolution out of System. Keep material registration and instance creation in an Aether resolver that can later be owned by AetherManager. --- Dissolve/Source/Dissolve.cpp | 7 ++- .../Engine/Aether/EffectMaterialResolver.cpp | 47 +++++++++++++++++++ .../Engine/Aether/EffectMaterialResolver.h | 23 +++++++++ Elixir/Source/Engine/Aether/System.cpp | 31 ------------ Elixir/Source/Engine/Aether/System.h | 7 +-- .../Aether/EffectMaterialResolverTest.cpp | 37 +++++++++++++++ 6 files changed, 116 insertions(+), 36 deletions(-) create mode 100644 Elixir/Source/Engine/Aether/EffectMaterialResolver.cpp create mode 100644 Elixir/Source/Engine/Aether/EffectMaterialResolver.h create mode 100644 Elixir/Tests/Engine/Aether/EffectMaterialResolverTest.cpp diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index 127471bb..f1f7b1af 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -9,6 +9,7 @@ #include #include #include +#include Ref pipeline; Scope m_ParticlesRenderer; @@ -69,15 +70,17 @@ Dissolve::Dissolve() GetMaterialSystem() ); + Aether::EffectMaterialResolver effectMaterials{ GetMaterialRegistry() }; + m_ParticleSystems[0] = Aether::LoadEffectFile("./Assets/VFX/FireAndFireworks.json"); EE_CORE_ASSERT( - m_ParticleSystems[0]->ResolveMaterialInstances(GetMaterialRegistry()), + effectMaterials.Resolve(*m_ParticleSystems[0]), "Could not resolve FireAndFireworks materials." ) m_ParticleSystems[1] = Aether::LoadEffectFile("./Assets/VFX/RibbonVortex.json"); EE_CORE_ASSERT( - m_ParticleSystems[1]->ResolveMaterialInstances(GetMaterialRegistry()), + effectMaterials.Resolve(*m_ParticleSystems[1]), "Could not resolve RibbonVortex materials." ) diff --git a/Elixir/Source/Engine/Aether/EffectMaterialResolver.cpp b/Elixir/Source/Engine/Aether/EffectMaterialResolver.cpp new file mode 100644 index 00000000..ed096d2c --- /dev/null +++ b/Elixir/Source/Engine/Aether/EffectMaterialResolver.cpp @@ -0,0 +1,47 @@ +#include "epch.h" +#include "EffectMaterialResolver.h" + +#include +#include +#include + +#include "spdlog/fmt/bundled/base.h" + +namespace Elixir::Aether +{ + EffectMaterialResolver::EffectMaterialResolver(MaterialRegistry& registry) + : m_Registry(registry) {} + + bool EffectMaterialResolver::Resolve(System& system) const + { + for (const auto& emitter : system.m_Emitters) + { + Ref material; + + if (const auto& definition = emitter->GetMaterialDefinition()) + { + const auto name = "Aether." + system.m_UUID.ToString() + "." + emitter->GetName(); + + material = m_Registry.Find(name); + if (!material) + { + material = CreateParticleMaterial(name, emitter->GetRenderMode(), *definition); + if (!m_Registry.Register(material)) + { + EE_CORE_ERROR("Aether material '{}' could not be registered.", name) + return false; + } + } + } + else + { + material = m_Registry.GetDefault(GetParticleMaterialUsage(emitter->GetRenderMode())); + } + + emitter->SetMaterial(material); + if (!emitter->GetMaterial()) return false; + } + + return true; + } +} diff --git a/Elixir/Source/Engine/Aether/EffectMaterialResolver.h b/Elixir/Source/Engine/Aether/EffectMaterialResolver.h new file mode 100644 index 00000000..ae0b261b --- /dev/null +++ b/Elixir/Source/Engine/Aether/EffectMaterialResolver.h @@ -0,0 +1,23 @@ +#pragma once + +namespace Elixir +{ + class MaterialRegistry; +} + +namespace Elixir::Aether +{ + class System; + + // Resolves effect authoring data into emitter-owned material instances. + class ELIXIR_API EffectMaterialResolver final + { + public: + explicit EffectMaterialResolver(MaterialRegistry& registry); + + bool Resolve(System& system) const; + + private: + MaterialRegistry& m_Registry; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Aether/System.cpp b/Elixir/Source/Engine/Aether/System.cpp index 1145765e..bfc76846 100644 --- a/Elixir/Source/Engine/Aether/System.cpp +++ b/Elixir/Source/Engine/Aether/System.cpp @@ -1,9 +1,6 @@ #include "epch.h" #include "System.h" -#include -#include - namespace Elixir::Aether { System::System(const std::string& name) : m_Name(name) {} @@ -29,34 +26,6 @@ namespace Elixir::Aether return nullptr; } - bool System::ResolveMaterialInstances(MaterialRegistry& materials) const - { - for (const auto& emitter : m_Emitters) - { - Ref material; - if (const auto& definition = emitter->GetMaterialDefinition()) - { - const auto name = "Aether." + m_UUID.ToString() + "." + emitter->GetName(); - material = CreateParticleMaterial(name, emitter->GetRenderMode(), *definition); - if (!materials.Register(material)) - { - EE_CORE_ERROR("Aether material '{}' is already registered.", name) - return false; - } - } - else - { - const auto usage = GetParticleMaterialUsage(emitter->GetRenderMode()); - material = materials.GetDefault(usage); - } - - emitter->SetMaterial(material); - if (!emitter->GetMaterial()) return false; - } - - return true; - } - SCompiledSystem System::Compile(MaterialResolver& materialResolver) const { SCompiledSystem system; diff --git a/Elixir/Source/Engine/Aether/System.h b/Elixir/Source/Engine/Aether/System.h index 199a0955..5fc95029 100644 --- a/Elixir/Source/Engine/Aether/System.h +++ b/Elixir/Source/Engine/Aether/System.h @@ -7,12 +7,13 @@ namespace Elixir { - class MaterialRegistry; class MaterialResolver; } namespace Elixir::Aether { + class EffectMaterialResolver; + struct SCompiledTriggerTarget { uint32_t TargetEmitterIndex = 0; @@ -49,6 +50,8 @@ namespace Elixir::Aether class ELIXIR_API System final { + friend class EffectMaterialResolver; + public: explicit System(const std::string& name); @@ -59,10 +62,8 @@ namespace Elixir::Aether System& operator=(const System&) = delete; Emitter& AddEmitter(const std::string& name, uint32_t maxParticles, float spawnRate); - Emitter* FindEmitter(std::string_view name) const; - bool ResolveMaterialInstances(MaterialRegistry& materials) const; SCompiledSystem Compile(MaterialResolver& materialResolver) const; ParameterStore& GetParameters() { return m_Parameters; } diff --git a/Elixir/Tests/Engine/Aether/EffectMaterialResolverTest.cpp b/Elixir/Tests/Engine/Aether/EffectMaterialResolverTest.cpp new file mode 100644 index 00000000..94fd595b --- /dev/null +++ b/Elixir/Tests/Engine/Aether/EffectMaterialResolverTest.cpp @@ -0,0 +1,37 @@ +#include + +#include +#include +#include + +using namespace Elixir; +using namespace Elixir::Aether; + +TEST(EffectMaterialResolverTest, CreatesAuthoredMaterialsAndUsesUsageDefaults) +{ + MaterialRegistry registry; + const EffectMaterialResolver resolver{ registry }; + System system{ "Effect material resolution" }; + + auto& sprite = system.AddEmitter("Sprite", 8, 0.0f); + sprite.SetMaterialDefinition({ + .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)); +} \ No newline at end of file From 0320d0a4a0a75ae225c0d3d8a966ae1b89b64398 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Wed, 5 Aug 2026 16:42:09 -0300 Subject: [PATCH 35/89] refactor(aether): add effect manager Centralize effect material resolution and system compilation. Expose the application-scoped Aether manager while keeping renderer ownership of GPU allocation and drawing. --- Dissolve/Source/Dissolve.cpp | 29 ++++--- .../Engine/Aether/EffectMaterialResolver.cpp | 5 ++ Elixir/Source/Engine/Aether/Manager.cpp | 34 +++++++++ Elixir/Source/Engine/Aether/Manager.h | 31 ++++++++ Elixir/Source/Engine/Aether/System.h | 1 + Elixir/Source/Engine/Core/Application.cpp | 18 +++++ Elixir/Source/Engine/Core/Application.h | 15 +++- Elixir/Tests/Engine/Aether/ManagerTest.cpp | 75 +++++++++++++++++++ Elixir/Tests/Engine/Aether/SystemTest.cpp | 13 +--- .../Engine/Aether/TestMaterialResolver.h | 16 ++++ 10 files changed, 209 insertions(+), 28 deletions(-) create mode 100644 Elixir/Source/Engine/Aether/Manager.cpp create mode 100644 Elixir/Source/Engine/Aether/Manager.h create mode 100644 Elixir/Tests/Engine/Aether/ManagerTest.cpp create mode 100644 Elixir/Tests/Engine/Aether/TestMaterialResolver.h diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index f1f7b1af..8869a24e 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -3,13 +3,12 @@ #include #include #include -#include +#include #include #include #include #include -#include Ref pipeline; Scope m_ParticlesRenderer; @@ -70,18 +69,16 @@ Dissolve::Dissolve() GetMaterialSystem() ); - Aether::EffectMaterialResolver effectMaterials{ GetMaterialRegistry() }; - - m_ParticleSystems[0] = Aether::LoadEffectFile("./Assets/VFX/FireAndFireworks.json"); + m_ParticleSystems[0] = GetAetherManager().LoadEffect("./Assets/VFX/FireAndFireworks.json"); EE_CORE_ASSERT( - effectMaterials.Resolve(*m_ParticleSystems[0]), - "Could not resolve FireAndFireworks materials." + m_ParticleSystems[0], + "Could not resolve FireAndFireworks effect." ) - m_ParticleSystems[1] = Aether::LoadEffectFile("./Assets/VFX/RibbonVortex.json"); + m_ParticleSystems[1] = GetAetherManager().LoadEffect("./Assets/VFX/RibbonVortex.json"); EE_CORE_ASSERT( - effectMaterials.Resolve(*m_ParticleSystems[1]), - "Could not resolve RibbonVortex materials." + m_ParticleSystems[1], + "Could not resolve RibbonVortex effect." ) { @@ -230,13 +227,13 @@ Dissolve::Dissolve() } } - m_ParticleSystemInstances[0] = CreateScope( - CreateRef(m_ParticleSystems[0]->Compile(GetMaterialSystem())) - ); + auto fireAndFireworks = GetAetherManager().Compile(*m_ParticleSystems[0]); + m_ParticleSystemInstances[0] = GetAetherManager().CreateInstance(fireAndFireworks); + EE_CORE_ASSERT(m_ParticleSystemInstances[0], "Could not compile FireAndFireworks effect.") - m_ParticleSystemInstances[1] = CreateScope( - CreateRef(m_ParticleSystems[1]->Compile(GetMaterialSystem())) - ); + auto ribbonVortex = GetAetherManager().Compile(*m_ParticleSystems[1]); + m_ParticleSystemInstances[1] = GetAetherManager().CreateInstance(ribbonVortex); + EE_CORE_ASSERT(m_ParticleSystemInstances[1], "Could not compile RibbonVortex effect.") m_GraphicsContext->SetClearColor({ 0.015f, 0.025f, 0.06f, 1.0f }); } diff --git a/Elixir/Source/Engine/Aether/EffectMaterialResolver.cpp b/Elixir/Source/Engine/Aether/EffectMaterialResolver.cpp index ed096d2c..e564fc06 100644 --- a/Elixir/Source/Engine/Aether/EffectMaterialResolver.cpp +++ b/Elixir/Source/Engine/Aether/EffectMaterialResolver.cpp @@ -16,6 +16,11 @@ namespace Elixir::Aether { for (const auto& emitter : system.m_Emitters) { + // A caller may replace an effect-authored instance before + // Manager::Compile(). Do not overwrite that explicit choice. + if (emitter->GetMaterial()) + continue; + Ref material; if (const auto& definition = emitter->GetMaterialDefinition()) diff --git a/Elixir/Source/Engine/Aether/Manager.cpp b/Elixir/Source/Engine/Aether/Manager.cpp new file mode 100644 index 00000000..0ae5e492 --- /dev/null +++ b/Elixir/Source/Engine/Aether/Manager.cpp @@ -0,0 +1,34 @@ +#include "epch.h" +#include "Manager.h" + +#include +#include + +namespace Elixir::Aether +{ + Manager::Manager(MaterialRegistry& materialRegistry, MaterialResolver& materialResolver) + : m_EffectMaterials(materialRegistry), + m_MaterialResolver(materialResolver) {} + + Ref Manager::LoadEffect(const std::filesystem::path& filepath) const + { + return LoadEffectFile(filepath); + } + + Ref Manager::Compile(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(m_MaterialResolver)); + } + + Scope Manager::CreateInstance(Ref system) const + { + if (!system) return nullptr; + return CreateScope(std::move(system)); + } +} diff --git a/Elixir/Source/Engine/Aether/Manager.h b/Elixir/Source/Engine/Aether/Manager.h new file mode 100644 index 00000000..6551c69b --- /dev/null +++ b/Elixir/Source/Engine/Aether/Manager.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +namespace Elixir +{ + class MaterialRegistry; + class MaterialResolver; +} + +namespace Elixir::Aether +{ + // Application-scoped entry point for effect assets and their immutable + // runtime payloads. Renderer owns GPU allocation and drawing separately. + class ELIXIR_API Manager final + { + public: + Manager(MaterialRegistry& materialRegistry, MaterialResolver& materialResolver); + + Ref LoadEffect(const std::filesystem::path& filepath) const; + + Ref Compile(System& system) const; + + Scope CreateInstance(Ref system) const; + + private: + EffectMaterialResolver m_EffectMaterials; + MaterialResolver& m_MaterialResolver; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Aether/System.h b/Elixir/Source/Engine/Aether/System.h index 5fc95029..3e1aae1e 100644 --- a/Elixir/Source/Engine/Aether/System.h +++ b/Elixir/Source/Engine/Aether/System.h @@ -66,6 +66,7 @@ namespace Elixir::Aether SCompiledSystem Compile(MaterialResolver& materialResolver) const; + const std::string& GetName() const { return m_Name; } ParameterStore& GetParameters() { return m_Parameters; } const ParameterStore& GetParameters() const { return m_Parameters; } CurveStore& GetCurves() { return m_Curves; } diff --git a/Elixir/Source/Engine/Core/Application.cpp b/Elixir/Source/Engine/Core/Application.cpp index 441356c3..55f3615f 100644 --- a/Elixir/Source/Engine/Core/Application.cpp +++ b/Elixir/Source/Engine/Core/Application.cpp @@ -14,6 +14,7 @@ #include #include #include +#include namespace Elixir { @@ -53,6 +54,11 @@ namespace Elixir m_Window->GetFramebufferExtent() // TODO: Get from Ctx->GetRenderTargetExtent().. ); + m_AetherManager = CreateScope( + *m_MaterialRegistry, + *m_MaterialSystem + ); + const auto buttonBg = TextureLoader::Load("./Assets/Button_Background.png"); const auto panel = CreateRef(); @@ -230,6 +236,18 @@ namespace Elixir 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 a044fded..0955dc48 100644 --- a/Elixir/Source/Engine/Core/Application.h +++ b/Elixir/Source/Engine/Core/Application.h @@ -12,7 +12,15 @@ namespace Elixir { - namespace GUI { class TextBlock; } + namespace GUI + { + class TextBlock; + } + + namespace Aether + { + class Manager; + } class MaterialSystem; class MaterialRegistry; @@ -37,6 +45,9 @@ namespace Elixir MaterialRegistry& GetMaterialRegistry(); const MaterialRegistry& GetMaterialRegistry() const; + Aether::Manager& GetAetherManager(); + const Aether::Manager& GetAetherManager() const; + static Application& Get() { return *s_Application; } protected: @@ -53,6 +64,8 @@ namespace Elixir Scope m_MaterialSystem; Scope m_MaterialRegistry; + Scope m_AetherManager; + Timer m_Timer; FrameProfiler m_Profiler; diff --git a/Elixir/Tests/Engine/Aether/ManagerTest.cpp b/Elixir/Tests/Engine/Aether/ManagerTest.cpp new file mode 100644 index 00000000..2d6d5744 --- /dev/null +++ b/Elixir/Tests/Engine/Aether/ManagerTest.cpp @@ -0,0 +1,75 @@ +#include + +#include +#include +#include +#include + +#include "TestMaterialResolver.h" + +using namespace Elixir; +using namespace Elixir::Aether; + +TEST(AetherManagerTest, ResolvesEffectMaterialsBeforePublishingACompiledSystem) +{ + MaterialRegistry registry; + TestMaterialResolver resolver; + const Manager manager{ registry, resolver }; + System system{ "Managed effect" }; + + auto& emitter = system.AddEmitter("Sprite", 8, 0.0f); + emitter.SetMaterialDefinition({ + .BaseColor = { 0.25f, 0.5f, 0.75f }, + .Opacity = 0.4f, + .Emissive = { 0.1f, 0.0f, 0.0f }, + }); + + const auto compiled = manager.Compile(system); + + ASSERT_TRUE(compiled); + ASSERT_EQ(compiled->Emitters.size(), 1); + ASSERT_TRUE(compiled->Emitters[0].Material); + EXPECT_TRUE(compiled->Emitters[0].Material->GetCompiledMaterial()->SupportsUsage( + EMaterialUsage::ParticleSprite + )); + + const auto instance = manager.CreateInstance(compiled); + + ASSERT_TRUE(instance); + EXPECT_EQ(&instance->GetCompiledSystem(), compiled.get()); +} + +TEST(AetherManagerTest, PreservesAnExplicitMaterialBeforeCompiling) +{ + MaterialRegistry registry; + TestMaterialResolver resolver; + const Manager manager{ registry, resolver }; + System system{ "Explicit material" }; + + auto& emitter = system.AddEmitter("Sprite", 8, 0.0f); + emitter.SetMaterialDefinition({ + .Opacity = 0.4f, + }); + + const auto material = CreateRef("Explicit particle material"); + ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleSprite, true)); + ASSERT_TRUE(material->DefineParameter("Tint", { + .Kind = EMaterialParameterKind::Value, + .ValueType = EMaterialGraphValueType::Float4, + .DefaultValue = SMaterialParam::MakeVector({ 1.0f, 1.0f, 1.0f, 1.0f }), + })); + + const auto explicitInstance = material->CreateInstance(); + ASSERT_TRUE(explicitInstance->SetVector("Tint", { 0.2f, 0.4f, 0.6f, 1.0f })); + + emitter.SetMaterial(explicitInstance); + + const auto compiled = manager.Compile(system); + + ASSERT_TRUE(compiled); + ASSERT_TRUE(compiled->Emitters[0].Material); + EXPECT_EQ( + compiled->Emitters[0].Material->GetInstanceRevision(), + explicitInstance->GetRevision() + ); +} \ No newline at end of file diff --git a/Elixir/Tests/Engine/Aether/SystemTest.cpp b/Elixir/Tests/Engine/Aether/SystemTest.cpp index eeebf741..490333b1 100644 --- a/Elixir/Tests/Engine/Aether/SystemTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemTest.cpp @@ -6,22 +6,13 @@ #include #include +#include "TestMaterialResolver.h" + using namespace Elixir; using namespace Elixir::Aether; namespace { - class TestMaterialResolver : public MaterialResolver - { - public: - Ref Resolve(const Ref& instance) override - { - if (!instance || !instance->GetParent()) return nullptr; - const auto result = MaterialCompiler::Build(*instance->GetParent()); - return result ? MaterialRenderProxy::Create(result.Material, *instance) : nullptr; - } - }; - SCompiledSystem Compile(const System& system) { TestMaterialResolver resolver; diff --git a/Elixir/Tests/Engine/Aether/TestMaterialResolver.h b/Elixir/Tests/Engine/Aether/TestMaterialResolver.h new file mode 100644 index 00000000..329061dd --- /dev/null +++ b/Elixir/Tests/Engine/Aether/TestMaterialResolver.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +using namespace Elixir; + +class TestMaterialResolver : public MaterialResolver +{ +public: + Ref Resolve(const Ref& instance) override + { + if (!instance || !instance->GetParent()) return nullptr; + const auto result = MaterialCompiler::Build(*instance->GetParent()); + return result ? MaterialRenderProxy::Create(result.Material, *instance) : nullptr; + } +}; \ No newline at end of file From b8b8f3477c9165445d559a66780be0e55d11b09e Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Wed, 5 Aug 2026 19:19:39 -0300 Subject: [PATCH 36/89] refactor(aether): move runtime ownership to manager Let Aether Manager own particle frame submission and rendering. Keep the application at the effect and system-instance boundary while leaving GPU allocation and drawing in the renderer. --- Dissolve/Source/Dissolve.cpp | 22 ++----- Elixir/Source/Engine/Aether/Manager.cpp | 51 +++++++++++++-- Elixir/Source/Engine/Aether/Manager.h | 44 ++++++++++++- Elixir/Source/Engine/Core/Application.cpp | 2 + Elixir/Tests/Engine/Aether/ManagerTest.cpp | 75 ---------------------- 5 files changed, 96 insertions(+), 98 deletions(-) delete mode 100644 Elixir/Tests/Engine/Aether/ManagerTest.cpp diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index 8869a24e..433a1759 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -2,7 +2,6 @@ #include #include -#include #include #include @@ -11,8 +10,6 @@ #include Ref pipeline; -Scope m_ParticlesRenderer; -Aether::FrameSubmission m_ParticleFrameSubmission; std::array, 2> m_ParticleSystems; std::array, 2> m_ParticleSystemInstances; @@ -63,12 +60,6 @@ Dissolve::Dissolve() shader->BindConstantBuffer("cbFrame", m_FrameConstantBuffer); - m_ParticlesRenderer = CreateScope( - m_GraphicsContext.get(), - m_ShaderLoader.get(), - GetMaterialSystem() - ); - m_ParticleSystems[0] = GetAetherManager().LoadEffect("./Assets/VFX/FireAndFireworks.json"); EE_CORE_ASSERT( m_ParticleSystems[0], @@ -258,22 +249,21 @@ void Dissolve::OnRender(const Timestep 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]); + bool submitted = aether.Submit(*m_ParticleSystemInstances[0]); EE_CORE_ASSERT(submitted, "The particle system instance was submitted more than once.") - submitted = m_ParticleFrameSubmission.Submit(*m_ParticleSystemInstances[1]); + submitted = aether.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& metrics = aether.GetLastSubmissionMetrics(); } void Dissolve::OnEvent(Event& event) diff --git a/Elixir/Source/Engine/Aether/Manager.cpp b/Elixir/Source/Engine/Aether/Manager.cpp index 0ae5e492..00843cbc 100644 --- a/Elixir/Source/Engine/Aether/Manager.cpp +++ b/Elixir/Source/Engine/Aether/Manager.cpp @@ -2,13 +2,22 @@ #include "Manager.h" #include +#include #include +#include namespace Elixir::Aether { - Manager::Manager(MaterialRegistry& materialRegistry, MaterialResolver& materialResolver) - : m_EffectMaterials(materialRegistry), - m_MaterialResolver(materialResolver) {} + Manager::Manager( + const GraphicsContext* context, + const ShaderLoader* shaderLoader, + MaterialRegistry& materialRegistry, + MaterialSystem& materialSystem + ) : m_EffectMaterials(materialRegistry), + m_MaterialSystem(materialSystem), + m_Renderer(CreateScope(context, shaderLoader, materialSystem)) {} + + Manager::~Manager() = default; Ref Manager::LoadEffect(const std::filesystem::path& filepath) const { @@ -23,7 +32,7 @@ namespace Elixir::Aether return nullptr; } - return CreateRef(system.Compile(m_MaterialResolver)); + return CreateRef(system.Compile(m_MaterialSystem)); } Scope Manager::CreateInstance(Ref system) const @@ -31,4 +40,38 @@ namespace Elixir::Aether if (!system) return nullptr; return CreateScope(std::move(system)); } + + void Manager::BeginFrame(const Timestep& timestep) + { + // Release the previous frame's non-owning references before accepting + // a new immutable submission. + m_FrameSubmission.Reset(); + GetRenderer().Update(timestep); + } + + bool Manager::Submit(const SystemInstance& instance) + { + return m_FrameSubmission.Submit(instance); + } + + void Manager::Render(const Camera& camera) + { + GetRenderer().Render(m_FrameSubmission, camera); + } + + void Manager::Retire(const SystemInstance& instance) + { + GetRenderer().Retire(instance); + } + + const SParticleSubmissionMetrics& Manager::GetLastSubmissionMetrics() const + { + return GetRenderer().GetLastSubmissionMetrics(); + } + + Renderer& Manager::GetRenderer() const + { + EE_CORE_ASSERT(m_Renderer, "Aether renderer is unavailable.") + return *m_Renderer; + } } diff --git a/Elixir/Source/Engine/Aether/Manager.h b/Elixir/Source/Engine/Aether/Manager.h index 6551c69b..780dd096 100644 --- a/Elixir/Source/Engine/Aether/Manager.h +++ b/Elixir/Source/Engine/Aether/Manager.h @@ -1,22 +1,45 @@ #pragma once #include +#include #include namespace Elixir { + class Camera; + class GraphicsContext; + class ShaderLoader; + class Timestep; + class MaterialRegistry; class MaterialResolver; + class MaterialSystem; } namespace Elixir::Aether { + class Renderer; + struct SParticleSubmissionMetrics; + // Application-scoped entry point for effect assets and their immutable - // runtime payloads. Renderer owns GPU allocation and drawing separately. + // runtime payloads. It owns the particle renderer, while the renderer + // retains GPU allocation, synchronization, and draw implementation. class ELIXIR_API Manager final { public: - Manager(MaterialRegistry& materialRegistry, MaterialResolver& materialResolver); + Manager( + const GraphicsContext* context, + const ShaderLoader* shaderLoader, + MaterialRegistry& materialRegistry, + MaterialSystem& materialSystem + ); + + ~Manager(); + + Manager(const Manager&) = delete; + Manager& operator=(const Manager&) = delete; + Manager(Manager&&) = delete; + Manager& operator=(Manager&&) = delete; Ref LoadEffect(const std::filesystem::path& filepath) const; @@ -24,8 +47,23 @@ namespace Elixir::Aether Scope CreateInstance(Ref system) const; + // Render-frame API. Submit() is valid only between BeginFrame() and Render(). + void BeginFrame(const Timestep& timestep); + + bool Submit(const SystemInstance& instance); + + void Render(const Camera& camera); + + void Retire(const SystemInstance& instance); + + const SParticleSubmissionMetrics& GetLastSubmissionMetrics() const; + private: + Renderer& GetRenderer() const; + EffectMaterialResolver m_EffectMaterials; - MaterialResolver& m_MaterialResolver; + MaterialSystem& m_MaterialSystem; + FrameSubmission m_FrameSubmission; + Scope m_Renderer; }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/Core/Application.cpp b/Elixir/Source/Engine/Core/Application.cpp index 55f3615f..f68306d3 100644 --- a/Elixir/Source/Engine/Core/Application.cpp +++ b/Elixir/Source/Engine/Core/Application.cpp @@ -55,6 +55,8 @@ namespace Elixir ); m_AetherManager = CreateScope( + m_GraphicsContext.get(), + m_ShaderLoader.get(), *m_MaterialRegistry, *m_MaterialSystem ); diff --git a/Elixir/Tests/Engine/Aether/ManagerTest.cpp b/Elixir/Tests/Engine/Aether/ManagerTest.cpp deleted file mode 100644 index 2d6d5744..00000000 --- a/Elixir/Tests/Engine/Aether/ManagerTest.cpp +++ /dev/null @@ -1,75 +0,0 @@ -#include - -#include -#include -#include -#include - -#include "TestMaterialResolver.h" - -using namespace Elixir; -using namespace Elixir::Aether; - -TEST(AetherManagerTest, ResolvesEffectMaterialsBeforePublishingACompiledSystem) -{ - MaterialRegistry registry; - TestMaterialResolver resolver; - const Manager manager{ registry, resolver }; - System system{ "Managed effect" }; - - auto& emitter = system.AddEmitter("Sprite", 8, 0.0f); - emitter.SetMaterialDefinition({ - .BaseColor = { 0.25f, 0.5f, 0.75f }, - .Opacity = 0.4f, - .Emissive = { 0.1f, 0.0f, 0.0f }, - }); - - const auto compiled = manager.Compile(system); - - ASSERT_TRUE(compiled); - ASSERT_EQ(compiled->Emitters.size(), 1); - ASSERT_TRUE(compiled->Emitters[0].Material); - EXPECT_TRUE(compiled->Emitters[0].Material->GetCompiledMaterial()->SupportsUsage( - EMaterialUsage::ParticleSprite - )); - - const auto instance = manager.CreateInstance(compiled); - - ASSERT_TRUE(instance); - EXPECT_EQ(&instance->GetCompiledSystem(), compiled.get()); -} - -TEST(AetherManagerTest, PreservesAnExplicitMaterialBeforeCompiling) -{ - MaterialRegistry registry; - TestMaterialResolver resolver; - const Manager manager{ registry, resolver }; - System system{ "Explicit material" }; - - auto& emitter = system.AddEmitter("Sprite", 8, 0.0f); - emitter.SetMaterialDefinition({ - .Opacity = 0.4f, - }); - - const auto material = CreateRef("Explicit particle material"); - ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleSprite, true)); - ASSERT_TRUE(material->DefineParameter("Tint", { - .Kind = EMaterialParameterKind::Value, - .ValueType = EMaterialGraphValueType::Float4, - .DefaultValue = SMaterialParam::MakeVector({ 1.0f, 1.0f, 1.0f, 1.0f }), - })); - - const auto explicitInstance = material->CreateInstance(); - ASSERT_TRUE(explicitInstance->SetVector("Tint", { 0.2f, 0.4f, 0.6f, 1.0f })); - - emitter.SetMaterial(explicitInstance); - - const auto compiled = manager.Compile(system); - - ASSERT_TRUE(compiled); - ASSERT_TRUE(compiled->Emitters[0].Material); - EXPECT_EQ( - compiled->Emitters[0].Material->GetInstanceRevision(), - explicitInstance->GetRevision() - ); -} \ No newline at end of file From 3da90af69d3c223553780cc098f5494ad33aa48d Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Wed, 5 Aug 2026 22:08:25 -0300 Subject: [PATCH 37/89] refactor(aether): centralize system instance ownership Let Aether Manager own runtime system instances and retirement. Remove submitted references before renderer retirement while keeping GPU allocation and synchronization in the renderer. --- Dissolve/Source/Dissolve.cpp | 6 ++-- Elixir/Source/Engine/Aether/FrameSubmission.h | 12 +++++++ Elixir/Source/Engine/Aether/Manager.cpp | 31 ++++++++++++++----- Elixir/Source/Engine/Aether/Manager.h | 13 ++++++-- .../Engine/Aether/FrameSubmissionTest.cpp | 13 ++++++++ 5 files changed, 61 insertions(+), 14 deletions(-) diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index 433a1759..97c354d7 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -11,7 +11,7 @@ Ref pipeline; std::array, 2> m_ParticleSystems; -std::array, 2> m_ParticleSystemInstances; +std::array m_ParticleSystemInstances; Ref graphMaterial; @@ -219,11 +219,11 @@ Dissolve::Dissolve() } auto fireAndFireworks = GetAetherManager().Compile(*m_ParticleSystems[0]); - m_ParticleSystemInstances[0] = GetAetherManager().CreateInstance(fireAndFireworks); + m_ParticleSystemInstances[0] = &GetAetherManager().CreateInstance(fireAndFireworks); EE_CORE_ASSERT(m_ParticleSystemInstances[0], "Could not compile FireAndFireworks effect.") auto ribbonVortex = GetAetherManager().Compile(*m_ParticleSystems[1]); - m_ParticleSystemInstances[1] = GetAetherManager().CreateInstance(ribbonVortex); + m_ParticleSystemInstances[1] = &GetAetherManager().CreateInstance(ribbonVortex); EE_CORE_ASSERT(m_ParticleSystemInstances[1], "Could not compile RibbonVortex effect.") m_GraphicsContext->SetClearColor({ 0.015f, 0.025f, 0.06f, 1.0f }); diff --git a/Elixir/Source/Engine/Aether/FrameSubmission.h b/Elixir/Source/Engine/Aether/FrameSubmission.h index 64053f13..7ddc1f22 100644 --- a/Elixir/Source/Engine/Aether/FrameSubmission.h +++ b/Elixir/Source/Engine/Aether/FrameSubmission.h @@ -22,6 +22,18 @@ namespace Elixir::Aether return true; } + // Releases a non-owning reference before its manager-owned instance + // is retired and destroyed. + bool Remove(const SystemInstance& instance) + { + const auto found = m_InstanceIds.find(instance.GetId()); + if (found == m_InstanceIds.end()) return false; + + std::erase(m_Instances, &instance); + m_InstanceIds.erase(found); + return true; + } + void Reset() { m_Instances.clear(); diff --git a/Elixir/Source/Engine/Aether/Manager.cpp b/Elixir/Source/Engine/Aether/Manager.cpp index 00843cbc..e92a2911 100644 --- a/Elixir/Source/Engine/Aether/Manager.cpp +++ b/Elixir/Source/Engine/Aether/Manager.cpp @@ -35,10 +35,30 @@ namespace Elixir::Aether return CreateRef(system.Compile(m_MaterialSystem)); } - Scope Manager::CreateInstance(Ref system) const + SystemInstance& Manager::CreateInstance(Ref system) { - if (!system) return nullptr; - return CreateScope(std::move(system)); + EE_CORE_ASSERT(system, "Aether system instance requires a compiled system.") + + auto instance = CreateScope(std::move(system)); + const auto id = instance->GetId(); + + const auto [_, inserted] = m_Instances.emplace(id, std::move(instance)); + EE_CORE_ASSERT(inserted, "Aether system instance UUID must be unique.") + + return *m_Instances[id]; + } + + bool Manager::DestroyInstance(const UUID& instanceId) + { + const auto found = m_Instances.find(instanceId); + if (found == m_Instances.end()) return false; + + const auto& instance = *found->second; + m_FrameSubmission.Remove(instance); + GetRenderer().Retire(instance); + m_Instances.erase(found); + + return true; } void Manager::BeginFrame(const Timestep& timestep) @@ -59,11 +79,6 @@ namespace Elixir::Aether GetRenderer().Render(m_FrameSubmission, camera); } - void Manager::Retire(const SystemInstance& instance) - { - GetRenderer().Retire(instance); - } - const SParticleSubmissionMetrics& Manager::GetLastSubmissionMetrics() const { return GetRenderer().GetLastSubmissionMetrics(); diff --git a/Elixir/Source/Engine/Aether/Manager.h b/Elixir/Source/Engine/Aether/Manager.h index 780dd096..28926f8e 100644 --- a/Elixir/Source/Engine/Aether/Manager.h +++ b/Elixir/Source/Engine/Aether/Manager.h @@ -45,7 +45,13 @@ namespace Elixir::Aether Ref Compile(System& system) const; - Scope CreateInstance(Ref system) const; + // The manager owns the returned instance. It remains valid until + // DestroyInstance() is called or the manager is destroyed. + SystemInstance& CreateInstance(Ref system); + + // Must be called from the render-frame callback. It detaches any + // frame submission before the renderer retires GPU allocations. + bool DestroyInstance(const UUID& instanceId); // Render-frame API. Submit() is valid only between BeginFrame() and Render(). void BeginFrame(const Timestep& timestep); @@ -54,15 +60,16 @@ namespace Elixir::Aether void Render(const Camera& camera); - void Retire(const SystemInstance& instance); - const SParticleSubmissionMetrics& GetLastSubmissionMetrics() const; private: Renderer& GetRenderer() const; EffectMaterialResolver m_EffectMaterials; + MaterialSystem& m_MaterialSystem; + std::unordered_map> m_Instances; + FrameSubmission m_FrameSubmission; Scope m_Renderer; }; diff --git a/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp b/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp index 9d3604b8..cc103fd8 100644 --- a/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp +++ b/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp @@ -38,4 +38,17 @@ TEST(AetherFrameSubmissionTest, ResetKeepsTheSubmissionReusable) EXPECT_TRUE(submission.Submit(firstInstance)); EXPECT_TRUE(submission.Submit(secondInstance)); EXPECT_EQ(submission.GetInstanceCount(), 2); +} + +TEST(AetherFrameSubmissionTest, RemovesAnInstanceBeforeItIsRetired) +{ + const auto compiledSystem = CreateRef(); + const SystemInstance instance{ compiledSystem }; + 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)); } \ No newline at end of file From 81bc3ba0d2e0b1d6bffab8048d8cde8a8f8729e7 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Wed, 5 Aug 2026 22:51:30 -0300 Subject: [PATCH 38/89] refactor(aether): expose system instance handles Replace application-held instance pointers with stable manager handles. Resolve handles only inside Aether Manager before submission or lifecycle operations. --- Dissolve/Source/Dissolve.cpp | 14 +++++----- Elixir/Source/Engine/Aether/Manager.cpp | 27 ++++++++++++++----- Elixir/Source/Engine/Aether/Manager.h | 14 ++++++---- .../Engine/Aether/SystemInstanceHandle.h | 13 +++++++++ 4 files changed, 50 insertions(+), 18 deletions(-) create mode 100644 Elixir/Source/Engine/Aether/SystemInstanceHandle.h diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index 97c354d7..09b00456 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -11,7 +11,7 @@ Ref pipeline; std::array, 2> m_ParticleSystems; -std::array m_ParticleSystemInstances; +std::array m_ParticleSystemInstances; Ref graphMaterial; @@ -219,12 +219,12 @@ Dissolve::Dissolve() } auto fireAndFireworks = GetAetherManager().Compile(*m_ParticleSystems[0]); - m_ParticleSystemInstances[0] = &GetAetherManager().CreateInstance(fireAndFireworks); - EE_CORE_ASSERT(m_ParticleSystemInstances[0], "Could not compile FireAndFireworks effect.") + EE_CORE_ASSERT(fireAndFireworks, "Could not compile FireAndFireworks effect.") + m_ParticleSystemInstances[0] = GetAetherManager().CreateInstance(fireAndFireworks); auto ribbonVortex = GetAetherManager().Compile(*m_ParticleSystems[1]); - m_ParticleSystemInstances[1] = &GetAetherManager().CreateInstance(ribbonVortex); - EE_CORE_ASSERT(m_ParticleSystemInstances[1], "Could not compile RibbonVortex effect.") + EE_CORE_ASSERT(ribbonVortex, "Could not compile RibbonVortex effect.") + m_ParticleSystemInstances[1] = GetAetherManager().CreateInstance(ribbonVortex); m_GraphicsContext->SetClearColor({ 0.015f, 0.025f, 0.06f, 1.0f }); } @@ -256,10 +256,10 @@ void Dissolve::OnRender(const Timestep frameTime) //DrawGeometry(); - bool submitted = aether.Submit(*m_ParticleSystemInstances[0]); + bool submitted = aether.Submit(m_ParticleSystemInstances[0]); EE_CORE_ASSERT(submitted, "The particle system instance was submitted more than once.") - submitted = aether.Submit(*m_ParticleSystemInstances[1]); + submitted = aether.Submit(m_ParticleSystemInstances[1]); EE_CORE_ASSERT(submitted, "The particle system instance was submitted more than once.") aether.Render(m_CameraController->GetCamera()); diff --git a/Elixir/Source/Engine/Aether/Manager.cpp b/Elixir/Source/Engine/Aether/Manager.cpp index e92a2911..9818e125 100644 --- a/Elixir/Source/Engine/Aether/Manager.cpp +++ b/Elixir/Source/Engine/Aether/Manager.cpp @@ -35,7 +35,7 @@ namespace Elixir::Aether return CreateRef(system.Compile(m_MaterialSystem)); } - SystemInstance& Manager::CreateInstance(Ref system) + SSystemInstanceHandle Manager::CreateInstance(Ref system) { EE_CORE_ASSERT(system, "Aether system instance requires a compiled system.") @@ -45,12 +45,24 @@ namespace Elixir::Aether const auto [_, inserted] = m_Instances.emplace(id, std::move(instance)); EE_CORE_ASSERT(inserted, "Aether system instance UUID must be unique.") - return *m_Instances[id]; + return { id }; } - bool Manager::DestroyInstance(const UUID& instanceId) + SystemInstance* Manager::FindInstance(const SSystemInstanceHandle& handle) { - const auto found = m_Instances.find(instanceId); + const auto found = m_Instances.find(handle.Id); + return found != m_Instances.end() ? found->second.get() : nullptr; + } + + const SystemInstance* Manager::FindInstance(const SSystemInstanceHandle& handle) const + { + const auto found = m_Instances.find(handle.Id); + return found != m_Instances.end() ? found->second.get() : nullptr; + } + + bool Manager::DestroyInstance(const SSystemInstanceHandle& handle) + { + const auto found = m_Instances.find(handle.Id); if (found == m_Instances.end()) return false; const auto& instance = *found->second; @@ -69,9 +81,12 @@ namespace Elixir::Aether GetRenderer().Update(timestep); } - bool Manager::Submit(const SystemInstance& instance) + bool Manager::Submit(const SSystemInstanceHandle& handle) { - return m_FrameSubmission.Submit(instance); + const auto* instance = FindInstance(handle); + if (!instance) return false; + + return m_FrameSubmission.Submit(*instance); } void Manager::Render(const Camera& camera) diff --git a/Elixir/Source/Engine/Aether/Manager.h b/Elixir/Source/Engine/Aether/Manager.h index 28926f8e..94145861 100644 --- a/Elixir/Source/Engine/Aether/Manager.h +++ b/Elixir/Source/Engine/Aether/Manager.h @@ -3,6 +3,7 @@ #include #include #include +#include namespace Elixir { @@ -45,18 +46,21 @@ namespace Elixir::Aether Ref Compile(System& system) const; - // The manager owns the returned instance. It remains valid until - // DestroyInstance() is called or the manager is destroyed. - SystemInstance& CreateInstance(Ref system); + SSystemInstanceHandle CreateInstance(Ref system); + + // The returned pointer is a temporary borrow. Do not store it across + // DestroyInstance() or use it from another thread. + SystemInstance* FindInstance(const SSystemInstanceHandle& handle); + const SystemInstance* FindInstance(const SSystemInstanceHandle& handle) const; // Must be called from the render-frame callback. It detaches any // frame submission before the renderer retires GPU allocations. - bool DestroyInstance(const UUID& instanceId); + bool DestroyInstance(const SSystemInstanceHandle& handle); // Render-frame API. Submit() is valid only between BeginFrame() and Render(). void BeginFrame(const Timestep& timestep); - bool Submit(const SystemInstance& instance); + bool Submit(const SSystemInstanceHandle& handle); void Render(const Camera& camera); diff --git a/Elixir/Source/Engine/Aether/SystemInstanceHandle.h b/Elixir/Source/Engine/Aether/SystemInstanceHandle.h new file mode 100644 index 00000000..f503365c --- /dev/null +++ b/Elixir/Source/Engine/Aether/SystemInstanceHandle.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +namespace Elixir::Aether +{ + struct SSystemInstanceHandle + { + UUID Id; + + bool operator==(const SSystemInstanceHandle&) const = default; + }; +} \ No newline at end of file From 2edfb3fab435569a23b8ef61a384c8ad53198db1 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Thu, 6 Aug 2026 01:23:54 -0300 Subject: [PATCH 39/89] refactor(aether): expose system instances Replace public system instance handles with shared runtime instances. Manager retains registration and renderer lifetime control while callers configure and submit SystemInstance directly. --- Dissolve/Source/Dissolve.cpp | 2 +- Elixir/Source/Engine/Aether/Manager.cpp | 42 ++++++++----------- Elixir/Source/Engine/Aether/Manager.h | 18 ++++---- .../Engine/Aether/SystemInstanceHandle.h | 13 ------ 4 files changed, 27 insertions(+), 48 deletions(-) delete mode 100644 Elixir/Source/Engine/Aether/SystemInstanceHandle.h diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index 09b00456..b9257fdc 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -11,7 +11,7 @@ Ref pipeline; std::array, 2> m_ParticleSystems; -std::array m_ParticleSystemInstances; +std::array, 2> m_ParticleSystemInstances; Ref graphMaterial; diff --git a/Elixir/Source/Engine/Aether/Manager.cpp b/Elixir/Source/Engine/Aether/Manager.cpp index 9818e125..acd80b2d 100644 --- a/Elixir/Source/Engine/Aether/Manager.cpp +++ b/Elixir/Source/Engine/Aether/Manager.cpp @@ -35,39 +35,28 @@ namespace Elixir::Aether return CreateRef(system.Compile(m_MaterialSystem)); } - SSystemInstanceHandle Manager::CreateInstance(Ref system) + Ref Manager::CreateInstance(Ref system) { EE_CORE_ASSERT(system, "Aether system instance requires a compiled system.") - auto instance = CreateScope(std::move(system)); + auto instance = CreateRef(std::move(system)); const auto id = instance->GetId(); - const auto [_, inserted] = m_Instances.emplace(id, std::move(instance)); + const auto [_, inserted] = m_Instances.emplace(id, instance); EE_CORE_ASSERT(inserted, "Aether system instance UUID must be unique.") - return { id }; + return instance; } - SystemInstance* Manager::FindInstance(const SSystemInstanceHandle& handle) + bool Manager::DestroyInstance(const Ref& instance) { - const auto found = m_Instances.find(handle.Id); - return found != m_Instances.end() ? found->second.get() : nullptr; - } - - const SystemInstance* Manager::FindInstance(const SSystemInstanceHandle& handle) const - { - const auto found = m_Instances.find(handle.Id); - return found != m_Instances.end() ? found->second.get() : nullptr; - } + if (!IsManagedInstance(instance)) return false; - bool Manager::DestroyInstance(const SSystemInstanceHandle& handle) - { - const auto found = m_Instances.find(handle.Id); + const auto found = m_Instances.find(instance->GetId()); if (found == m_Instances.end()) return false; - const auto& instance = *found->second; - m_FrameSubmission.Remove(instance); - GetRenderer().Retire(instance); + m_FrameSubmission.Remove(*instance); + GetRenderer().Retire(*instance); m_Instances.erase(found); return true; @@ -81,11 +70,9 @@ namespace Elixir::Aether GetRenderer().Update(timestep); } - bool Manager::Submit(const SSystemInstanceHandle& handle) + bool Manager::Submit(const Ref& instance) { - const auto* instance = FindInstance(handle); - if (!instance) return false; - + if (!IsManagedInstance(instance)) return false; return m_FrameSubmission.Submit(*instance); } @@ -104,4 +91,11 @@ namespace Elixir::Aether EE_CORE_ASSERT(m_Renderer, "Aether renderer is unavailable.") return *m_Renderer; } + + bool Manager::IsManagedInstance(const Ref& instance) const + { + if (!instance) return false; + const auto found = m_Instances.find(instance->GetId()); + return found != m_Instances.end() && found->second.get() == instance.get(); + } } diff --git a/Elixir/Source/Engine/Aether/Manager.h b/Elixir/Source/Engine/Aether/Manager.h index 94145861..d431d00d 100644 --- a/Elixir/Source/Engine/Aether/Manager.h +++ b/Elixir/Source/Engine/Aether/Manager.h @@ -3,7 +3,6 @@ #include #include #include -#include namespace Elixir { @@ -46,21 +45,18 @@ namespace Elixir::Aether Ref Compile(System& system) const; - SSystemInstanceHandle CreateInstance(Ref system); - - // The returned pointer is a temporary borrow. Do not store it across - // DestroyInstance() or use it from another thread. - SystemInstance* FindInstance(const SSystemInstanceHandle& handle); - const SystemInstance* FindInstance(const SSystemInstanceHandle& handle) const; + // The returned runtime instance is configured through its public API. + // Manager retains registration and GPU lifetime ownership. + Ref CreateInstance(Ref system); // Must be called from the render-frame callback. It detaches any // frame submission before the renderer retires GPU allocations. - bool DestroyInstance(const SSystemInstanceHandle& handle); + bool DestroyInstance(const Ref& instance); // Render-frame API. Submit() is valid only between BeginFrame() and Render(). void BeginFrame(const Timestep& timestep); - bool Submit(const SSystemInstanceHandle& handle); + bool Submit(const Ref& instance); void Render(const Camera& camera); @@ -69,10 +65,12 @@ namespace Elixir::Aether private: Renderer& GetRenderer() const; + bool IsManagedInstance(const Ref& instance) const; + EffectMaterialResolver m_EffectMaterials; MaterialSystem& m_MaterialSystem; - std::unordered_map> m_Instances; + std::unordered_map> m_Instances; FrameSubmission m_FrameSubmission; Scope m_Renderer; diff --git a/Elixir/Source/Engine/Aether/SystemInstanceHandle.h b/Elixir/Source/Engine/Aether/SystemInstanceHandle.h deleted file mode 100644 index f503365c..00000000 --- a/Elixir/Source/Engine/Aether/SystemInstanceHandle.h +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once - -#include - -namespace Elixir::Aether -{ - struct SSystemInstanceHandle - { - UUID Id; - - bool operator==(const SSystemInstanceHandle&) const = default; - }; -} \ No newline at end of file From 391b03d6197ef41d6afc70c33b886df16691637f Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Thu, 6 Aug 2026 01:29:36 -0300 Subject: [PATCH 40/89] fix(vulkan): trim GPU renderer name Format Vulkan's fixed-size device name as a string view. Avoid logging its null padding as box glyphs on macOS. --- Elixir/Source/Graphics/Vulkan/VulkanGraphicsContext.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Elixir/Source/Graphics/Vulkan/VulkanGraphicsContext.cpp b/Elixir/Source/Graphics/Vulkan/VulkanGraphicsContext.cpp index 8402761f..e1e5ec81 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; From f811075932ee558f36a882a4aa0b4a3be839391d Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Thu, 6 Aug 2026 17:29:49 -0300 Subject: [PATCH 41/89] refactor(aether): snapshot system instance state Publish immutable SystemInstance state for renderer consumption. Parameter override changes now create copy-on-write snapshots guarded by per-instance synchronization. --- Elixir/Source/Engine/Aether/Renderer.cpp | 70 ++++--- Elixir/Source/Engine/Aether/Renderer.h | 14 +- .../Source/Engine/Aether/SystemInstance.cpp | 197 +++++++++++++----- Elixir/Source/Engine/Aether/SystemInstance.h | 62 ++++-- .../Engine/Aether/SystemInstanceTest.cpp | 38 ++-- 5 files changed, 261 insertions(+), 120 deletions(-) diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index e39b5c68..9f1a4d2c 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -114,11 +114,11 @@ namespace Elixir::Aether glm::mat4 GetParticleRenderTransform( const SCompiledEmitter& emitter, - const SystemInstance& instance + const SystemInstanceSnapshot& snapshot ) { return emitter.SimulationSpace == EParticleSimulationSpace::Local - ? instance.GetWorldTransform() + ? snapshot.GetWorldTransform() : glm::mat4{ 1.0f }; } @@ -198,14 +198,15 @@ namespace Elixir::Aether for (const auto* instance : instances) { - const auto& system = instance->GetCompiledSystem(); + const auto snapshot = instance->CaptureSnapshot(); + const auto& system = snapshot->GetCompiledSystem(); m_LastSubmissionMetrics.RequestedEmitterCount += system.Emitters.size(); m_LastSubmissionMetrics.RequestedParticleCapacity += system.TotalMaxParticles; if (!IsParticleStateLayoutSupported(system.ParticleStateLayout)) { - if (m_UnsupportedParticleStateLayoutInstances.insert(instance->GetId()).second) + if (m_UnsupportedParticleStateLayoutInstances.insert(snapshot->GetId()).second) { EE_CORE_ERROR( "Aether does not support particle state layout '{}' for system instance '{}'.", @@ -217,12 +218,12 @@ namespace Elixir::Aether continue; } - m_UnsupportedParticleStateLayoutInstances.erase(instance->GetId()); + m_UnsupportedParticleStateLayoutInstances.erase(snapshot->GetId()); - auto* record = ResolveInstanceRecord(*instance); + auto* record = ResolveInstanceRecord(*snapshot); if (!record) continue; - UpdateBuffers(*instance, *record); + UpdateBuffers(*snapshot, *record); const auto emitterCount = record->Allocation.Emitters.Count; const auto particleCount = record->Allocation.Particles.Count; @@ -232,7 +233,7 @@ namespace Elixir::Aether m_LastSubmissionMetrics.SubmittedParticleCapacity += particleCount; submittedInstances.push_back({ - .Instance = instance, + .Snapshot = snapshot, .Allocation = record->Allocation, .ParticleStateLayout = system.ParticleStateLayout, }); @@ -768,10 +769,12 @@ namespace Elixir::Aether m_GraphicsContext->EnqueueSecondaryCommandBuffer(cmd); } - Renderer::SInstanceRecord* Renderer::ResolveInstanceRecord(const SystemInstance& instance) + Renderer::SInstanceRecord* Renderer::ResolveInstanceRecord( + const SystemInstanceSnapshot& snapshot + ) { - const auto instanceRevision = instance.GetRevision(); - const auto found = m_InstanceRecords.find(instance.GetId()); + const auto instanceRevision = snapshot.GetRevision(); + const auto found = m_InstanceRecords.find(snapshot.GetId()); if (found != m_InstanceRecords.end() && found->second.SystemInstanceRevision == instanceRevision) @@ -779,12 +782,12 @@ namespace Elixir::Aether return &found->second; } - const auto& system = instance.GetCompiledSystem(); + const auto& system = snapshot.GetCompiledSystem(); const auto replacementAllocation = m_ParticleResourcePool.Allocate(system); if (!replacementAllocation) { - if (m_AllocationFailures.insert(instance.GetId()).second) + if (m_AllocationFailures.insert(snapshot.GetId()).second) { EE_CORE_ERROR( "Aether GPU resource pool exhausted while creating system instance '{}'.", @@ -796,23 +799,23 @@ namespace Elixir::Aether } ClearParticleAllocation(*replacementAllocation); - UploadCompiledSystem(instance, *replacementAllocation); + UploadCompiledSystem(snapshot, *replacementAllocation); const SInstanceRecord replacement{ - .SystemInstanceId = instance.GetId(), + .SystemInstanceId = snapshot.GetId(), .SystemInstanceRevision = instanceRevision, .CompiledSystemId = system.SourceId, .CompilationRevision = system.CompilationRevision, - .ParameterRevision = instance.GetParameterRevision(), + .ParameterRevision = snapshot.GetParameterRevision(), .Allocation = *replacementAllocation, }; if (found == m_InstanceRecords.end()) { - const auto [it, inserted] = m_InstanceRecords.emplace(instance.GetId(), replacement); + const auto [it, inserted] = m_InstanceRecords.emplace(snapshot.GetId(), replacement); EE_CORE_ASSERT(inserted, "Aether system instance registry insertion failed.") - m_AllocationFailures.erase(instance.GetId()); + m_AllocationFailures.erase(snapshot.GetId()); return &it->second; } @@ -820,16 +823,16 @@ namespace Elixir::Aether // previous record. If allocation fails, the old record remains intact. QueueRetirement(found->second.Allocation); found->second = replacement; - m_AllocationFailures.erase(instance.GetId()); + m_AllocationFailures.erase(snapshot.GetId()); return &found->second; } void Renderer::UploadCompiledSystem( - const SystemInstance& instance, + const SystemInstanceSnapshot& snapshot, const SSystemInstanceAllocation& allocation ) const { - const auto& system = instance.GetCompiledSystem(); + const auto& system = snapshot.GetCompiledSystem(); auto* emitters = (SEmitterData*)m_EmitterBuffer->Map(); for (uint32_t i = 0; i < allocation.Emitters.Count; ++i) @@ -850,7 +853,7 @@ namespace Elixir::Aether ); } - UploadInstanceParameters(instance, allocation); + UploadInstanceParameters(snapshot, allocation); auto* targets = (STriggerTargetData*)m_TriggerTargetBuffer->Map(); for (uint32_t i = 0; i < allocation.TriggerTargets.Count; ++i) @@ -859,14 +862,14 @@ namespace Elixir::Aether } void Renderer::UploadInstanceParameters( - const SystemInstance& instance, + const SystemInstanceSnapshot& snapshot, 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); + snapshot.ResolveParameterValue(i); } void Renderer::QueueRetirement(SSystemInstanceAllocation allocation) @@ -888,12 +891,15 @@ namespace Elixir::Aether retirements.clear(); } - void Renderer::UpdateBuffers(SystemInstance const& instance, SInstanceRecord& record) + void Renderer::UpdateBuffers( + const SystemInstanceSnapshot& snapshot, + SInstanceRecord& record + ) { - if (record.ParameterRevision != instance.GetParameterRevision()) + if (record.ParameterRevision != snapshot.GetParameterRevision()) { - UploadInstanceParameters(instance, record.Allocation); - record.ParameterRevision = instance.GetParameterRevision(); + UploadInstanceParameters(snapshot, record.Allocation); + record.ParameterRevision = snapshot.GetParameterRevision(); } const SParamsData params{ @@ -907,7 +913,7 @@ namespace Elixir::Aether }; m_ParamsBuffer->UpdateData(¶ms, sizeof(SParamsData)); - const auto& system = instance.GetCompiledSystem(); + const auto& system = snapshot.GetCompiledSystem(); const SSystemInstanceData instanceData { @@ -1082,7 +1088,7 @@ namespace Elixir::Aether for (const auto& instance : instances) { - const auto& emitters = instance.Instance->GetCompiledSystem().Emitters; + const auto& emitters = instance.Snapshot->GetCompiledSystem().Emitters; const auto geometry = getGeometry(instance.ParticleStateLayout); for (uint32_t emitterIndex = 0; emitterIndex < emitters.size(); ++emitterIndex) @@ -1092,7 +1098,7 @@ namespace Elixir::Aether const auto worldTransform = GetParticleRenderTransform( emitter, - *instance.Instance + *instance.Snapshot ); switch (emitter.RenderMode) @@ -1259,7 +1265,7 @@ namespace Elixir::Aether for (const auto* instance : batch.Instances) { - const auto& system = instance->Instance->GetCompiledSystem(); + const auto& system = instance->Snapshot->GetCompiledSystem(); const auto emitterCount = instance->Allocation.Emitters.Count; m_LastSubmissionMetrics.ScheduledEmitterCount += emitterCount; diff --git a/Elixir/Source/Engine/Aether/Renderer.h b/Elixir/Source/Engine/Aether/Renderer.h index 0bcfe8fd..c81f86c3 100644 --- a/Elixir/Source/Engine/Aether/Renderer.h +++ b/Elixir/Source/Engine/Aether/Renderer.h @@ -212,11 +212,11 @@ namespace Elixir::Aether SSystemInstanceAllocation Allocation; }; - // Frame-local, renderer-owned snapshot. It decouples batch execution - // from m_InstanceRecords and remains valid for the complete Render(). + // Frame-local, renderer-owned snapshot paired with an immutable + // SystemInstance state captured at the start of Render(). struct SSubmittedSystemInstance { - const SystemInstance* Instance = nullptr; + Ref Snapshot; SSystemInstanceAllocation Allocation; EParticleStateLayout ParticleStateLayout = EParticleStateLayout::CoreV1; }; @@ -227,20 +227,20 @@ namespace Elixir::Aether std::vector Instances; }; - SInstanceRecord* ResolveInstanceRecord(const SystemInstance& instance); + SInstanceRecord* ResolveInstanceRecord(const SystemInstanceSnapshot& snapshot); void UploadCompiledSystem( - const SystemInstance& instance, + const SystemInstanceSnapshot& snapshot, const SSystemInstanceAllocation& allocation ) const; void UploadInstanceParameters( - const SystemInstance& instance, + const SystemInstanceSnapshot& snapshot, const SSystemInstanceAllocation& allocation ) const; void QueueRetirement(SSystemInstanceAllocation allocation); void ProcessCompletedRetirements(); - void UpdateBuffers(SystemInstance const& instance, SInstanceRecord& record); + void UpdateBuffers(const SystemInstanceSnapshot& snapshot, SInstanceRecord& record); SParticleStateLayoutRuntime* FindParticleStateLayoutRuntime(EParticleStateLayout layout); const SParticleStateLayoutRuntime* FindParticleStateLayoutRuntime(EParticleStateLayout layout) const; diff --git a/Elixir/Source/Engine/Aether/SystemInstance.cpp b/Elixir/Source/Engine/Aether/SystemInstance.cpp index 3b21773f..a0e0c114 100644 --- a/Elixir/Source/Engine/Aether/SystemInstance.cpp +++ b/Elixir/Source/Engine/Aether/SystemInstance.cpp @@ -3,99 +3,194 @@ namespace Elixir::Aether { - SystemInstance::SystemInstance(Ref compiledSystem) - : m_CompiledSystem(std::move(compiledSystem)) + /* SystemInstanceSnapshot */ + + SystemInstanceSnapshot::SystemInstanceSnapshot( + UUID id, + const uint32_t revision, + const uint32_t parameterRevision, + Ref system, + const glm::mat4& worldTransform, + Ref overrides + ) : m_Id(id), + m_Revision(revision), + m_ParameterRevision(parameterRevision), + m_CompiledSystem(std::move(system)), + m_WorldTransform(worldTransform), + m_ParameterOverrides(std::move(overrides)) {} + + glm::vec4 SystemInstanceSnapshot::ResolveParameterValue(uint32_t parameterIndex) const { - EE_CORE_ASSERT(m_CompiledSystem, "SystemInstance requires a compiled system.") + EE_CORE_ASSERT( + parameterIndex < m_CompiledSystem->Parameters.size(), + "Aether parameter index is outside the compiled system parameter table." + ) + + 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; } - void SystemInstance::SetCompiledSystem(Ref compiledSystem) + /* SystemInstance */ + + SystemInstance::SystemInstance(Ref system) { - EE_CORE_ASSERT(compiledSystem, "SystemInstance requires a compiled system.") + EE_CORE_ASSERT(system, "SystemInstance requires a compiled system.") + m_Snapshot = CreateSnapshot( + m_Id, + 1, + 1, + std::move(system), + glm::mat4{ 1.0f }, + CreateRef() + ); + } - if (m_CompiledSystem == compiledSystem) - return; + void SystemInstance::SetCompiledSystem(Ref system) + { + EE_CORE_ASSERT(system, "SystemInstance requires a compiled system.") + const std::scoped_lock lock(m_SnapshotMutex); - m_CompiledSystem = std::move(compiledSystem); + if (m_Snapshot->m_CompiledSystem == system) + return; - bool removedOverrides = false; + auto overrides = CreateRef( + *m_Snapshot->m_ParameterOverrides + ); - for (auto it = m_ParameterOverrides.begin(); it != m_ParameterOverrides.end();) + const auto removed = std::erase_if(*overrides, [&system](const auto& entry) { - if (IsExposedParameter(it->first)) - { - ++it; - continue; - } - it = m_ParameterOverrides.erase(it); - removedOverrides = true; - } - - ++m_Revision; - - if (removedOverrides) - ++m_ParameterRevision; + return !IsExposedParameter(*system, entry.first); + }); + + m_Snapshot = CreateSnapshot( + m_Id, + m_Snapshot->m_Revision + 1, + m_Snapshot->m_ParameterRevision + (removed > 0 ? 1 : 0), + std::move(system), + m_Snapshot->m_WorldTransform, + std::move(overrides) + ); } void SystemInstance::SetWorldTransform(const glm::mat4& worldTransform) { - m_WorldTransform = worldTransform; + const std::scoped_lock lock(m_SnapshotMutex); + + m_Snapshot = CreateSnapshot( + m_Id, + m_Snapshot->m_Revision, + m_Snapshot->m_ParameterRevision, + m_Snapshot->m_CompiledSystem, + worldTransform, + m_Snapshot->m_ParameterOverrides + ); } - bool SystemInstance::SetParameterOverride(std::string name, const glm::vec4& value) + bool SystemInstance::SetParameterOverride( + const std::string& name, + const glm::vec4& value + ) { - if (!IsExposedParameter(name)) + const std::scoped_lock lock(m_SnapshotMutex); + + if (!IsExposedParameter(*m_Snapshot->m_CompiledSystem, name)) return false; - m_ParameterOverrides.insert_or_assign(std::move(name), value); - ++m_ParameterRevision; + auto overrides = CreateRef( + *m_Snapshot->m_ParameterOverrides + ); + + overrides->insert_or_assign(std::string(name), value); + m_Snapshot = CreateSnapshot( + m_Id, + m_Snapshot->m_Revision, + m_Snapshot->m_ParameterRevision + 1, + m_Snapshot->m_CompiledSystem, + m_Snapshot->m_WorldTransform, + std::move(overrides) + ); return true; } bool SystemInstance::ClearParameterOverride(const std::string& name) { - const auto found = m_ParameterOverrides.find(name); - if (found == m_ParameterOverrides.end()) + const std::scoped_lock lock(m_SnapshotMutex); + + const auto found = m_Snapshot->m_ParameterOverrides->find(name); + if (found == m_Snapshot->m_ParameterOverrides->end()) return false; - m_ParameterOverrides.erase(found); - ++m_ParameterRevision; + auto overrides = CreateRef( + *m_Snapshot->m_ParameterOverrides + ); + + overrides->erase(found->first); + + m_Snapshot = CreateSnapshot( + m_Id, + m_Snapshot->m_Revision, + m_Snapshot->m_ParameterRevision + 1, + m_Snapshot->m_CompiledSystem, + m_Snapshot->m_WorldTransform, + std::move(overrides) + ); return true; } void SystemInstance::ClearParameterOverrides() { - if (m_ParameterOverrides.empty()) + const std::scoped_lock lock(m_SnapshotMutex); + + if (m_Snapshot->m_ParameterOverrides->empty()) return; - m_ParameterOverrides.clear(); - ++m_ParameterRevision; + m_Snapshot = CreateSnapshot( + m_Id, + m_Snapshot->m_Revision, + m_Snapshot->m_ParameterRevision + 1, + m_Snapshot->m_CompiledSystem, + m_Snapshot->m_WorldTransform, + CreateRef() + ); } - glm::vec4 SystemInstance::ResolveParameterValue(uint32_t parameterIndex) const + Ref SystemInstance::CaptureSnapshot() const { - EE_CORE_ASSERT( - parameterIndex < m_CompiledSystem->Parameters.size(), - "Aether parameter index is outside the compiled system parameter table." - ) - - if (parameterIndex >= m_CompiledSystem->Parameters.size()) - return {}; - - const auto& parameter = m_CompiledSystem->Parameters[parameterIndex]; - const auto found = m_ParameterOverrides.find(parameter.Name); + const std::scoped_lock lock(m_SnapshotMutex); + return m_Snapshot; + } - return found != m_ParameterOverrides.end() - ? found->second - : parameter.Value; + Ref SystemInstance::CreateSnapshot( + const UUID& id, + uint32_t revision, + uint32_t parameterRevision, + Ref system, + const glm::mat4& worldTransform, + Ref overrides + ) + { + return CreateRef( + id, + revision, + parameterRevision, + std::move(system), + worldTransform, + std::move(overrides) + ); } - bool SystemInstance::IsExposedParameter(const std::string& name) const + bool SystemInstance::IsExposedParameter(const SCompiledSystem& system, std::string_view name) { return std::ranges::any_of( - m_CompiledSystem->ExposedParameters, + system.ExposedParameters, [&name](const SExposedParameter& parameter) { return parameter.Name == name; diff --git a/Elixir/Source/Engine/Aether/SystemInstance.h b/Elixir/Source/Engine/Aether/SystemInstance.h index 2342a15e..845edd24 100644 --- a/Elixir/Source/Engine/Aether/SystemInstance.h +++ b/Elixir/Source/Engine/Aether/SystemInstance.h @@ -4,6 +4,40 @@ namespace Elixir::Aether { + using ParameterOverridesMap = std::unordered_map; + + // Immutable runtime state captured by one rendering frame. + class ELIXIR_API SystemInstanceSnapshot final + { + friend class SystemInstance; + + public: + SystemInstanceSnapshot( + UUID id, + uint32_t revision, + uint32_t parameterRevision, + Ref system, + const glm::mat4& worldTransform, + Ref overrides + ); + + glm::vec4 ResolveParameterValue(uint32_t parameterIndex) const; + + const UUID& GetId() const { return m_Id; } + uint32_t GetRevision() const { return m_Revision; } + uint32_t GetParameterRevision() const { return m_ParameterRevision; } + const SCompiledSystem& GetCompiledSystem() const { return *m_CompiledSystem; } + const glm::mat4& GetWorldTransform() const { return m_WorldTransform; } + + private: + UUID m_Id; + uint32_t m_Revision = 1; + uint32_t m_ParameterRevision = 1; + Ref m_CompiledSystem; + glm::mat4 m_WorldTransform{ 1.0f }; + Ref m_ParameterOverrides; + }; + // Runtime identity and immutable compiled payload selection. // GPU allocations belong to Renderer::ParticleResourcePool, never here. class ELIXIR_API SystemInstance final @@ -17,30 +51,32 @@ namespace Elixir::Aether const UUID& GetId() const { return m_Id; } - uint32_t GetRevision() const { return m_Revision; } - uint32_t GetParameterRevision() const { return m_ParameterRevision; } - - const SCompiledSystem& GetCompiledSystem() const { return *m_CompiledSystem; } void SetCompiledSystem(Ref compiledSystem); - const glm::mat4& GetWorldTransform() const { return m_WorldTransform; } void SetWorldTransform(const glm::mat4& worldTransform); - bool SetParameterOverride(std::string name, const glm::vec4& value); + bool SetParameterOverride(const std::string& name, const glm::vec4& value); bool ClearParameterOverride(const std::string& name); void ClearParameterOverrides(); - glm::vec4 ResolveParameterValue(uint32_t parameterIndex) const; + // Acquire one immutable view without retaining the instance lock. + Ref CaptureSnapshot() const; private: - bool IsExposedParameter(const std::string& name) const; + static Ref CreateSnapshot( + const UUID& id, + uint32_t revision, + uint32_t parameterRevision, + Ref system, + const glm::mat4& worldTransform, + Ref overrides + ); + + static bool IsExposedParameter(const SCompiledSystem& system, std::string_view name); UUID m_Id; - uint32_t m_Revision = 1; - uint32_t m_ParameterRevision = 1; - Ref m_CompiledSystem; - glm::mat4 m_WorldTransform{ 1.0f }; - std::unordered_map m_ParameterOverrides; + mutable std::mutex m_SnapshotMutex; + Ref m_Snapshot; }; } diff --git a/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp b/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp index dd467994..6dbd228a 100644 --- a/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp @@ -30,23 +30,23 @@ TEST(AetherSystemInstanceTest, ReplacesCompiledSystemAndIncrementsRevision) const auto replacementSystem = CreateRef(); SystemInstance instance{ initialSystem }; - const auto initialRevision = instance.GetRevision(); + const auto initialRevision = instance.CaptureSnapshot()->GetRevision(); instance.SetCompiledSystem(replacementSystem); - EXPECT_EQ(instance.GetRevision(), initialRevision + 1); - EXPECT_EQ(&instance.GetCompiledSystem(), replacementSystem.get()); + const auto snapshot = instance.CaptureSnapshot(); + + EXPECT_EQ(snapshot->GetRevision(), initialRevision + 1); + EXPECT_EQ(&snapshot->GetCompiledSystem(), replacementSystem.get()); } TEST(AetherSystemInstanceTest, DoesNotIncrementRevisionForSameCompiledSystem) { const auto compiledSystem = CreateRef(); SystemInstance instance{ compiledSystem }; - - const auto initialRevision = instance.GetRevision(); instance.SetCompiledSystem(compiledSystem); - EXPECT_EQ(instance.GetRevision(), initialRevision); + EXPECT_EQ(instance.CaptureSnapshot()->GetRevision(), 1); } TEST(AetherSystemInstanceTest, AppliesOverridesOnlyToExposedParameters) @@ -54,19 +54,21 @@ TEST(AetherSystemInstanceTest, AppliesOverridesOnlyToExposedParameters) const auto compiledSystem = MakeCompiledSystem(); SystemInstance instance{ compiledSystem }; - const auto initialParameterRevision = instance.GetParameterRevision(); + const auto initialParameterRevision = instance.CaptureSnapshot()->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_EQ(instance.GetParameterRevision(), initialParameterRevision + 1); - const auto tint = instance.ResolveParameterValue(0); + const auto snapshot = instance.CaptureSnapshot(); + EXPECT_EQ(snapshot->GetParameterRevision(), initialParameterRevision + 1); + + const auto tint = snapshot->ResolveParameterValue(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->ResolveParameterValue(1); EXPECT_FLOAT_EQ(colorChunk.x, 0.0f); EXPECT_FLOAT_EQ(colorChunk.y, 0.5f); EXPECT_FLOAT_EQ(colorChunk.z, 1.0f); @@ -82,7 +84,7 @@ TEST(AetherSystemInstanceTest, ClearsOverridesAndRestoresCompiledDefaults) ASSERT_TRUE(instance.ClearParameterOverride("Tint")); EXPECT_FALSE(instance.ClearParameterOverride("Tint")); - const auto tint = instance.ResolveParameterValue(0); + const auto tint = instance.CaptureSnapshot()->ResolveParameterValue(0); EXPECT_FLOAT_EQ(tint.x, 1.0f); EXPECT_FLOAT_EQ(tint.y, 1.0f); EXPECT_FLOAT_EQ(tint.z, 1.0f); @@ -106,7 +108,7 @@ TEST(AetherSystemInstanceTest, RetainsOnlyOverridesExposedByReplacementSystem) instance.SetCompiledSystem(replacementSystem); - const auto tint = instance.ResolveParameterValue(0); + const auto tint = instance.CaptureSnapshot()->ResolveParameterValue(0); EXPECT_FLOAT_EQ(tint.x, 0.25f); EXPECT_FLOAT_EQ(tint.y, 0.5f); EXPECT_FLOAT_EQ(tint.z, 0.75f); @@ -123,8 +125,10 @@ TEST(AetherSystemInstanceTest, StoresWorldTransformWithoutChangingCompiledSystem 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 = instance.CaptureSnapshot(); + + 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.get()); +} From d695c6e4f57925d04fbb2d5139798687d79ecc00 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Thu, 6 Aug 2026 18:18:23 -0300 Subject: [PATCH 42/89] refactor(aether): snapshot frame submissions Capture immutable system instance state when a frame is submitted. The renderer now consumes frame-owned snapshots instead of runtime instance pointers. --- Elixir/Source/Engine/Aether/FrameSubmission.h | 29 +++++++++++-------- Elixir/Source/Engine/Aether/Manager.cpp | 4 +-- Elixir/Source/Engine/Aether/Renderer.cpp | 9 +++--- Elixir/Source/Engine/Aether/SystemInstance.h | 6 ++-- .../Engine/Aether/FrameSubmissionTest.cpp | 22 ++++++++++++-- .../Engine/Aether/SystemInstanceTest.cpp | 24 ++++++++++----- 6 files changed, 63 insertions(+), 31 deletions(-) diff --git a/Elixir/Source/Engine/Aether/FrameSubmission.h b/Elixir/Source/Engine/Aether/FrameSubmission.h index 7ddc1f22..faf7fbe7 100644 --- a/Elixir/Source/Engine/Aether/FrameSubmission.h +++ b/Elixir/Source/Engine/Aether/FrameSubmission.h @@ -7,9 +7,9 @@ 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. + // Immutable system instance states selected for one rendering frame. + // Submit() captures the state exactly once; an instance can be selected + // at most once per frame. class ELIXIR_API FrameSubmission final { public: @@ -18,36 +18,41 @@ namespace Elixir::Aether const auto [_, inserted] = m_InstanceIds.insert(instance.GetId()); if (!inserted) return false; - m_Instances.push_back(&instance); + m_Snapshots.push_back(instance.CaptureSnapshot()); return true; } - // Releases a non-owning reference before its manager-owned instance - // is retired and destroyed. + // Drop a captured state before the manager retires its GPU allocation. bool Remove(const SystemInstance& instance) { const auto found = m_InstanceIds.find(instance.GetId()); if (found == m_InstanceIds.end()) return false; - std::erase(m_Instances, &instance); + std::erase_if(m_Snapshots, [&instance](const auto& snapshot) + { + return snapshot->GetId() == instance.GetId(); + }); m_InstanceIds.erase(found); return true; } void Reset() { - m_Instances.clear(); + m_Snapshots.clear(); m_InstanceIds.clear(); } - bool IsEmpty() const { return m_Instances.empty(); } + bool IsEmpty() const { return m_Snapshots.empty(); } - size_t GetInstanceCount() const { return m_Instances.size(); } + size_t GetInstanceCount() const { return m_Snapshots.size(); } - const std::vector& GetInstances() const { return m_Instances; } + const std::vector>& GetSnapshots() const + { + return m_Snapshots; + } private: - std::vector m_Instances; + std::vector> m_Snapshots; 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 index acd80b2d..66828818 100644 --- a/Elixir/Source/Engine/Aether/Manager.cpp +++ b/Elixir/Source/Engine/Aether/Manager.cpp @@ -64,8 +64,8 @@ namespace Elixir::Aether void Manager::BeginFrame(const Timestep& timestep) { - // Release the previous frame's non-owning references before accepting - // a new immutable submission. + // Release the previous frame's immutable states before accepting a + // new submission. m_FrameSubmission.Reset(); GetRenderer().Update(timestep); } diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index 9f1a4d2c..95584b95 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -173,13 +173,13 @@ namespace Elixir::Aether void Renderer::Render(const FrameSubmission& submission, const Camera& camera) { - const auto& instances = submission.GetInstances(); + const auto& snapshots = submission.GetSnapshots(); m_LastSubmissionMetrics = { .SubmissionSerial = ++m_SubmissionSerial, .DeltaTimeSeconds = m_LastDeltaTimeSeconds, .ElapsedTimeSeconds = m_ElapsedTimeSeconds, - .RequestedSystemInstanceCount = instances.size(), + .RequestedSystemInstanceCount = snapshots.size(), .TriggerEventCapacityPerEmitter = m_ParticlePoolLimits.TriggerEventCapacityPerEmitter, }; @@ -194,11 +194,10 @@ namespace Elixir::Aether m_FrameConstantBuffer->UpdateData(&m_FrameData, sizeof(SFrameData)); std::vector submittedInstances; - submittedInstances.reserve(instances.size()); + submittedInstances.reserve(snapshots.size()); - for (const auto* instance : instances) + for (const auto& snapshot : snapshots) { - const auto snapshot = instance->CaptureSnapshot(); const auto& system = snapshot->GetCompiledSystem(); m_LastSubmissionMetrics.RequestedEmitterCount += system.Emitters.size(); diff --git a/Elixir/Source/Engine/Aether/SystemInstance.h b/Elixir/Source/Engine/Aether/SystemInstance.h index 845edd24..dc3b3a48 100644 --- a/Elixir/Source/Engine/Aether/SystemInstance.h +++ b/Elixir/Source/Engine/Aether/SystemInstance.h @@ -42,6 +42,8 @@ namespace Elixir::Aether // GPU allocations belong to Renderer::ParticleResourcePool, never here. class ELIXIR_API SystemInstance final { + friend class FrameSubmission; + public: explicit SystemInstance(Ref compiledSystem); SystemInstance(const SystemInstance&) = delete; @@ -59,10 +61,10 @@ namespace Elixir::Aether bool ClearParameterOverride(const std::string& name); void ClearParameterOverrides(); - // Acquire one immutable view without retaining the instance lock. + private: + // Acquires one immutable view without retaining the instance lock. Ref CaptureSnapshot() const; - private: static Ref CreateSnapshot( const UUID& id, uint32_t revision, diff --git a/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp b/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp index cc103fd8..cb65d4e9 100644 --- a/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp +++ b/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp @@ -18,8 +18,8 @@ TEST(AetherFrameSubmissionTest, RetainsEachSystemInstanceAtMostOnce) EXPECT_TRUE(submission.Submit(secondInstance)); ASSERT_EQ(submission.GetInstanceCount(), 2); - EXPECT_EQ(submission.GetInstances()[0], &firstInstance); - EXPECT_EQ(submission.GetInstances()[1], &secondInstance); + EXPECT_EQ(submission.GetSnapshots()[0]->GetId(), firstInstance.GetId()); + EXPECT_EQ(submission.GetSnapshots()[1]->GetId(), secondInstance.GetId()); } TEST(AetherFrameSubmissionTest, ResetKeepsTheSubmissionReusable) @@ -51,4 +51,22 @@ TEST(AetherFrameSubmissionTest, RemovesAnInstanceBeforeItIsRetired) EXPECT_EQ(submission.GetInstanceCount(), 0); EXPECT_FALSE(submission.Remove(instance)); EXPECT_TRUE(submission.Submit(instance)); +} + +TEST(AetherFrameSubmissionTest, RetainsTheStateCapturedAtSubmission) +{ + const auto compiledSystem = CreateRef(); + SystemInstance instance{ compiledSystem }; + 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& snapshot = submission.GetSnapshots().front(); + EXPECT_FLOAT_EQ(snapshot->GetWorldTransform()[3].x, 0.0f); + EXPECT_FLOAT_EQ(snapshot->GetWorldTransform()[3].y, 0.0f); + EXPECT_FLOAT_EQ(snapshot->GetWorldTransform()[3].z, 0.0f); } \ No newline at end of file diff --git a/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp b/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp index 6dbd228a..072b291d 100644 --- a/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp @@ -1,12 +1,20 @@ #include #include +#include using namespace Elixir; using namespace Elixir::Aether; namespace { + Ref CaptureForTest(const SystemInstance& instance) + { + FrameSubmission submission; + EXPECT_TRUE(submission.Submit(instance)); + return submission.GetSnapshots().front(); + } + Ref MakeCompiledSystem() { const auto system = CreateRef(); @@ -30,11 +38,11 @@ TEST(AetherSystemInstanceTest, ReplacesCompiledSystemAndIncrementsRevision) const auto replacementSystem = CreateRef(); SystemInstance instance{ initialSystem }; - const auto initialRevision = instance.CaptureSnapshot()->GetRevision(); + const auto initialRevision = CaptureForTest(instance)->GetRevision(); instance.SetCompiledSystem(replacementSystem); - const auto snapshot = instance.CaptureSnapshot(); + const auto snapshot = CaptureForTest(instance); EXPECT_EQ(snapshot->GetRevision(), initialRevision + 1); EXPECT_EQ(&snapshot->GetCompiledSystem(), replacementSystem.get()); @@ -46,7 +54,7 @@ TEST(AetherSystemInstanceTest, DoesNotIncrementRevisionForSameCompiledSystem) SystemInstance instance{ compiledSystem }; instance.SetCompiledSystem(compiledSystem); - EXPECT_EQ(instance.CaptureSnapshot()->GetRevision(), 1); + EXPECT_EQ(CaptureForTest(instance)->GetRevision(), 1); } TEST(AetherSystemInstanceTest, AppliesOverridesOnlyToExposedParameters) @@ -54,12 +62,12 @@ TEST(AetherSystemInstanceTest, AppliesOverridesOnlyToExposedParameters) const auto compiledSystem = MakeCompiledSystem(); SystemInstance instance{ compiledSystem }; - const auto initialParameterRevision = instance.CaptureSnapshot()->GetParameterRevision(); + const auto initialParameterRevision = CaptureForTest(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 })); - const auto snapshot = instance.CaptureSnapshot(); + const auto snapshot = CaptureForTest(instance); EXPECT_EQ(snapshot->GetParameterRevision(), initialParameterRevision + 1); const auto tint = snapshot->ResolveParameterValue(0); @@ -84,7 +92,7 @@ TEST(AetherSystemInstanceTest, ClearsOverridesAndRestoresCompiledDefaults) ASSERT_TRUE(instance.ClearParameterOverride("Tint")); EXPECT_FALSE(instance.ClearParameterOverride("Tint")); - const auto tint = instance.CaptureSnapshot()->ResolveParameterValue(0); + const auto tint = CaptureForTest(instance)->ResolveParameterValue(0); EXPECT_FLOAT_EQ(tint.x, 1.0f); EXPECT_FLOAT_EQ(tint.y, 1.0f); EXPECT_FLOAT_EQ(tint.z, 1.0f); @@ -108,7 +116,7 @@ TEST(AetherSystemInstanceTest, RetainsOnlyOverridesExposedByReplacementSystem) instance.SetCompiledSystem(replacementSystem); - const auto tint = instance.CaptureSnapshot()->ResolveParameterValue(0); + const auto tint = CaptureForTest(instance)->ResolveParameterValue(0); EXPECT_FLOAT_EQ(tint.x, 0.25f); EXPECT_FLOAT_EQ(tint.y, 0.5f); EXPECT_FLOAT_EQ(tint.z, 0.75f); @@ -125,7 +133,7 @@ TEST(AetherSystemInstanceTest, StoresWorldTransformWithoutChangingCompiledSystem instance.SetWorldTransform(transform); - const auto snapshot = instance.CaptureSnapshot(); + const auto snapshot = CaptureForTest(instance); EXPECT_FLOAT_EQ(snapshot->GetWorldTransform()[3].x, 5.0f); EXPECT_FLOAT_EQ(snapshot->GetWorldTransform()[3].y, 2.0f); From 37b7279bd9dd35a3ba713a0cf6cfa8b26c0fa69a Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Thu, 6 Aug 2026 18:19:12 -0300 Subject: [PATCH 43/89] style(aether): fix material scene declaration Remove an extra space from the Renderer material scene method declaration. --- Elixir/Source/Engine/Aether/Renderer.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index 95584b95..6235d53d 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -1003,7 +1003,7 @@ namespace Elixir::Aether return batches; } - MaterialRenderScene Renderer:: BuildMaterialRenderScene( + MaterialRenderScene Renderer::BuildMaterialRenderScene( const std::vector& instances ) const { From 0e76d97f8acfb1a3fe49024712844c6178a8a1ea Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Thu, 6 Aug 2026 20:53:56 -0300 Subject: [PATCH 44/89] refactor(aether): publish frame submissions Publish sealed particle frame snapshots through a synchronized handoff. The renderer acquires immutable submissions while producers retain frame preparation ownership. --- Dissolve/Source/Dissolve.cpp | 8 ++- Elixir/Source/Engine/Aether/FrameSubmission.h | 58 +++++++++++++++++++ Elixir/Source/Engine/Aether/Manager.cpp | 25 +++++--- Elixir/Source/Engine/Aether/Manager.h | 11 +++- .../Engine/Aether/FrameSubmissionTest.cpp | 30 ++++++++++ 5 files changed, 119 insertions(+), 13 deletions(-) diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index b9257fdc..a9c2aefa 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -256,12 +256,16 @@ void Dissolve::OnRender(const Timestep frameTime) //DrawGeometry(); - bool submitted = aether.Submit(m_ParticleSystemInstances[0]); + const auto submission = aether.CreateFrameSubmission(); + + bool submitted = aether.Submit(*submission, m_ParticleSystemInstances[0]); EE_CORE_ASSERT(submitted, "The particle system instance was submitted more than once.") - submitted = aether.Submit(m_ParticleSystemInstances[1]); + submitted = aether.Submit(*submission, m_ParticleSystemInstances[1]); EE_CORE_ASSERT(submitted, "The particle system instance was submitted more than once.") + aether.PublishFrameSubmission(submission); + aether.Render(m_CameraController->GetCamera()); const auto& metrics = aether.GetLastSubmissionMetrics(); } diff --git a/Elixir/Source/Engine/Aether/FrameSubmission.h b/Elixir/Source/Engine/Aether/FrameSubmission.h index faf7fbe7..bedabab6 100644 --- a/Elixir/Source/Engine/Aether/FrameSubmission.h +++ b/Elixir/Source/Engine/Aether/FrameSubmission.h @@ -12,9 +12,12 @@ namespace Elixir::Aether // at most once per frame. class ELIXIR_API FrameSubmission final { + friend class FrameSubmissionPublisher; public: bool Submit(const SystemInstance& instance) { + if (m_IsSealed) return false; + const auto [_, inserted] = m_InstanceIds.insert(instance.GetId()); if (!inserted) return false; @@ -25,6 +28,8 @@ namespace Elixir::Aether // Drop a captured state before the manager retires its GPU allocation. bool Remove(const SystemInstance& instance) { + if (m_IsSealed) return false; + const auto found = m_InstanceIds.find(instance.GetId()); if (found == m_InstanceIds.end()) return false; @@ -42,6 +47,24 @@ namespace Elixir::Aether m_InstanceIds.clear(); } + Ref Without(const UUID& instanceId) const + { + auto copy = CreateRef(); + copy->m_Snapshots = m_Snapshots; + copy->m_InstanceIds = m_InstanceIds; + + std::erase_if(copy->m_Snapshots, [&instanceId](const auto& snapshot) + { + return snapshot->GetId() == instanceId; + }); + + copy->m_InstanceIds.erase(instanceId); + copy->m_IsSealed = true; + return copy; + } + + bool IsSealed() const { return m_IsSealed; } + bool IsEmpty() const { return m_Snapshots.empty(); } size_t GetInstanceCount() const { return m_Snapshots.size(); } @@ -52,7 +75,42 @@ namespace Elixir::Aether } private: + void Seal() { m_IsSealed = true; } + + bool m_IsSealed = false; std::vector> m_Snapshots; std::unordered_set m_InstanceIds; }; + + // Short synchronized handoff between the submission producer and renderer. + class ELIXIR_API FrameSubmissionPublisher final + { + public: + void Publish(Ref submission) + { + EE_CORE_ASSERT(submission, "Aether frame submission cannot be null.") + submission->Seal(); + + const std::scoped_lock lock(m_Mutex); + m_Published = std::move(submission); + } + + Ref Acquire() + { + const std::scoped_lock lock(m_Mutex); + return m_Published; + } + + void Remove(const UUID& instanceId) + { + const std::scoped_lock lock(m_Mutex); + + if (m_Published) + m_Published = m_Published->Without(instanceId); + } + + private: + mutable std::mutex m_Mutex; + Ref m_Published; + }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/Aether/Manager.cpp b/Elixir/Source/Engine/Aether/Manager.cpp index 66828818..e882c99c 100644 --- a/Elixir/Source/Engine/Aether/Manager.cpp +++ b/Elixir/Source/Engine/Aether/Manager.cpp @@ -55,7 +55,7 @@ namespace Elixir::Aether const auto found = m_Instances.find(instance->GetId()); if (found == m_Instances.end()) return false; - m_FrameSubmission.Remove(*instance); + m_FrameSubmissionPublisher.Remove(instance->GetId()); GetRenderer().Retire(*instance); m_Instances.erase(found); @@ -64,21 +64,30 @@ namespace Elixir::Aether void Manager::BeginFrame(const Timestep& timestep) { - // Release the previous frame's immutable states before accepting a - // new submission. - m_FrameSubmission.Reset(); GetRenderer().Update(timestep); } - bool Manager::Submit(const Ref& instance) + Ref Manager::CreateFrameSubmission() const { - if (!IsManagedInstance(instance)) return false; - return m_FrameSubmission.Submit(*instance); + return CreateRef(); + } + + bool Manager::Submit(FrameSubmission& submission, const Ref& instance) const + { + return IsManagedInstance(instance) && submission.Submit(*instance); + } + + void Manager::PublishFrameSubmission(Ref submission) + { + m_FrameSubmissionPublisher.Publish(std::move(submission)); } void Manager::Render(const Camera& camera) { - GetRenderer().Render(m_FrameSubmission, camera); + const auto submission = m_FrameSubmissionPublisher.Acquire(); + if (!submission) return; + + GetRenderer().Render(*submission, camera); } const SParticleSubmissionMetrics& Manager::GetLastSubmissionMetrics() const diff --git a/Elixir/Source/Engine/Aether/Manager.h b/Elixir/Source/Engine/Aether/Manager.h index d431d00d..1abd5c88 100644 --- a/Elixir/Source/Engine/Aether/Manager.h +++ b/Elixir/Source/Engine/Aether/Manager.h @@ -53,10 +53,15 @@ namespace Elixir::Aether // frame submission before the renderer retires GPU allocations. bool DestroyInstance(const Ref& instance); - // Render-frame API. Submit() is valid only between BeginFrame() and Render(). void BeginFrame(const Timestep& timestep); - bool Submit(const Ref& instance); + // A submission is built by one producer thread, then atomically + // published for the renderer thread to consume. + Ref CreateFrameSubmission() const; + + bool Submit(FrameSubmission& submission, const Ref& instance) const; + + void PublishFrameSubmission(Ref submission); void Render(const Camera& camera); @@ -72,7 +77,7 @@ namespace Elixir::Aether MaterialSystem& m_MaterialSystem; std::unordered_map> m_Instances; - FrameSubmission m_FrameSubmission; + FrameSubmissionPublisher m_FrameSubmissionPublisher; Scope m_Renderer; }; } \ No newline at end of file diff --git a/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp b/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp index cb65d4e9..bcf679d5 100644 --- a/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp +++ b/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp @@ -69,4 +69,34 @@ TEST(AetherFrameSubmissionTest, RetainsTheStateCapturedAtSubmission) EXPECT_FLOAT_EQ(snapshot->GetWorldTransform()[3].x, 0.0f); EXPECT_FLOAT_EQ(snapshot->GetWorldTransform()[3].y, 0.0f); EXPECT_FLOAT_EQ(snapshot->GetWorldTransform()[3].z, 0.0f); +} + +TEST(AetherFrameSubmissionPublisherTest, PublishesOnlySealedSubmissions) +{ + const auto compiledSystem = CreateRef(); + const SystemInstance instance{ compiledSystem }; + 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(AetherFrameSubmissionPublisherTest, RemovesDestroyedInstanceFromPublishedFrame) +{ + const auto compiledSystem = CreateRef(); + const SystemInstance instance{ compiledSystem }; + const auto submission = CreateRef(); + FrameSubmissionPublisher publisher; + + ASSERT_TRUE(submission->Submit(instance)); + publisher.Publish(submission); + publisher.Remove(instance.GetId()); + + EXPECT_TRUE(publisher.Acquire()->IsEmpty()); } \ No newline at end of file From ae5e00d9fe4d28359109b61e551b6167ee846af7 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Thu, 6 Aug 2026 21:14:27 -0300 Subject: [PATCH 45/89] refactor(application): prepare frames before rendering Build and publish Aether frame submissions on the application thread. The render thread retains exclusive ownership of GPU work and consumes the published immutable frame state. --- Dissolve/Source/Dissolve.cpp | 31 ++++++++++++++--------- Dissolve/Source/Dissolve.h | 3 ++- Elixir/Source/Engine/Core/Application.cpp | 4 ++- Elixir/Source/Engine/Core/Application.h | 7 ++++- 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index a9c2aefa..43acb53d 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -240,22 +240,12 @@ 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::OnRender(frameTime); - - m_CameraController->Update(frameTime); - m_FrameData.ViewProj = m_CameraController->GetCamera().GetViewProjectionMatrix(); - m_FrameConstantBuffer->UpdateData(&m_FrameData, sizeof(SFrameData)); + Application::Prepare(frameTime); auto& aether = GetAetherManager(); - aether.BeginFrame(frameTime); - - m_GraphicsContext->Clear(); - - //DrawGeometry(); - const auto submission = aether.CreateFrameSubmission(); bool submitted = aether.Submit(*submission, m_ParticleSystemInstances[0]); @@ -265,6 +255,23 @@ void Dissolve::OnRender(const Timestep frameTime) EE_CORE_ASSERT(submitted, "The particle system instance was submitted more than once.") aether.PublishFrameSubmission(submission); +} + +void Dissolve::Render(const Timestep frameTime) +{ + EE_PROFILE_ZONE_SCOPED() + Application::Render(frameTime); + + m_CameraController->Update(frameTime); + m_FrameData.ViewProj = m_CameraController->GetCamera().GetViewProjectionMatrix(); + m_FrameConstantBuffer->UpdateData(&m_FrameData, sizeof(SFrameData)); + + auto& aether = GetAetherManager(); + aether.BeginFrame(frameTime); + + m_GraphicsContext->Clear(); + + //DrawGeometry(); aether.Render(m_CameraController->GetCamera()); const auto& metrics = aether.GetLastSubmissionMetrics(); 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/Elixir/Source/Engine/Core/Application.cpp b/Elixir/Source/Engine/Core/Application.cpp index f68306d3..482bd675 100644 --- a/Elixir/Source/Engine/Core/Application.cpp +++ b/Elixir/Source/Engine/Core/Application.cpp @@ -192,9 +192,11 @@ 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); + Render(frameTime); m_GUIManager->Render(); }); diff --git a/Elixir/Source/Engine/Core/Application.h b/Elixir/Source/Engine/Core/Application.h index 0955dc48..4a6dd7d8 100644 --- a/Elixir/Source/Engine/Core/Application.h +++ b/Elixir/Source/Engine/Core/Application.h @@ -34,7 +34,12 @@ 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); const Window* GetWindow() const { return m_Window.get(); } From cc1960a34d26fc513c2885c3b4ff8c8fe6a24fe8 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Thu, 6 Aug 2026 23:40:23 -0300 Subject: [PATCH 46/89] refactor(aether): defer instance retirement Detach destroyed instances from published frames on the producer side. Retire their GPU allocations on the render thread after the frame-slot fence has made reuse safe. --- Elixir/Source/Engine/Aether/Manager.cpp | 11 ++++++- Elixir/Source/Engine/Aether/Manager.h | 8 +++-- .../Aether/SystemInstanceRetirementQueue.h | 30 +++++++++++++++++++ .../SystemInstanceRetirementQueueTest.cpp | 24 +++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 Elixir/Source/Engine/Aether/SystemInstanceRetirementQueue.h create mode 100644 Elixir/Tests/Engine/Aether/SystemInstanceRetirementQueueTest.cpp diff --git a/Elixir/Source/Engine/Aether/Manager.cpp b/Elixir/Source/Engine/Aether/Manager.cpp index e882c99c..402ac19f 100644 --- a/Elixir/Source/Engine/Aether/Manager.cpp +++ b/Elixir/Source/Engine/Aether/Manager.cpp @@ -56,7 +56,7 @@ namespace Elixir::Aether if (found == m_Instances.end()) return false; m_FrameSubmissionPublisher.Remove(instance->GetId()); - GetRenderer().Retire(*instance); + m_PendingRetirements.Enqueue(found->second); m_Instances.erase(found); return true; @@ -65,6 +65,7 @@ namespace Elixir::Aether void Manager::BeginFrame(const Timestep& timestep) { GetRenderer().Update(timestep); + RetireDestroyedInstances(); } Ref Manager::CreateFrameSubmission() const @@ -101,6 +102,14 @@ namespace Elixir::Aether return *m_Renderer; } + void Manager::RetireDestroyedInstances() + { + const auto instances = m_PendingRetirements.Drain(); + + for (const auto& instance : instances) + GetRenderer().Retire(*instance); + } + bool Manager::IsManagedInstance(const Ref& instance) const { if (!instance) return false; diff --git a/Elixir/Source/Engine/Aether/Manager.h b/Elixir/Source/Engine/Aether/Manager.h index 1abd5c88..7a406858 100644 --- a/Elixir/Source/Engine/Aether/Manager.h +++ b/Elixir/Source/Engine/Aether/Manager.h @@ -3,6 +3,7 @@ #include #include #include +#include namespace Elixir { @@ -49,8 +50,8 @@ namespace Elixir::Aether // Manager retains registration and GPU lifetime ownership. Ref CreateInstance(Ref system); - // Must be called from the render-frame callback. It detaches any - // frame submission before the renderer retires GPU allocations. + // Detaches the instance from future frames immediately. Its GPU + // allocation is retired from BeginFrame on the render thread. bool DestroyInstance(const Ref& instance); void BeginFrame(const Timestep& timestep); @@ -70,6 +71,8 @@ namespace Elixir::Aether private: Renderer& GetRenderer() const; + void RetireDestroyedInstances(); + bool IsManagedInstance(const Ref& instance) const; EffectMaterialResolver m_EffectMaterials; @@ -77,6 +80,7 @@ namespace Elixir::Aether MaterialSystem& m_MaterialSystem; std::unordered_map> m_Instances; + SystemInstanceRetirementQueue m_PendingRetirements; FrameSubmissionPublisher m_FrameSubmissionPublisher; Scope m_Renderer; }; diff --git a/Elixir/Source/Engine/Aether/SystemInstanceRetirementQueue.h b/Elixir/Source/Engine/Aether/SystemInstanceRetirementQueue.h new file mode 100644 index 00000000..48cd39de --- /dev/null +++ b/Elixir/Source/Engine/Aether/SystemInstanceRetirementQueue.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +namespace Elixir::Aether +{ + // Cross-shared handoff for instances whose GPU allocations must be retired. + class ELIXIR_API SystemInstanceRetirementQueue final + { + public: + 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)); + } + + 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/Tests/Engine/Aether/SystemInstanceRetirementQueueTest.cpp b/Elixir/Tests/Engine/Aether/SystemInstanceRetirementQueueTest.cpp new file mode 100644 index 00000000..cf9c6f74 --- /dev/null +++ b/Elixir/Tests/Engine/Aether/SystemInstanceRetirementQueueTest.cpp @@ -0,0 +1,24 @@ +#include + +#include + +using namespace Elixir; +using namespace Elixir::Aether; + +TEST(SystemInstanceRetirementQueueTest, TransfersPendingInstancesExactlyOnce) +{ + const auto system = CreateRef(); + const auto first = CreateRef(system); + const auto second = CreateRef(system); + + 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()); +} \ No newline at end of file From fbfb00e1bfe236778ab41397a18afae5eab4ca2b Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Fri, 7 Aug 2026 07:05:22 -0300 Subject: [PATCH 47/89] refactor(aether): hide system instance identity Replace the public instance UUID with an internal key shared by the manager, frame submissions, and renderer records. --- Elixir/Source/Engine/Aether/FrameSubmission.h | 28 +++++------ Elixir/Source/Engine/Aether/Manager.cpp | 10 ++-- Elixir/Source/Engine/Aether/Manager.h | 2 +- Elixir/Source/Engine/Aether/Renderer.cpp | 22 ++++---- Elixir/Source/Engine/Aether/Renderer.h | 8 +-- .../Source/Engine/Aether/SystemInstance.cpp | 20 ++++---- Elixir/Source/Engine/Aether/SystemInstance.h | 50 +++++++++++++++---- .../Engine/Aether/FrameSubmissionTest.cpp | 13 +++-- 8 files changed, 95 insertions(+), 58 deletions(-) diff --git a/Elixir/Source/Engine/Aether/FrameSubmission.h b/Elixir/Source/Engine/Aether/FrameSubmission.h index bedabab6..8b3aebc6 100644 --- a/Elixir/Source/Engine/Aether/FrameSubmission.h +++ b/Elixir/Source/Engine/Aether/FrameSubmission.h @@ -18,7 +18,7 @@ namespace Elixir::Aether { if (m_IsSealed) return false; - const auto [_, inserted] = m_InstanceIds.insert(instance.GetId()); + const auto [_, inserted] = m_InstanceKeys.insert(instance.GetKey()); if (!inserted) return false; m_Snapshots.push_back(instance.CaptureSnapshot()); @@ -30,35 +30,35 @@ namespace Elixir::Aether { if (m_IsSealed) return false; - const auto found = m_InstanceIds.find(instance.GetId()); - if (found == m_InstanceIds.end()) return false; + const auto found = m_InstanceKeys.find(instance.GetKey()); + if (found == m_InstanceKeys.end()) return false; std::erase_if(m_Snapshots, [&instance](const auto& snapshot) { - return snapshot->GetId() == instance.GetId(); + return snapshot->GetKey() == instance.GetKey(); }); - m_InstanceIds.erase(found); + m_InstanceKeys.erase(found); return true; } void Reset() { m_Snapshots.clear(); - m_InstanceIds.clear(); + m_InstanceKeys.clear(); } - Ref Without(const UUID& instanceId) const + Ref Without(const SSystemInstanceKey& key) const { auto copy = CreateRef(); copy->m_Snapshots = m_Snapshots; - copy->m_InstanceIds = m_InstanceIds; + copy->m_InstanceKeys = m_InstanceKeys; - std::erase_if(copy->m_Snapshots, [&instanceId](const auto& snapshot) + std::erase_if(copy->m_Snapshots, [&key](const auto& snapshot) { - return snapshot->GetId() == instanceId; + return snapshot->GetKey() == key; }); - copy->m_InstanceIds.erase(instanceId); + copy->m_InstanceKeys.erase(key); copy->m_IsSealed = true; return copy; } @@ -79,7 +79,7 @@ namespace Elixir::Aether bool m_IsSealed = false; std::vector> m_Snapshots; - std::unordered_set m_InstanceIds; + std::unordered_set m_InstanceKeys; }; // Short synchronized handoff between the submission producer and renderer. @@ -101,12 +101,12 @@ namespace Elixir::Aether return m_Published; } - void Remove(const UUID& instanceId) + void Remove(const SystemInstance& instance) { const std::scoped_lock lock(m_Mutex); if (m_Published) - m_Published = m_Published->Without(instanceId); + m_Published = m_Published->Without(instance.GetKey()); } private: diff --git a/Elixir/Source/Engine/Aether/Manager.cpp b/Elixir/Source/Engine/Aether/Manager.cpp index 402ac19f..99fa43ce 100644 --- a/Elixir/Source/Engine/Aether/Manager.cpp +++ b/Elixir/Source/Engine/Aether/Manager.cpp @@ -40,9 +40,9 @@ namespace Elixir::Aether EE_CORE_ASSERT(system, "Aether system instance requires a compiled system.") auto instance = CreateRef(std::move(system)); - const auto id = instance->GetId(); + const auto key = instance->GetKey(); - const auto [_, inserted] = m_Instances.emplace(id, instance); + const auto [_, inserted] = m_Instances.emplace(key, instance); EE_CORE_ASSERT(inserted, "Aether system instance UUID must be unique.") return instance; @@ -52,10 +52,10 @@ namespace Elixir::Aether { if (!IsManagedInstance(instance)) return false; - const auto found = m_Instances.find(instance->GetId()); + const auto found = m_Instances.find(instance->GetKey()); if (found == m_Instances.end()) return false; - m_FrameSubmissionPublisher.Remove(instance->GetId()); + m_FrameSubmissionPublisher.Remove(*instance); m_PendingRetirements.Enqueue(found->second); m_Instances.erase(found); @@ -113,7 +113,7 @@ namespace Elixir::Aether bool Manager::IsManagedInstance(const Ref& instance) const { if (!instance) return false; - const auto found = m_Instances.find(instance->GetId()); + 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/Manager.h b/Elixir/Source/Engine/Aether/Manager.h index 7a406858..72132ddf 100644 --- a/Elixir/Source/Engine/Aether/Manager.h +++ b/Elixir/Source/Engine/Aether/Manager.h @@ -78,7 +78,7 @@ namespace Elixir::Aether EffectMaterialResolver m_EffectMaterials; MaterialSystem& m_MaterialSystem; - std::unordered_map> m_Instances; + std::unordered_map> m_Instances; SystemInstanceRetirementQueue m_PendingRetirements; FrameSubmissionPublisher m_FrameSubmissionPublisher; diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index 6235d53d..023ed37d 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -205,7 +205,7 @@ namespace Elixir::Aether if (!IsParticleStateLayoutSupported(system.ParticleStateLayout)) { - if (m_UnsupportedParticleStateLayoutInstances.insert(snapshot->GetId()).second) + if (m_UnsupportedParticleStateLayoutInstances.insert(snapshot->GetKey()).second) { EE_CORE_ERROR( "Aether does not support particle state layout '{}' for system instance '{}'.", @@ -217,7 +217,7 @@ namespace Elixir::Aether continue; } - m_UnsupportedParticleStateLayoutInstances.erase(snapshot->GetId()); + m_UnsupportedParticleStateLayoutInstances.erase(snapshot->GetKey()); auto* record = ResolveInstanceRecord(*snapshot); if (!record) continue; @@ -291,14 +291,14 @@ namespace Elixir::Aether void Renderer::Retire(const SystemInstance& instance) { - const auto found = m_InstanceRecords.find(instance.GetId()); + const auto found = m_InstanceRecords.find(instance.GetKey()); if (found == m_InstanceRecords.end()) return; QueueRetirement(found->second.Allocation); m_InstanceRecords.erase(found); - m_AllocationFailures.erase(instance.GetId()); - m_UnsupportedParticleStateLayoutInstances.erase(instance.GetId()); + m_AllocationFailures.erase(instance.GetKey()); + m_UnsupportedParticleStateLayoutInstances.erase(instance.GetKey()); } const SParticleSubmissionMetrics& Renderer::GetLastSubmissionMetrics() const @@ -773,7 +773,7 @@ namespace Elixir::Aether ) { const auto instanceRevision = snapshot.GetRevision(); - const auto found = m_InstanceRecords.find(snapshot.GetId()); + const auto found = m_InstanceRecords.find(snapshot.GetKey()); if (found != m_InstanceRecords.end() && found->second.SystemInstanceRevision == instanceRevision) @@ -786,7 +786,7 @@ namespace Elixir::Aether const auto replacementAllocation = m_ParticleResourcePool.Allocate(system); if (!replacementAllocation) { - if (m_AllocationFailures.insert(snapshot.GetId()).second) + if (m_AllocationFailures.insert(snapshot.GetKey()).second) { EE_CORE_ERROR( "Aether GPU resource pool exhausted while creating system instance '{}'.", @@ -801,7 +801,7 @@ namespace Elixir::Aether UploadCompiledSystem(snapshot, *replacementAllocation); const SInstanceRecord replacement{ - .SystemInstanceId = snapshot.GetId(), + .SystemInstanceKey = snapshot.GetKey(), .SystemInstanceRevision = instanceRevision, .CompiledSystemId = system.SourceId, .CompilationRevision = system.CompilationRevision, @@ -811,10 +811,10 @@ namespace Elixir::Aether if (found == m_InstanceRecords.end()) { - const auto [it, inserted] = m_InstanceRecords.emplace(snapshot.GetId(), replacement); + const auto [it, inserted] = m_InstanceRecords.emplace(snapshot.GetKey(), replacement); EE_CORE_ASSERT(inserted, "Aether system instance registry insertion failed.") - m_AllocationFailures.erase(snapshot.GetId()); + m_AllocationFailures.erase(snapshot.GetKey()); return &it->second; } @@ -822,7 +822,7 @@ namespace Elixir::Aether // previous record. If allocation fails, the old record remains intact. QueueRetirement(found->second.Allocation); found->second = replacement; - m_AllocationFailures.erase(snapshot.GetId()); + m_AllocationFailures.erase(snapshot.GetKey()); return &found->second; } diff --git a/Elixir/Source/Engine/Aether/Renderer.h b/Elixir/Source/Engine/Aether/Renderer.h index c81f86c3..580f8e6a 100644 --- a/Elixir/Source/Engine/Aether/Renderer.h +++ b/Elixir/Source/Engine/Aether/Renderer.h @@ -204,7 +204,7 @@ namespace Elixir::Aether struct SInstanceRecord { - UUID SystemInstanceId; + SSystemInstanceKey SystemInstanceKey; uint32_t SystemInstanceRevision = 0; UUID CompiledSystemId; uint32_t CompilationRevision = 0; @@ -302,9 +302,9 @@ namespace Elixir::Aether 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::unordered_map m_InstanceRecords; + std::unordered_set m_AllocationFailures; + std::unordered_set m_UnsupportedParticleStateLayoutInstances; std::array< std::vector, GraphicsContext::FRAMES diff --git a/Elixir/Source/Engine/Aether/SystemInstance.cpp b/Elixir/Source/Engine/Aether/SystemInstance.cpp index a0e0c114..913159e6 100644 --- a/Elixir/Source/Engine/Aether/SystemInstance.cpp +++ b/Elixir/Source/Engine/Aether/SystemInstance.cpp @@ -6,13 +6,13 @@ namespace Elixir::Aether /* SystemInstanceSnapshot */ SystemInstanceSnapshot::SystemInstanceSnapshot( - UUID id, + SSystemInstanceKey key, const uint32_t revision, const uint32_t parameterRevision, Ref system, const glm::mat4& worldTransform, Ref overrides - ) : m_Id(id), + ) : m_Key(std::move(key)), m_Revision(revision), m_ParameterRevision(parameterRevision), m_CompiledSystem(std::move(system)), @@ -42,7 +42,7 @@ namespace Elixir::Aether { EE_CORE_ASSERT(system, "SystemInstance requires a compiled system.") m_Snapshot = CreateSnapshot( - m_Id, + m_Key, 1, 1, std::move(system), @@ -69,7 +69,7 @@ namespace Elixir::Aether }); m_Snapshot = CreateSnapshot( - m_Id, + m_Key, m_Snapshot->m_Revision + 1, m_Snapshot->m_ParameterRevision + (removed > 0 ? 1 : 0), std::move(system), @@ -83,7 +83,7 @@ namespace Elixir::Aether const std::scoped_lock lock(m_SnapshotMutex); m_Snapshot = CreateSnapshot( - m_Id, + m_Key, m_Snapshot->m_Revision, m_Snapshot->m_ParameterRevision, m_Snapshot->m_CompiledSystem, @@ -108,7 +108,7 @@ namespace Elixir::Aether overrides->insert_or_assign(std::string(name), value); m_Snapshot = CreateSnapshot( - m_Id, + m_Key, m_Snapshot->m_Revision, m_Snapshot->m_ParameterRevision + 1, m_Snapshot->m_CompiledSystem, @@ -134,7 +134,7 @@ namespace Elixir::Aether overrides->erase(found->first); m_Snapshot = CreateSnapshot( - m_Id, + m_Key, m_Snapshot->m_Revision, m_Snapshot->m_ParameterRevision + 1, m_Snapshot->m_CompiledSystem, @@ -153,7 +153,7 @@ namespace Elixir::Aether return; m_Snapshot = CreateSnapshot( - m_Id, + m_Key, m_Snapshot->m_Revision, m_Snapshot->m_ParameterRevision + 1, m_Snapshot->m_CompiledSystem, @@ -169,7 +169,7 @@ namespace Elixir::Aether } Ref SystemInstance::CreateSnapshot( - const UUID& id, + const SSystemInstanceKey& key, uint32_t revision, uint32_t parameterRevision, Ref system, @@ -178,7 +178,7 @@ namespace Elixir::Aether ) { return CreateRef( - id, + key, revision, parameterRevision, std::move(system), diff --git a/Elixir/Source/Engine/Aether/SystemInstance.h b/Elixir/Source/Engine/Aether/SystemInstance.h index dc3b3a48..be52ea2d 100644 --- a/Elixir/Source/Engine/Aether/SystemInstance.h +++ b/Elixir/Source/Engine/Aether/SystemInstance.h @@ -4,16 +4,41 @@ namespace Elixir::Aether { + class FrameSubmissionPublisher; + class Manager; + class Renderer; + + 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; + + UUID m_Id; + }; + using ParameterOverridesMap = std::unordered_map; // Immutable runtime state captured by one rendering frame. class ELIXIR_API SystemInstanceSnapshot final { friend class SystemInstance; + friend class FrameSubmission; + friend class Manager; + friend class Renderer; public: SystemInstanceSnapshot( - UUID id, + SSystemInstanceKey key, uint32_t revision, uint32_t parameterRevision, Ref system, @@ -23,14 +48,15 @@ namespace Elixir::Aether glm::vec4 ResolveParameterValue(uint32_t parameterIndex) const; - const UUID& GetId() const { return m_Id; } uint32_t GetRevision() const { return m_Revision; } uint32_t GetParameterRevision() const { return m_ParameterRevision; } const SCompiledSystem& GetCompiledSystem() const { return *m_CompiledSystem; } const glm::mat4& GetWorldTransform() const { return m_WorldTransform; } private: - UUID m_Id; + const SSystemInstanceKey& GetKey() const { return m_Key; } + + SSystemInstanceKey m_Key; uint32_t m_Revision = 1; uint32_t m_ParameterRevision = 1; Ref m_CompiledSystem; @@ -43,17 +69,18 @@ namespace Elixir::Aether class ELIXIR_API SystemInstance final { friend class FrameSubmission; + friend class FrameSubmissionPublisher; + friend class Manager; + friend class Renderer; public: - explicit SystemInstance(Ref compiledSystem); + explicit SystemInstance(Ref system); SystemInstance(const SystemInstance&) = delete; SystemInstance& operator=(const SystemInstance&) = delete; SystemInstance(SystemInstance&&) = delete; SystemInstance& operator=(SystemInstance&&) = delete; - const UUID& GetId() const { return m_Id; } - - void SetCompiledSystem(Ref compiledSystem); + void SetCompiledSystem(Ref system); void SetWorldTransform(const glm::mat4& worldTransform); @@ -66,7 +93,7 @@ namespace Elixir::Aether Ref CaptureSnapshot() const; static Ref CreateSnapshot( - const UUID& id, + const SSystemInstanceKey& key, uint32_t revision, uint32_t parameterRevision, Ref system, @@ -76,9 +103,12 @@ namespace Elixir::Aether static bool IsExposedParameter(const SCompiledSystem& system, std::string_view name); - UUID m_Id; + const SSystemInstanceKey& GetKey() const { return m_Key; } - mutable std::mutex m_SnapshotMutex; + SSystemInstanceKey m_Key; Ref m_Snapshot; + mutable std::mutex m_SnapshotMutex; }; } + +GENERATE_HASH_FUNCTION(Elixir::Aether::SSystemInstanceKey) \ No newline at end of file diff --git a/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp b/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp index bcf679d5..463d9a1f 100644 --- a/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp +++ b/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp @@ -5,6 +5,14 @@ using namespace Elixir; using namespace Elixir::Aether; +template +concept HasPublicSystemInstanceId = requires(const T& instance) +{ + instance.GetId(); +}; + +static_assert(!HasPublicSystemInstanceId); + TEST(AetherFrameSubmissionTest, RetainsEachSystemInstanceAtMostOnce) { const auto compiledSystem = CreateRef(); @@ -18,8 +26,7 @@ TEST(AetherFrameSubmissionTest, RetainsEachSystemInstanceAtMostOnce) EXPECT_TRUE(submission.Submit(secondInstance)); ASSERT_EQ(submission.GetInstanceCount(), 2); - EXPECT_EQ(submission.GetSnapshots()[0]->GetId(), firstInstance.GetId()); - EXPECT_EQ(submission.GetSnapshots()[1]->GetId(), secondInstance.GetId()); + EXPECT_NE(submission.GetSnapshots()[0], submission.GetSnapshots()[1]); } TEST(AetherFrameSubmissionTest, ResetKeepsTheSubmissionReusable) @@ -96,7 +103,7 @@ TEST(AetherFrameSubmissionPublisherTest, RemovesDestroyedInstanceFromPublishedFr ASSERT_TRUE(submission->Submit(instance)); publisher.Publish(submission); - publisher.Remove(instance.GetId()); + publisher.Remove(instance); EXPECT_TRUE(publisher.Acquire()->IsEmpty()); } \ No newline at end of file From d9e96b8d4c19c0c3200f0613f67787323040ed1e Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Fri, 7 Aug 2026 11:24:17 -0300 Subject: [PATCH 48/89] refactor(aether): publish system instance render proxies Resolve immutable renderer-facing proxies during snapshot creation and submit them directly to the renderer. --- Elixir/Source/Engine/Aether/FrameSubmission.h | 28 ++++--- Elixir/Source/Engine/Aether/Renderer.cpp | 73 ++++++++++--------- Elixir/Source/Engine/Aether/Renderer.h | 10 +-- .../Source/Engine/Aether/SystemInstance.cpp | 29 +++----- Elixir/Source/Engine/Aether/SystemInstance.h | 5 +- .../Aether/SystemInstanceRenderProxy.cpp | 28 +++++++ .../Engine/Aether/SystemInstanceRenderProxy.h | 50 +++++++++++++ .../Engine/Aether/FrameSubmissionTest.cpp | 18 +++-- .../Engine/Aether/SystemInstanceTest.cpp | 12 +-- 9 files changed, 169 insertions(+), 84 deletions(-) create mode 100644 Elixir/Source/Engine/Aether/SystemInstanceRenderProxy.cpp create mode 100644 Elixir/Source/Engine/Aether/SystemInstanceRenderProxy.h diff --git a/Elixir/Source/Engine/Aether/FrameSubmission.h b/Elixir/Source/Engine/Aether/FrameSubmission.h index 8b3aebc6..0e6b9e4d 100644 --- a/Elixir/Source/Engine/Aether/FrameSubmission.h +++ b/Elixir/Source/Engine/Aether/FrameSubmission.h @@ -4,6 +4,7 @@ #include #include +#include namespace Elixir::Aether { @@ -21,7 +22,8 @@ namespace Elixir::Aether const auto [_, inserted] = m_InstanceKeys.insert(instance.GetKey()); if (!inserted) return false; - m_Snapshots.push_back(instance.CaptureSnapshot()); + const auto snapshot = instance.CaptureSnapshot(); + m_RenderProxies.push_back(snapshot->GetRenderProxy()); return true; } @@ -33,29 +35,31 @@ namespace Elixir::Aether const auto found = m_InstanceKeys.find(instance.GetKey()); if (found == m_InstanceKeys.end()) return false; - std::erase_if(m_Snapshots, [&instance](const auto& snapshot) + std::erase_if(m_RenderProxies, [&instance](const auto& proxy) { - return snapshot->GetKey() == instance.GetKey(); + return proxy->GetKey() == instance.GetKey(); }); + m_InstanceKeys.erase(found); + return true; } void Reset() { - m_Snapshots.clear(); + m_RenderProxies.clear(); m_InstanceKeys.clear(); } Ref Without(const SSystemInstanceKey& key) const { auto copy = CreateRef(); - copy->m_Snapshots = m_Snapshots; + copy->m_RenderProxies = m_RenderProxies; copy->m_InstanceKeys = m_InstanceKeys; - std::erase_if(copy->m_Snapshots, [&key](const auto& snapshot) + std::erase_if(copy->m_RenderProxies, [&key](const auto& proxy) { - return snapshot->GetKey() == key; + return proxy->GetKey() == key; }); copy->m_InstanceKeys.erase(key); @@ -65,20 +69,20 @@ namespace Elixir::Aether bool IsSealed() const { return m_IsSealed; } - bool IsEmpty() const { return m_Snapshots.empty(); } + bool IsEmpty() const { return m_RenderProxies.empty(); } - size_t GetInstanceCount() const { return m_Snapshots.size(); } + size_t GetInstanceCount() const { return m_RenderProxies.size(); } - const std::vector>& GetSnapshots() const + const std::vector>& GetRenderProxies() const { - return m_Snapshots; + return m_RenderProxies; } private: void Seal() { m_IsSealed = true; } bool m_IsSealed = false; - std::vector> m_Snapshots; + std::vector> m_RenderProxies; std::unordered_set m_InstanceKeys; }; diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index 023ed37d..39d1fc95 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -3,6 +3,7 @@ #include #include +#include namespace Elixir::Aether { @@ -114,11 +115,11 @@ namespace Elixir::Aether glm::mat4 GetParticleRenderTransform( const SCompiledEmitter& emitter, - const SystemInstanceSnapshot& snapshot + const SystemInstanceRenderProxy& proxy ) { return emitter.SimulationSpace == EParticleSimulationSpace::Local - ? snapshot.GetWorldTransform() + ? proxy.GetWorldTransform() : glm::mat4{ 1.0f }; } @@ -173,13 +174,13 @@ namespace Elixir::Aether void Renderer::Render(const FrameSubmission& submission, const Camera& camera) { - const auto& snapshots = submission.GetSnapshots(); + const auto& proxies = submission.GetRenderProxies(); m_LastSubmissionMetrics = { .SubmissionSerial = ++m_SubmissionSerial, .DeltaTimeSeconds = m_LastDeltaTimeSeconds, .ElapsedTimeSeconds = m_ElapsedTimeSeconds, - .RequestedSystemInstanceCount = snapshots.size(), + .RequestedSystemInstanceCount = proxies.size(), .TriggerEventCapacityPerEmitter = m_ParticlePoolLimits.TriggerEventCapacityPerEmitter, }; @@ -194,18 +195,18 @@ namespace Elixir::Aether m_FrameConstantBuffer->UpdateData(&m_FrameData, sizeof(SFrameData)); std::vector submittedInstances; - submittedInstances.reserve(snapshots.size()); + submittedInstances.reserve(proxies.size()); - for (const auto& snapshot : snapshots) + for (const auto& proxy : proxies) { - const auto& system = snapshot->GetCompiledSystem(); + const auto& system = proxy->GetCompiledSystem(); m_LastSubmissionMetrics.RequestedEmitterCount += system.Emitters.size(); m_LastSubmissionMetrics.RequestedParticleCapacity += system.TotalMaxParticles; if (!IsParticleStateLayoutSupported(system.ParticleStateLayout)) { - if (m_UnsupportedParticleStateLayoutInstances.insert(snapshot->GetKey()).second) + if (m_UnsupportedParticleStateLayoutInstances.insert(proxy->GetKey()).second) { EE_CORE_ERROR( "Aether does not support particle state layout '{}' for system instance '{}'.", @@ -217,12 +218,12 @@ namespace Elixir::Aether continue; } - m_UnsupportedParticleStateLayoutInstances.erase(snapshot->GetKey()); + m_UnsupportedParticleStateLayoutInstances.erase(proxy->GetKey()); - auto* record = ResolveInstanceRecord(*snapshot); + auto* record = ResolveInstanceRecord(*proxy); if (!record) continue; - UpdateBuffers(*snapshot, *record); + UpdateBuffers(*proxy, *record); const auto emitterCount = record->Allocation.Emitters.Count; const auto particleCount = record->Allocation.Particles.Count; @@ -232,7 +233,7 @@ namespace Elixir::Aether m_LastSubmissionMetrics.SubmittedParticleCapacity += particleCount; submittedInstances.push_back({ - .Snapshot = snapshot, + .Proxy = proxy, .Allocation = record->Allocation, .ParticleStateLayout = system.ParticleStateLayout, }); @@ -769,11 +770,11 @@ namespace Elixir::Aether } Renderer::SInstanceRecord* Renderer::ResolveInstanceRecord( - const SystemInstanceSnapshot& snapshot + const SystemInstanceRenderProxy& proxy ) { - const auto instanceRevision = snapshot.GetRevision(); - const auto found = m_InstanceRecords.find(snapshot.GetKey()); + const auto instanceRevision = proxy.GetRevision(); + const auto found = m_InstanceRecords.find(proxy.GetKey()); if (found != m_InstanceRecords.end() && found->second.SystemInstanceRevision == instanceRevision) @@ -781,12 +782,12 @@ namespace Elixir::Aether return &found->second; } - const auto& system = snapshot.GetCompiledSystem(); + const auto& system = proxy.GetCompiledSystem(); const auto replacementAllocation = m_ParticleResourcePool.Allocate(system); if (!replacementAllocation) { - if (m_AllocationFailures.insert(snapshot.GetKey()).second) + if (m_AllocationFailures.insert(proxy.GetKey()).second) { EE_CORE_ERROR( "Aether GPU resource pool exhausted while creating system instance '{}'.", @@ -798,23 +799,23 @@ namespace Elixir::Aether } ClearParticleAllocation(*replacementAllocation); - UploadCompiledSystem(snapshot, *replacementAllocation); + UploadCompiledSystem(proxy, *replacementAllocation); const SInstanceRecord replacement{ - .SystemInstanceKey = snapshot.GetKey(), + .SystemInstanceKey = proxy.GetKey(), .SystemInstanceRevision = instanceRevision, .CompiledSystemId = system.SourceId, .CompilationRevision = system.CompilationRevision, - .ParameterRevision = snapshot.GetParameterRevision(), + .ParameterRevision = proxy.GetParameterRevision(), .Allocation = *replacementAllocation, }; if (found == m_InstanceRecords.end()) { - const auto [it, inserted] = m_InstanceRecords.emplace(snapshot.GetKey(), replacement); + const auto [it, inserted] = m_InstanceRecords.emplace(proxy.GetKey(), replacement); EE_CORE_ASSERT(inserted, "Aether system instance registry insertion failed.") - m_AllocationFailures.erase(snapshot.GetKey()); + m_AllocationFailures.erase(proxy.GetKey()); return &it->second; } @@ -822,16 +823,16 @@ namespace Elixir::Aether // previous record. If allocation fails, the old record remains intact. QueueRetirement(found->second.Allocation); found->second = replacement; - m_AllocationFailures.erase(snapshot.GetKey()); + m_AllocationFailures.erase(proxy.GetKey()); return &found->second; } void Renderer::UploadCompiledSystem( - const SystemInstanceSnapshot& snapshot, + const SystemInstanceRenderProxy& proxy, const SSystemInstanceAllocation& allocation ) const { - const auto& system = snapshot.GetCompiledSystem(); + const auto& system = proxy.GetCompiledSystem(); auto* emitters = (SEmitterData*)m_EmitterBuffer->Map(); for (uint32_t i = 0; i < allocation.Emitters.Count; ++i) @@ -852,7 +853,7 @@ namespace Elixir::Aether ); } - UploadInstanceParameters(snapshot, allocation); + UploadInstanceParameters(proxy, allocation); auto* targets = (STriggerTargetData*)m_TriggerTargetBuffer->Map(); for (uint32_t i = 0; i < allocation.TriggerTargets.Count; ++i) @@ -861,14 +862,14 @@ namespace Elixir::Aether } void Renderer::UploadInstanceParameters( - const SystemInstanceSnapshot& snapshot, + 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 = - snapshot.ResolveParameterValue(i); + proxy.GetParameterValue(i); } void Renderer::QueueRetirement(SSystemInstanceAllocation allocation) @@ -891,14 +892,14 @@ namespace Elixir::Aether } void Renderer::UpdateBuffers( - const SystemInstanceSnapshot& snapshot, + const SystemInstanceRenderProxy& proxy, SInstanceRecord& record ) { - if (record.ParameterRevision != snapshot.GetParameterRevision()) + if (record.ParameterRevision != proxy.GetParameterRevision()) { - UploadInstanceParameters(snapshot, record.Allocation); - record.ParameterRevision = snapshot.GetParameterRevision(); + UploadInstanceParameters(proxy, record.Allocation); + record.ParameterRevision = proxy.GetParameterRevision(); } const SParamsData params{ @@ -912,7 +913,7 @@ namespace Elixir::Aether }; m_ParamsBuffer->UpdateData(¶ms, sizeof(SParamsData)); - const auto& system = snapshot.GetCompiledSystem(); + const auto& system = proxy.GetCompiledSystem(); const SSystemInstanceData instanceData { @@ -1087,7 +1088,7 @@ namespace Elixir::Aether for (const auto& instance : instances) { - const auto& emitters = instance.Snapshot->GetCompiledSystem().Emitters; + const auto& emitters = instance.Proxy->GetCompiledSystem().Emitters; const auto geometry = getGeometry(instance.ParticleStateLayout); for (uint32_t emitterIndex = 0; emitterIndex < emitters.size(); ++emitterIndex) @@ -1097,7 +1098,7 @@ namespace Elixir::Aether const auto worldTransform = GetParticleRenderTransform( emitter, - *instance.Snapshot + *instance.Proxy ); switch (emitter.RenderMode) @@ -1264,7 +1265,7 @@ namespace Elixir::Aether for (const auto* instance : batch.Instances) { - const auto& system = instance->Snapshot->GetCompiledSystem(); + const auto& system = instance->Proxy->GetCompiledSystem(); const auto emitterCount = instance->Allocation.Emitters.Count; m_LastSubmissionMetrics.ScheduledEmitterCount += emitterCount; diff --git a/Elixir/Source/Engine/Aether/Renderer.h b/Elixir/Source/Engine/Aether/Renderer.h index 580f8e6a..c8e9495c 100644 --- a/Elixir/Source/Engine/Aether/Renderer.h +++ b/Elixir/Source/Engine/Aether/Renderer.h @@ -216,7 +216,7 @@ namespace Elixir::Aether // SystemInstance state captured at the start of Render(). struct SSubmittedSystemInstance { - Ref Snapshot; + Ref Proxy; SSystemInstanceAllocation Allocation; EParticleStateLayout ParticleStateLayout = EParticleStateLayout::CoreV1; }; @@ -227,20 +227,20 @@ namespace Elixir::Aether std::vector Instances; }; - SInstanceRecord* ResolveInstanceRecord(const SystemInstanceSnapshot& snapshot); + SInstanceRecord* ResolveInstanceRecord(const SystemInstanceRenderProxy& proxy); void UploadCompiledSystem( - const SystemInstanceSnapshot& snapshot, + const SystemInstanceRenderProxy& proxy, const SSystemInstanceAllocation& allocation ) const; void UploadInstanceParameters( - const SystemInstanceSnapshot& snapshot, + const SystemInstanceRenderProxy& proxy, const SSystemInstanceAllocation& allocation ) const; void QueueRetirement(SSystemInstanceAllocation allocation); void ProcessCompletedRetirements(); - void UpdateBuffers(const SystemInstanceSnapshot& snapshot, SInstanceRecord& record); + void UpdateBuffers(const SystemInstanceRenderProxy& proxy, SInstanceRecord& record); SParticleStateLayoutRuntime* FindParticleStateLayoutRuntime(EParticleStateLayout layout); const SParticleStateLayoutRuntime* FindParticleStateLayoutRuntime(EParticleStateLayout layout) const; diff --git a/Elixir/Source/Engine/Aether/SystemInstance.cpp b/Elixir/Source/Engine/Aether/SystemInstance.cpp index 913159e6..899c1e41 100644 --- a/Elixir/Source/Engine/Aether/SystemInstance.cpp +++ b/Elixir/Source/Engine/Aether/SystemInstance.cpp @@ -1,6 +1,8 @@ #include "epch.h" #include "SystemInstance.h" +#include + namespace Elixir::Aether { /* SystemInstanceSnapshot */ @@ -17,24 +19,15 @@ namespace Elixir::Aether m_ParameterRevision(parameterRevision), m_CompiledSystem(std::move(system)), m_WorldTransform(worldTransform), - m_ParameterOverrides(std::move(overrides)) {} - - glm::vec4 SystemInstanceSnapshot::ResolveParameterValue(uint32_t parameterIndex) const - { - EE_CORE_ASSERT( - parameterIndex < m_CompiledSystem->Parameters.size(), - "Aether parameter index is outside the compiled system parameter table." - ) - - 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_ParameterOverrides(std::move(overrides)), + m_RenderProxy(new SystemInstanceRenderProxy( + m_Key, + m_Revision, + m_ParameterRevision, + m_CompiledSystem, + m_WorldTransform, + *m_ParameterOverrides + )) {} /* SystemInstance */ diff --git a/Elixir/Source/Engine/Aether/SystemInstance.h b/Elixir/Source/Engine/Aether/SystemInstance.h index be52ea2d..e82aa8c0 100644 --- a/Elixir/Source/Engine/Aether/SystemInstance.h +++ b/Elixir/Source/Engine/Aether/SystemInstance.h @@ -4,6 +4,7 @@ namespace Elixir::Aether { + class SystemInstanceRenderProxy; class FrameSubmissionPublisher; class Manager; class Renderer; @@ -46,8 +47,6 @@ namespace Elixir::Aether Ref overrides ); - glm::vec4 ResolveParameterValue(uint32_t parameterIndex) const; - uint32_t GetRevision() const { return m_Revision; } uint32_t GetParameterRevision() const { return m_ParameterRevision; } const SCompiledSystem& GetCompiledSystem() const { return *m_CompiledSystem; } @@ -55,6 +54,7 @@ namespace Elixir::Aether private: const SSystemInstanceKey& GetKey() const { return m_Key; } + const Ref& GetRenderProxy() const { return m_RenderProxy; } SSystemInstanceKey m_Key; uint32_t m_Revision = 1; @@ -62,6 +62,7 @@ namespace Elixir::Aether Ref m_CompiledSystem; glm::mat4 m_WorldTransform{ 1.0f }; Ref m_ParameterOverrides; + Ref m_RenderProxy; }; // Runtime identity and immutable compiled payload selection. diff --git a/Elixir/Source/Engine/Aether/SystemInstanceRenderProxy.cpp b/Elixir/Source/Engine/Aether/SystemInstanceRenderProxy.cpp new file mode 100644 index 00000000..8ebc15f9 --- /dev/null +++ b/Elixir/Source/Engine/Aether/SystemInstanceRenderProxy.cpp @@ -0,0 +1,28 @@ +#include "epch.h" +#include "SystemInstanceRenderProxy.h" + +namespace Elixir::Aether +{ + SystemInstanceRenderProxy::SystemInstanceRenderProxy( + const SSystemInstanceKey& key, + const uint32_t revision, + const uint32_t parameterRevision, + Ref system, + const glm::mat4& worldTransform, + const ParameterOverridesMap& overrides + ) : m_Key(key), + m_Revision(revision), + m_ParameterRevision(parameterRevision), + m_CompiledSystem(std::move(system)), + m_WorldTransform(worldTransform) + { + m_ParameterValues.reserve(m_CompiledSystem->Parameters.size()); + + for (const auto& parameter : m_CompiledSystem->Parameters) + { + const auto found = overrides.find(parameter.Name); + const auto value = found != overrides.end() ? found->second : parameter.Value; + m_ParameterValues.push_back(value); + } + } +} diff --git a/Elixir/Source/Engine/Aether/SystemInstanceRenderProxy.h b/Elixir/Source/Engine/Aether/SystemInstanceRenderProxy.h new file mode 100644 index 00000000..2634fc2a --- /dev/null +++ b/Elixir/Source/Engine/Aether/SystemInstanceRenderProxy.h @@ -0,0 +1,50 @@ +#pragma once + +#include + +namespace Elixir::Aether +{ + // Renderer-facing state resolved from one immutable instance snapshot. + class SystemInstanceRenderProxy + { + friend class SystemInstanceSnapshot; + friend class FrameSubmission; + friend class Renderer; + + public: + uint32_t GetRevision() const { return m_Revision; } + uint32_t GetParameterRevision() const { return m_ParameterRevision; } + const SCompiledSystem& GetCompiledSystem() const { return *m_CompiledSystem; } + const glm::mat4& GetWorldTransform() const { return m_WorldTransform; } + + glm::vec4 GetParameterValue(const uint32_t parameterIndex) const + { + EE_CORE_ASSERT( + parameterIndex < m_ParameterValues.size(), + "Aether parameter index is outside the render proxy table." + ) + return parameterIndex < m_ParameterValues.size() + ? m_ParameterValues[parameterIndex] + : glm::vec4{}; + } + + private: + SystemInstanceRenderProxy( + const SSystemInstanceKey& key, + uint32_t revision, + uint32_t parameterRevision, + Ref system, + const glm::mat4& worldTransform, + const ParameterOverridesMap& overrides + ); + + 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 }; + std::vector m_ParameterValues; + }; +} diff --git a/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp b/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp index 463d9a1f..b5476b98 100644 --- a/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp +++ b/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp @@ -13,6 +13,14 @@ concept HasPublicSystemInstanceId = requires(const T& instance) static_assert(!HasPublicSystemInstanceId); +template +concept HasFrameSnapshots = requires(const T& submission) +{ + submission.GetSnapshots(); +}; + +static_assert(!HasFrameSnapshots); + TEST(AetherFrameSubmissionTest, RetainsEachSystemInstanceAtMostOnce) { const auto compiledSystem = CreateRef(); @@ -26,7 +34,7 @@ TEST(AetherFrameSubmissionTest, RetainsEachSystemInstanceAtMostOnce) EXPECT_TRUE(submission.Submit(secondInstance)); ASSERT_EQ(submission.GetInstanceCount(), 2); - EXPECT_NE(submission.GetSnapshots()[0], submission.GetSnapshots()[1]); + EXPECT_NE(submission.GetRenderProxies()[0], submission.GetRenderProxies()[1]); } TEST(AetherFrameSubmissionTest, ResetKeepsTheSubmissionReusable) @@ -72,10 +80,10 @@ TEST(AetherFrameSubmissionTest, RetainsTheStateCapturedAtSubmission) transform[3] = { 3.0f, 2.0f, 1.0f, 1.0f }; instance.SetWorldTransform(transform); - const auto& snapshot = submission.GetSnapshots().front(); - EXPECT_FLOAT_EQ(snapshot->GetWorldTransform()[3].x, 0.0f); - EXPECT_FLOAT_EQ(snapshot->GetWorldTransform()[3].y, 0.0f); - EXPECT_FLOAT_EQ(snapshot->GetWorldTransform()[3].z, 0.0f); + 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); } TEST(AetherFrameSubmissionPublisherTest, PublishesOnlySealedSubmissions) diff --git a/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp b/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp index 072b291d..424a5f97 100644 --- a/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp @@ -8,11 +8,11 @@ using namespace Elixir::Aether; namespace { - Ref CaptureForTest(const SystemInstance& instance) + Ref CaptureForTest(const SystemInstance& instance) { FrameSubmission submission; EXPECT_TRUE(submission.Submit(instance)); - return submission.GetSnapshots().front(); + return submission.GetRenderProxies().front(); } Ref MakeCompiledSystem() @@ -70,13 +70,13 @@ TEST(AetherSystemInstanceTest, AppliesOverridesOnlyToExposedParameters) const auto snapshot = CaptureForTest(instance); EXPECT_EQ(snapshot->GetParameterRevision(), initialParameterRevision + 1); - const auto tint = snapshot->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 = snapshot->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); @@ -92,7 +92,7 @@ TEST(AetherSystemInstanceTest, ClearsOverridesAndRestoresCompiledDefaults) ASSERT_TRUE(instance.ClearParameterOverride("Tint")); EXPECT_FALSE(instance.ClearParameterOverride("Tint")); - const auto tint = CaptureForTest(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); @@ -116,7 +116,7 @@ TEST(AetherSystemInstanceTest, RetainsOnlyOverridesExposedByReplacementSystem) instance.SetCompiledSystem(replacementSystem); - const auto tint = CaptureForTest(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); From a96362fc5018d2fa7990ef9077e6fcdabf6e5767 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Fri, 7 Aug 2026 15:20:32 -0300 Subject: [PATCH 49/89] refactor(aether): guard instance publication Filter destroyed instances at publication, synchronize instance registration, and retire GPU allocations by internal instance key. --- Elixir/Source/Engine/Aether/FrameSubmission.h | 36 ++++++++++++++++++- Elixir/Source/Engine/Aether/Manager.cpp | 28 ++++++++++++--- Elixir/Source/Engine/Aether/Manager.h | 2 ++ Elixir/Source/Engine/Aether/Renderer.cpp | 8 ++--- Elixir/Source/Engine/Aether/Renderer.h | 2 +- .../Engine/Aether/FrameSubmissionTest.cpp | 16 +++++++++ 6 files changed, 82 insertions(+), 10 deletions(-) diff --git a/Elixir/Source/Engine/Aether/FrameSubmission.h b/Elixir/Source/Engine/Aether/FrameSubmission.h index 0e6b9e4d..52c37ef3 100644 --- a/Elixir/Source/Engine/Aether/FrameSubmission.h +++ b/Elixir/Source/Engine/Aether/FrameSubmission.h @@ -79,6 +79,27 @@ namespace Elixir::Aether } private: + 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; + } + void Seal() { m_IsSealed = true; } bool m_IsSealed = false; @@ -91,12 +112,25 @@ namespace Elixir::Aether { public: void Publish(Ref submission) + { + Publish(std::move(submission), [](const SSystemInstanceKey&) + { + return true; + }); + } + + 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(submission); + m_Published = std::move(filtered); } Ref Acquire() diff --git a/Elixir/Source/Engine/Aether/Manager.cpp b/Elixir/Source/Engine/Aether/Manager.cpp index 99fa43ce..35b288f3 100644 --- a/Elixir/Source/Engine/Aether/Manager.cpp +++ b/Elixir/Source/Engine/Aether/Manager.cpp @@ -41,6 +41,7 @@ namespace Elixir::Aether auto instance = CreateRef(std::move(system)); const auto key = instance->GetKey(); + const std::scoped_lock lock(m_InstancesMutex); const auto [_, inserted] = m_Instances.emplace(key, instance); EE_CORE_ASSERT(inserted, "Aether system instance UUID must be unique.") @@ -50,7 +51,8 @@ namespace Elixir::Aether bool Manager::DestroyInstance(const Ref& instance) { - if (!IsManagedInstance(instance)) return false; + const std::scoped_lock lock(m_InstancesMutex); + if (!IsManagedInstanceLocked(instance)) return false; const auto found = m_Instances.find(instance->GetKey()); if (found == m_Instances.end()) return false; @@ -75,12 +77,24 @@ namespace Elixir::Aether bool Manager::Submit(FrameSubmission& submission, const Ref& instance) const { - return IsManagedInstance(instance) && submission.Submit(*instance); + const std::scoped_lock lock(m_InstancesMutex); + return IsManagedInstanceLocked(instance) && submission.Submit(*instance); } void Manager::PublishFrameSubmission(Ref submission) { - m_FrameSubmissionPublisher.Publish(std::move(submission)); + EE_CORE_ASSERT(submission, "Aether frame submission cannot be null.") + + // Keep the registry lock until publishing is complete. DestroyInstance() + // takes the same lock before removing a published frame. + const std::lock_guard lock(m_InstancesMutex); + m_FrameSubmissionPublisher.Publish( + std::move(submission), + [this](const SSystemInstanceKey& key) + { + return m_Instances.contains(key); + } + ); } void Manager::Render(const Camera& camera) @@ -107,10 +121,16 @@ namespace Elixir::Aether const auto instances = m_PendingRetirements.Drain(); for (const auto& instance : instances) - GetRenderer().Retire(*instance); + GetRenderer().Retire(instance->GetKey()); } bool Manager::IsManagedInstance(const Ref& instance) const + { + const std::scoped_lock lock(m_InstancesMutex); + return IsManagedInstanceLocked(instance); + } + + bool Manager::IsManagedInstanceLocked(const Ref& instance) const { if (!instance) return false; const auto found = m_Instances.find(instance->GetKey()); diff --git a/Elixir/Source/Engine/Aether/Manager.h b/Elixir/Source/Engine/Aether/Manager.h index 72132ddf..4ad32807 100644 --- a/Elixir/Source/Engine/Aether/Manager.h +++ b/Elixir/Source/Engine/Aether/Manager.h @@ -74,11 +74,13 @@ namespace Elixir::Aether void RetireDestroyedInstances(); bool IsManagedInstance(const Ref& instance) const; + bool IsManagedInstanceLocked(const Ref& instance) const; EffectMaterialResolver m_EffectMaterials; MaterialSystem& m_MaterialSystem; std::unordered_map> m_Instances; + mutable std::mutex m_InstancesMutex; SystemInstanceRetirementQueue m_PendingRetirements; FrameSubmissionPublisher m_FrameSubmissionPublisher; diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Renderer.cpp index 39d1fc95..bd3fec54 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Renderer.cpp @@ -290,16 +290,16 @@ namespace Elixir::Aether EndRendering(cmd); } - void Renderer::Retire(const SystemInstance& instance) + void Renderer::Retire(const SSystemInstanceKey& key) { - const auto found = m_InstanceRecords.find(instance.GetKey()); + const auto found = m_InstanceRecords.find(key); if (found == m_InstanceRecords.end()) return; QueueRetirement(found->second.Allocation); m_InstanceRecords.erase(found); - m_AllocationFailures.erase(instance.GetKey()); - m_UnsupportedParticleStateLayoutInstances.erase(instance.GetKey()); + m_AllocationFailures.erase(key); + m_UnsupportedParticleStateLayoutInstances.erase(key); } const SParticleSubmissionMetrics& Renderer::GetLastSubmissionMetrics() const diff --git a/Elixir/Source/Engine/Aether/Renderer.h b/Elixir/Source/Engine/Aether/Renderer.h index c8e9495c..5fef4575 100644 --- a/Elixir/Source/Engine/Aether/Renderer.h +++ b/Elixir/Source/Engine/Aether/Renderer.h @@ -162,7 +162,7 @@ namespace Elixir::Aether // 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); + void Retire(const SSystemInstanceKey& key); // Read only at the frame boundary after Render() returns. const SParticleSubmissionMetrics& GetLastSubmissionMetrics() const; diff --git a/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp b/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp index b5476b98..25aceb27 100644 --- a/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp +++ b/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp @@ -113,5 +113,21 @@ TEST(AetherFrameSubmissionPublisherTest, RemovesDestroyedInstanceFromPublishedFr publisher.Publish(submission); publisher.Remove(instance); + EXPECT_TRUE(publisher.Acquire()->IsEmpty()); +} + +TEST(AetherFrameSubmissionTest, FiltersInstanceRejectedAtPublication) +{ + const auto system = CreateRef(); + const SystemInstance instance{ system }; + const auto submission = CreateRef(); + FrameSubmissionPublisher publisher; + + ASSERT_TRUE(submission->Submit(instance)); + publisher.Publish(submission, [](const SSystemInstanceKey&) + { + return false; + }); + EXPECT_TRUE(publisher.Acquire()->IsEmpty()); } \ No newline at end of file From 83a78ca9b969928221be7defed6a73a6f17ddda4 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Fri, 7 Aug 2026 15:20:49 -0300 Subject: [PATCH 50/89] test(aether): cover instance frame handoff Cover immutable proxy publication, concurrent overrides, and retirement queue draining. --- .../Engine/Aether/FrameSubmissionTest.cpp | 51 ++++++++++++++++++- .../SystemInstanceRetirementQueueTest.cpp | 44 ++++++++++++++++ .../Engine/Aether/SystemInstanceTest.cpp | 41 +++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) diff --git a/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp b/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp index 25aceb27..924f4252 100644 --- a/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp +++ b/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp @@ -1,5 +1,10 @@ #include +#include +#include +#include +#include + #include using namespace Elixir; @@ -21,6 +26,13 @@ concept HasFrameSnapshots = requires(const T& submission) static_assert(!HasFrameSnapshots); +static_assert( + std::same_as< + decltype(std::declval().GetRenderProxies()), + const std::vector>& + > +); + TEST(AetherFrameSubmissionTest, RetainsEachSystemInstanceAtMostOnce) { const auto compiledSystem = CreateRef(); @@ -116,7 +128,7 @@ TEST(AetherFrameSubmissionPublisherTest, RemovesDestroyedInstanceFromPublishedFr EXPECT_TRUE(publisher.Acquire()->IsEmpty()); } -TEST(AetherFrameSubmissionTest, FiltersInstanceRejectedAtPublication) +TEST(AetherFrameSubmissionPublisherTest, FiltersInstanceRejectedAtPublication) { const auto system = CreateRef(); const SystemInstance instance{ system }; @@ -130,4 +142,41 @@ TEST(AetherFrameSubmissionTest, FiltersInstanceRejectedAtPublication) }); EXPECT_TRUE(publisher.Acquire()->IsEmpty()); +} + +TEST(AetherFrameSubmissionPublisherTest, PublishesAndAcquiresSealedFramesConcurrently) +{ + const auto system = CreateRef(); + const SystemInstance instance{ system }; + 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); } \ No newline at end of file diff --git a/Elixir/Tests/Engine/Aether/SystemInstanceRetirementQueueTest.cpp b/Elixir/Tests/Engine/Aether/SystemInstanceRetirementQueueTest.cpp index cf9c6f74..e8c7cf8e 100644 --- a/Elixir/Tests/Engine/Aether/SystemInstanceRetirementQueueTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemInstanceRetirementQueueTest.cpp @@ -1,5 +1,10 @@ #include +#include +#include +#include +#include + #include using namespace Elixir; @@ -21,4 +26,43 @@ TEST(SystemInstanceRetirementQueueTest, TransfersPendingInstancesExactlyOnce) EXPECT_EQ(retired[0], first); EXPECT_EQ(retired[1], second); EXPECT_TRUE(queue.Drain().empty()); +} + +TEST(SystemInstanceRetirementQueueTest, DrainsDestroyRequestsEnqueuedDuringUpdate) +{ + constexpr uint32_t destroyRequestCount = 256; + const auto system = CreateRef(); + std::vector> instances; + instances.reserve(destroyRequestCount); + + for (uint32_t instance = 0; instance < destroyRequestCount; ++instance) + instances.push_back(CreateRef(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); } \ No newline at end of file diff --git a/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp b/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp index 424a5f97..3b138a01 100644 --- a/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp @@ -1,11 +1,22 @@ #include +#include +#include + #include #include using namespace Elixir; using namespace Elixir::Aether; +template +concept HasPublicSnapshotCapture = requires(const T& instance) +{ + instance.CaptureSnapshot(); +}; + +static_assert(!HasPublicSnapshotCapture); + namespace { Ref CaptureForTest(const SystemInstance& instance) @@ -140,3 +151,33 @@ TEST(AetherSystemInstanceTest, StoresWorldTransformWithoutChangingCompiledSystem EXPECT_FLOAT_EQ(snapshot->GetWorldTransform()[3].z, -3.0f); EXPECT_EQ(&snapshot->GetCompiledSystem(), compiledSystem.get()); } + +TEST(AetherSystemInstanceTest, KeepsCapturedProxyImmutableDuringConcurrentOverrides) +{ + const auto system = MakeCompiledSystem(); + SystemInstance instance{ system }; + 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); +} \ No newline at end of file From 4be591a449e5bbcac8a0e80cf1df4173f70ffab0 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Fri, 7 Aug 2026 15:37:58 -0300 Subject: [PATCH 51/89] refactor(aether): make effect loading static Mark stateless effect loading as static and align the hash macro formatting. --- Elixir/Source/Engine/Aether/Manager.cpp | 2 +- Elixir/Source/Engine/Aether/Manager.h | 2 +- Elixir/Source/Engine/Core/Core.h | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Elixir/Source/Engine/Aether/Manager.cpp b/Elixir/Source/Engine/Aether/Manager.cpp index 35b288f3..f14e7a76 100644 --- a/Elixir/Source/Engine/Aether/Manager.cpp +++ b/Elixir/Source/Engine/Aether/Manager.cpp @@ -19,7 +19,7 @@ namespace Elixir::Aether Manager::~Manager() = default; - Ref Manager::LoadEffect(const std::filesystem::path& filepath) const + Ref Manager::LoadEffect(const std::filesystem::path& filepath) { return LoadEffectFile(filepath); } diff --git a/Elixir/Source/Engine/Aether/Manager.h b/Elixir/Source/Engine/Aether/Manager.h index 4ad32807..704e47fd 100644 --- a/Elixir/Source/Engine/Aether/Manager.h +++ b/Elixir/Source/Engine/Aether/Manager.h @@ -42,7 +42,7 @@ namespace Elixir::Aether Manager(Manager&&) = delete; Manager& operator=(Manager&&) = delete; - Ref LoadEffect(const std::filesystem::path& filepath) const; + static Ref LoadEffect(const std::filesystem::path& filepath); Ref Compile(System& system) const; diff --git a/Elixir/Source/Engine/Core/Core.h b/Elixir/Source/Engine/Core/Core.h index 4f19100f..8b234350 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()); \ } \ }; \ } From 2aa7d926e045f648fcd01d8916ae236dc2836a50 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Sun, 9 Aug 2026 01:06:54 -0300 Subject: [PATCH 52/89] docs(aether): document runtime entities Add Doxygen contracts for the authored system, runtime instances, frame handoff, and retirement flow. Clarify internal ownership checks and make frame-submission creation independent of Manager state. --- Elixir/Source/Engine/Aether/Emitter.cpp | 46 ++-- Elixir/Source/Engine/Aether/Emitter.h | 217 ++++++++++++++++-- Elixir/Source/Engine/Aether/FrameSubmission.h | 107 ++++++++- Elixir/Source/Engine/Aether/Manager.cpp | 12 +- Elixir/Source/Engine/Aether/Manager.h | 153 +++++++++++- Elixir/Source/Engine/Aether/System.h | 105 ++++++++- .../Source/Engine/Aether/SystemInstance.cpp | 28 +-- Elixir/Source/Engine/Aether/SystemInstance.h | 131 ++++++++++- .../Engine/Aether/SystemInstanceRenderProxy.h | 56 ++++- .../Aether/SystemInstanceRetirementQueue.h | 27 ++- 10 files changed, 783 insertions(+), 99 deletions(-) diff --git a/Elixir/Source/Engine/Aether/Emitter.cpp b/Elixir/Source/Engine/Aether/Emitter.cpp index 80664af4..6740191f 100644 --- a/Elixir/Source/Engine/Aether/Emitter.cpp +++ b/Elixir/Source/Engine/Aether/Emitter.cpp @@ -13,29 +13,6 @@ namespace Elixir::Aether m_MaxParticles(maxParticles), m_SpawnRate(spawnRate) {} - void Emitter::SetBurst(const uint32_t count, const float intervalSeconds) - { - m_BurstCount = count; - m_BurstIntervalSeconds = intervalSeconds; - } - - void Emitter::SetTriggerEmitter(std::string emitterName, const float delaySeconds) - { - m_TriggerEmitterName = std::move(emitterName); - m_TriggerDelaySeconds = delaySeconds; - } - - void Emitter::SetMaterial(const Ref& material) - { - if (!material) - { - EE_CORE_ERROR("Trying to set a null material to emitter.") - return; - } - - SetMaterial(material->CreateInstance()); - } - SCompiledEmitter Emitter::Compile( const ParameterStore& paramStore, const std::vector& params, @@ -416,4 +393,27 @@ namespace Elixir::Aether return emitter; } + + 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; + m_BurstIntervalSeconds = intervalSeconds; + } + + void Emitter::SetTriggerEmitter(std::string emitterName, const float delaySeconds) + { + m_TriggerEmitterName = std::move(emitterName); + m_TriggerDelaySeconds = delaySeconds; + } } diff --git a/Elixir/Source/Engine/Aether/Emitter.h b/Elixir/Source/Engine/Aether/Emitter.h index c2ddf1f1..e5e7406d 100644 --- a/Elixir/Source/Engine/Aether/Emitter.h +++ b/Elixir/Source/Engine/Aether/Emitter.h @@ -14,6 +14,19 @@ namespace Elixir::Aether { class ParameterStore; + /** + * @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; @@ -50,7 +63,7 @@ namespace Elixir::Aether return Id == other.Id; } - auto GetHashParams() const + UUID GetHashParams() const { return Id; } @@ -61,14 +74,28 @@ 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 { 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); @@ -78,6 +105,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) { @@ -87,6 +124,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) { @@ -96,16 +143,20 @@ namespace Elixir::Aether return ref; } - EParticleRenderMode GetRenderMode() const { return m_RenderMode; } - void SetRenderMode(const EParticleRenderMode mode) { m_RenderMode = mode; } - - EParticleSimulationSpace GetSimulationSpace() const { return m_SimulationSpace; } - void SetSimulationSpace(const EParticleSimulationSpace space) { m_SimulationSpace = space; } - - void SetBurst(uint32_t count, float intervalSeconds); - - void SetTriggerEmitter(std::string emitterName, float delaySeconds); - + /** + * @brief Compiles this emitter into GPU-ready data. + * + * The method resolves parameter bindings, converts modules into GPU + * operations, resolves the selected material, and records operation ranges. + * + * @param paramStore Parent system parameter store. + * @param params Compiled parameter table for the parent system. + * @param ops Output operation stream to append to. + * @param materialResolver Resolves the selected material instance. + * @return Immutable compiled data for this emitter. + * + * @warning The selected material must support the current render mode. + */ SCompiledEmitter Compile( const ParameterStore& paramStore, const std::vector& params, @@ -113,37 +164,171 @@ namespace Elixir::Aether MaterialResolver& materialResolver ) const; + /** + * @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; } + + /** + * @brief Returns the maximum particle capacity. + * @return Maximum number of particles owned by this emitter. + */ uint32_t GetMaxParticles() const { return m_MaxParticles; } + /** + * @brief Returns the material data parsed from an effect asset. + * @return The authored material definition, when one exists. + * @note This is effect-format data, not a Material instance. + */ const std::optional& GetMaterialDefinition() const { return m_MaterialDefinition; } + /** + * @brief Stores material data parsed from an effect asset. + * @param definition Effect-format material data for this emitter. + * @note EffectMaterialResolver converts this data into a material instance. + */ void SetMaterialDefinition(SParticleMaterialDefinition definition) { m_MaterialDefinition = std::move(definition); } + /** + * @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); + + /** + * @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: @@ -159,7 +344,7 @@ namespace Elixir::Aether 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 index 52c37ef3..82e21098 100644 --- a/Elixir/Source/Engine/Aether/FrameSubmission.h +++ b/Elixir/Source/Engine/Aether/FrameSubmission.h @@ -8,13 +8,34 @@ namespace Elixir::Aether { - // Immutable system instance states selected for one rendering frame. - // Submit() captures the state exactly once; an instance can be selected - // at most once per frame. + /** + * @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 submission is sealed or already contains instance. + */ bool Submit(const SystemInstance& instance) { if (m_IsSealed) return false; @@ -27,7 +48,13 @@ namespace Elixir::Aether return true; } - // Drop a captured state before the manager retires its GPU allocation. + /** + * @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; @@ -45,12 +72,23 @@ namespace Elixir::Aether 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(); @@ -67,18 +105,35 @@ namespace Elixir::Aether 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 in submission order. + */ 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 { @@ -100,6 +155,7 @@ namespace Elixir::Aether return copy; } + // Prevents further instance additions and removals. void Seal() { m_IsSealed = true; } bool m_IsSealed = false; @@ -107,10 +163,26 @@ namespace Elixir::Aether std::unordered_set m_InstanceKeys; }; - // Short synchronized handoff between the submission producer and renderer. + /** + * @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&) @@ -119,6 +191,18 @@ namespace Elixir::Aether }); } + /** + * @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) { @@ -133,12 +217,25 @@ namespace Elixir::Aether 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); diff --git a/Elixir/Source/Engine/Aether/Manager.cpp b/Elixir/Source/Engine/Aether/Manager.cpp index f14e7a76..9302fa1d 100644 --- a/Elixir/Source/Engine/Aether/Manager.cpp +++ b/Elixir/Source/Engine/Aether/Manager.cpp @@ -52,7 +52,7 @@ namespace Elixir::Aether bool Manager::DestroyInstance(const Ref& instance) { const std::scoped_lock lock(m_InstancesMutex); - if (!IsManagedInstanceLocked(instance)) return false; + if (!IsManagedInstance(instance)) return false; const auto found = m_Instances.find(instance->GetKey()); if (found == m_Instances.end()) return false; @@ -70,7 +70,7 @@ namespace Elixir::Aether RetireDestroyedInstances(); } - Ref Manager::CreateFrameSubmission() const + Ref Manager::CreateFrameSubmission() { return CreateRef(); } @@ -78,7 +78,7 @@ namespace Elixir::Aether bool Manager::Submit(FrameSubmission& submission, const Ref& instance) const { const std::scoped_lock lock(m_InstancesMutex); - return IsManagedInstanceLocked(instance) && submission.Submit(*instance); + return IsManagedInstance(instance) && submission.Submit(*instance); } void Manager::PublishFrameSubmission(Ref submission) @@ -125,12 +125,6 @@ namespace Elixir::Aether } bool Manager::IsManagedInstance(const Ref& instance) const - { - const std::scoped_lock lock(m_InstancesMutex); - return IsManagedInstanceLocked(instance); - } - - bool Manager::IsManagedInstanceLocked(const Ref& instance) const { if (!instance) return false; const auto found = m_Instances.find(instance->GetKey()); diff --git a/Elixir/Source/Engine/Aether/Manager.h b/Elixir/Source/Engine/Aether/Manager.h index 704e47fd..904e4c43 100644 --- a/Elixir/Source/Engine/Aether/Manager.h +++ b/Elixir/Source/Engine/Aether/Manager.h @@ -22,12 +22,36 @@ namespace Elixir::Aether class Renderer; struct SParticleSubmissionMetrics; - // Application-scoped entry point for effect assets and their immutable - // runtime payloads. It owns the particle renderer, while the renderer - // retains GPU allocation, synchronization, and draw implementation. + /** + * @brief Coordinates Aether effect compilation, runtime instances, 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. + * + * The manager owns the Aether renderer. The renderer owns GPU allocations, + * synchronization, simulation and draw execution. + * + * A frame producer creates and fills a FrameSubmission. Publishing the + * submission transfers an immutable view of its instances to the render path. + * + * @note The GraphicsContext, ShaderLoader, 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 Provides graphics resources and frame synchronization. + * @param shaderLoader Loads the shaders required by the particle renderer. + * @param materialRegistry Stores default and effect-generated materials. + * @param materialSystem Resolves material instances for rendering. + */ Manager( const GraphicsContext* context, const ShaderLoader* shaderLoader, @@ -35,6 +59,11 @@ namespace Elixir::Aether MaterialSystem& materialSystem ); + /** + * @brief Destroys the manager and its owned renderer. + * + * Runtime instances must not be used after the manager is destroyed. + */ ~Manager(); Manager(const Manager&) = delete; @@ -42,39 +71,139 @@ namespace Elixir::Aether 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 Resolves materials and compiles a system for runtime use. + * + * The method resolves effect-authored materials definitions before compiling + * the system into an immutable SCompiledSystem. + * + * @param system Mutable system authoring data to compile. + * @return The compiled system, or null when material resolution fails. + * + * @note This method assigns a default material when emitters have no + * explicit material. + */ Ref Compile(System& system) const; - // The returned runtime instance is configured through its public API. - // Manager retains registration and GPU lifetime ownership. + /** + * @brief Creates and registers a runtime instance of a compiled system. + * + * The returned instance exposes the runtime API for transforms and + * parameter overrides. The manager retains registration and GPU lifetime + * ownership. + * + * @param system Immutable compiled system data. + * @return The registered runtime instance. + * + * @pre system is not null. + */ Ref CreateInstance(Ref system); - // Detaches the instance from future frames immediately. Its GPU - // allocation is retired from BeginFrame on the render thread. + /** + * @brief Detaches a runtime instance from future frames. + * + * The method removes the instance from the manager and from the published + * submission. The renderer retires its GPU allocation during a later frame + * and releases it after the required GPU fence completes. + * + * @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. + * + * @warning Do not submit the instance after this method returns true. + */ bool DestroyInstance(const Ref& instance); + /** + * @brief Starts an Aether render frame. + * + * Updates renderer frame state and forwards pending instance retirements to + * the renderer. + * + * @param timestep Elapsed time for the current frame. + * + * @note Call this method from the render-frame path. + */ void BeginFrame(const Timestep& timestep); - // A submission is built by one producer thread, then atomically - // published for the renderer thread to consume. - Ref CreateFrameSubmission() const; - + /** + * @brief Creates an empty mutable frame submission. + * + * A single producer fills the submission with managed system instances, + * then publishes it for rendering. + * + * @return A new unsealed frame submission. + */ + static Ref CreateFrameSubmission() ; + + /** + * @brief Captures an instance for a frame submission. + * + * The method captures the instance's immutable render proxy. It does not + * publish the submission. + * + * @param submission Submission to update. + * @param instance Registered runtime instance to capture. + * @return True when the instance was captured. + * @return False when the instance is unmanaged, null, duplicated, or the + * submission is sealed. + */ bool Submit(FrameSubmission& submission, const Ref& instance) const; + /** + * @brief Publishes a completed frame submission for rendering. + * + * The method seals the submission and removes instances that were detached + * before publication. The renderer consumes the latest published + * submission. + * + * @param submission Submission to publish. + * + * @pre submission is not null. + * @warning Do not modify the submission after publishing it. + */ void PublishFrameSubmission(Ref submission); + /** + * @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 metrics for the most recently rendered particle frame. + * + * @return Read-only metrics collected during the latest Render() call. + * + * @note Read the result at a frame boundary after rendering completes. + */ const SParticleSubmissionMetrics& GetLastSubmissionMetrics() const; private: + // Returns the owned renderer and verifies that construction completed. Renderer& GetRenderer() const; + // Forwards detached instances to the renderer for fence-safe GPU retirement. void RetireDestroyedInstances(); + // Checks ownership while the instance registry mutex is already held. bool IsManagedInstance(const Ref& instance) const; - bool IsManagedInstanceLocked(const Ref& instance) const; EffectMaterialResolver m_EffectMaterials; diff --git a/Elixir/Source/Engine/Aether/System.h b/Elixir/Source/Engine/Aether/System.h index 3e1aae1e..6ae9d6e6 100644 --- a/Elixir/Source/Engine/Aether/System.h +++ b/Elixir/Source/Engine/Aether/System.h @@ -14,6 +14,24 @@ namespace Elixir::Aether { class EffectMaterialResolver; + /** + * @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; @@ -21,12 +39,16 @@ namespace Elixir::Aether float DelaySeconds = 0.0f; }; - struct SExposedParameter - { - std::string Name; - uint32_t ParameterIndex = 0; - }; - + /** + * @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; @@ -48,11 +70,29 @@ namespace Elixir::Aether uint32_t TotalMaxParticles = 0; }; + /** + * @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 { friend class EffectMaterialResolver; public: + /** + * @brief Creates an empty particle effect definition. + * + * @param name Display name for the effect. + */ explicit System(const std::string& name); System(System&&) = default; @@ -61,15 +101,68 @@ namespace Elixir::Aether System(const System&) = delete; System& operator=(const System&) = delete; + /** + * @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); + + /** + * @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 Compiles the authored effect into immutable runtime data. + * + * The method compiles system and emitter parameters, bake curves into + * parameter chunks, compile emitter modules into GPU operations, resolves + * material render proxies, and builds trigger targets. + * + * @param materialResolver Resolves emitter material instances for rendering. + * @return Immutable data for a SystemInstance and the particle renderer. + * + * @warning Any later change to this System requires a new compilation. + */ SCompiledSystem Compile(MaterialResolver& materialResolver) const; + /** + * @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-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: diff --git a/Elixir/Source/Engine/Aether/SystemInstance.cpp b/Elixir/Source/Engine/Aether/SystemInstance.cpp index 899c1e41..68a65c2a 100644 --- a/Elixir/Source/Engine/Aether/SystemInstance.cpp +++ b/Elixir/Source/Engine/Aether/SystemInstance.cpp @@ -71,20 +71,6 @@ namespace Elixir::Aether ); } - void SystemInstance::SetWorldTransform(const glm::mat4& worldTransform) - { - const std::scoped_lock lock(m_SnapshotMutex); - - m_Snapshot = CreateSnapshot( - m_Key, - m_Snapshot->m_Revision, - m_Snapshot->m_ParameterRevision, - m_Snapshot->m_CompiledSystem, - worldTransform, - m_Snapshot->m_ParameterOverrides - ); - } - bool SystemInstance::SetParameterOverride( const std::string& name, const glm::vec4& value @@ -155,6 +141,20 @@ namespace Elixir::Aether ); } + void SystemInstance::SetWorldTransform(const glm::mat4& worldTransform) + { + const std::scoped_lock lock(m_SnapshotMutex); + + m_Snapshot = CreateSnapshot( + m_Key, + m_Snapshot->m_Revision, + m_Snapshot->m_ParameterRevision, + m_Snapshot->m_CompiledSystem, + worldTransform, + m_Snapshot->m_ParameterOverrides + ); + } + Ref SystemInstance::CaptureSnapshot() const { const std::scoped_lock lock(m_SnapshotMutex); diff --git a/Elixir/Source/Engine/Aether/SystemInstance.h b/Elixir/Source/Engine/Aether/SystemInstance.h index e82aa8c0..b6437724 100644 --- a/Elixir/Source/Engine/Aether/SystemInstance.h +++ b/Elixir/Source/Engine/Aether/SystemInstance.h @@ -9,6 +9,15 @@ namespace Elixir::Aether class Manager; class Renderer; + /** + * @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; @@ -29,7 +38,19 @@ namespace Elixir::Aether using ParameterOverridesMap = std::unordered_map; - // Immutable runtime state captured by one rendering frame. + /** + * @brief Stores one immutable view of a SystemInstance. + * + * A snapshot contains the compiled system, world transform, and parameter + * overrides 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 SystemInstance; @@ -38,6 +59,19 @@ namespace Elixir::Aether friend class Renderer; 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 overrides Named parameter overrides selected by the instance. + * + * @pre system is not null. + * @pre overrides is not null. + */ SystemInstanceSnapshot( SSystemInstanceKey key, uint32_t revision, @@ -47,14 +81,39 @@ namespace Elixir::Aether Ref overrides ); + /** + * @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; } + + /** + * @brief Returns the selected world transform. + * @return World transform stored in this snapshot. + */ const glm::mat4& GetWorldTransform() const { return m_WorldTransform; } private: + // Returns the internal identity used by frame and renderer bookkeeping. const SSystemInstanceKey& GetKey() const { return m_Key; } - const Ref& GetRenderProxy() const { return m_RenderProxy; } + + // 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; @@ -65,8 +124,20 @@ namespace Elixir::Aether Ref m_RenderProxy; }; - // Runtime identity and immutable compiled payload selection. - // GPU allocations belong to Renderer::ParticleResourcePool, never here. + /** + * @brief Represents one runtime use of a compiled Aether system. + * + * SystemInstance owns mutable runtime choices: the selected compiled system, + * world transform, and exposed parameter overrides. 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 FrameSubmission; @@ -75,24 +146,68 @@ namespace Elixir::Aether friend class Renderer; public: + /** + * @brief Creates a runtime instance for a compiled system. + * + * @param system Immutable compiled system to select initially. + * + * @pre system is not null. + */ explicit SystemInstance(Ref system); + SystemInstance(const SystemInstance&) = delete; SystemInstance& operator=(const SystemInstance&) = delete; SystemInstance(SystemInstance&&) = delete; SystemInstance& operator=(SystemInstance&&) = delete; + /** + * @brief Replaces the selected compiled system. + * + * Compatible parameter overrides are preserved. Overrides that do not + * exist in the replacement system are removed. + * + * @param system Replacement compiled system. + * + * @pre system is not null. + */ void SetCompiledSystem(Ref system); - void SetWorldTransform(const glm::mat4& worldTransform); - + /** + * @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(); + /** + * @brief Replaces the world transform for future frame submissions. + * @param worldTransform Transform applied to this system instance. + */ + void SetWorldTransform(const glm::mat4& worldTransform); + private: - // Acquires one immutable view without retaining the instance lock. + // Captures the current immutable state without retaining the mutex. Ref CaptureSnapshot() const; + // Builds a snapshot and its renderer-facing proxy from resolved state. static Ref CreateSnapshot( const SSystemInstanceKey& key, uint32_t revision, @@ -102,8 +217,10 @@ namespace Elixir::Aether Ref overrides ); + // Checks whether a parameter can be changed through the runtime API. static bool IsExposedParameter(const SCompiledSystem& system, std::string_view name); + // Returns the internal identity used by Manager and Renderer. const SSystemInstanceKey& GetKey() const { return m_Key; } SSystemInstanceKey m_Key; diff --git a/Elixir/Source/Engine/Aether/SystemInstanceRenderProxy.h b/Elixir/Source/Engine/Aether/SystemInstanceRenderProxy.h index 2634fc2a..ceef5324 100644 --- a/Elixir/Source/Engine/Aether/SystemInstanceRenderProxy.h +++ b/Elixir/Source/Engine/Aether/SystemInstanceRenderProxy.h @@ -4,7 +4,19 @@ namespace Elixir::Aether { - // Renderer-facing state resolved from one immutable instance snapshot. + /** + * @brief Provides immutable SystemInstance data to the particle renderer. + * + * SystemInstanceSnapshot creates this proxy from one compiled system, world + * transform, and parameter-override set. FrameSubmission stores the proxy, so + * Renderer never reads mutable SystemInstance state. + * + * The proxy resolves each compiled parameter to either its instance override or + * its compiled default value. Its parameter table has the same order as + * SCompiledSystem::Parameters. + * + * @thread_safety Immutable after construction. + */ class SystemInstanceRenderProxy { friend class SystemInstanceSnapshot; @@ -12,11 +24,17 @@ namespace Elixir::Aether friend class Renderer; public: - uint32_t GetRevision() const { return m_Revision; } - uint32_t GetParameterRevision() const { return m_ParameterRevision; } - const SCompiledSystem& GetCompiledSystem() const { return *m_CompiledSystem; } - const glm::mat4& GetWorldTransform() const { return m_WorldTransform; } - + /** + * @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( @@ -28,7 +46,32 @@ namespace Elixir::Aether : 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: + // Resolves compiled defaults and instance overrides into a dense parameter table. SystemInstanceRenderProxy( const SSystemInstanceKey& key, uint32_t revision, @@ -38,6 +81,7 @@ namespace Elixir::Aether const ParameterOverridesMap& overrides ); + // Returns the internal identity used by Renderer instance records. const SSystemInstanceKey& GetKey() const { return m_Key; } SSystemInstanceKey m_Key; diff --git a/Elixir/Source/Engine/Aether/SystemInstanceRetirementQueue.h b/Elixir/Source/Engine/Aether/SystemInstanceRetirementQueue.h index 48cd39de..623ad79c 100644 --- a/Elixir/Source/Engine/Aether/SystemInstanceRetirementQueue.h +++ b/Elixir/Source/Engine/Aether/SystemInstanceRetirementQueue.h @@ -5,10 +5,28 @@ namespace Elixir::Aether { - // Cross-shared handoff for instances whose GPU allocations must be retired. + /** + * @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.") @@ -17,6 +35,13 @@ namespace Elixir::Aether 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); From fb2c0f7af3ffdcfef6abb76c1faa323924524a0c Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Sun, 9 Aug 2026 13:11:47 -0300 Subject: [PATCH 53/89] refactor(aether): organize runtime namespaces Move Aether implementation into Core, Effect, Modules, and Rendering namespaces.\n\nUpdate imports and tests to use the new ownership boundaries. --- Elixir/Source/Engine.h | 2 +- .../Aether/{ => Core}/ColorCurveStore.cpp | 2 +- .../Aether/{ => Core}/ColorCurveStore.h | 2 +- .../Engine/Aether/{ => Core}/CurveStore.cpp | 2 +- .../Engine/Aether/{ => Core}/CurveStore.h | 4 +- .../Aether/{ => Core}/ParameterStore.cpp | 2 +- .../Engine/Aether/{ => Core}/ParameterStore.h | 2 +- .../Engine/Aether/{ => Core}/Particle.h | 29 ++++++++++++-- .../{ => Core}/ParticleResourcePool.cpp | 2 +- .../Aether/{ => Core}/ParticleResourcePool.h | 4 +- .../Aether/{ => Core}/ParticleStateLayout.cpp | 2 +- .../Aether/{ => Core}/ParticleStateLayout.h | 4 +- .../Engine/Aether/{ => Effect}/Effect.cpp | 36 ++++++------------ .../Engine/Aether/{ => Effect}/Effect.h | 2 +- .../MaterialDescription.h} | 4 +- .../MaterialFactory.cpp} | 30 +++++++-------- .../Engine/Aether/Effect/MaterialFactory.h | 16 ++++++++ .../MaterialResolver.cpp} | 22 +++++------ .../Engine/Aether/Effect/MaterialResolver.h | 19 ++++++++++ .../Engine/Aether/EffectMaterialResolver.h | 23 ----------- Elixir/Source/Engine/Aether/Emitter.cpp | 4 +- Elixir/Source/Engine/Aether/Emitter.h | 32 ++++++++-------- Elixir/Source/Engine/Aether/Manager.cpp | 23 ++++++----- Elixir/Source/Engine/Aether/Manager.h | 38 +++++++++++-------- .../Engine/Aether/{ => Modules}/Modules.cpp | 4 +- .../Engine/Aether/{ => Modules}/Modules.h | 32 ++-------------- .../Engine/Aether/ParticleMaterialFactory.h | 16 -------- .../Aether/{ => Rendering}/FrameSubmission.h | 9 ++--- .../Aether/{ => Rendering}/Renderer.cpp | 6 ++- .../Engine/Aether/{ => Rendering}/Renderer.h | 16 +++++--- .../SystemInstanceRenderProxy.cpp | 2 +- .../SystemInstanceRenderProxy.h | 4 +- .../SystemInstanceRetirementQueue.h | 2 +- Elixir/Source/Engine/Aether/System.h | 24 ++++++++---- .../Source/Engine/Aether/SystemInstance.cpp | 4 +- Elixir/Source/Engine/Aether/SystemInstance.h | 26 +++++++------ Elixir/Source/Engine/Core/UUID.h | 20 ++++++++++ .../Aether/EffectMaterialResolverTest.cpp | 9 +++-- .../Engine/Aether/FrameSubmissionTest.cpp | 5 ++- .../Aether/ParticleResourcePoolTest.cpp | 5 ++- .../SystemInstanceRetirementQueueTest.cpp | 5 ++- .../Engine/Aether/SystemInstanceTest.cpp | 5 ++- Elixir/Tests/Engine/Aether/SystemTest.cpp | 3 +- 43 files changed, 266 insertions(+), 237 deletions(-) rename Elixir/Source/Engine/Aether/{ => Core}/ColorCurveStore.cpp (94%) rename Elixir/Source/Engine/Aether/{ => Core}/ColorCurveStore.h (96%) rename Elixir/Source/Engine/Aether/{ => Core}/CurveStore.cpp (94%) rename Elixir/Source/Engine/Aether/{ => Core}/CurveStore.h (94%) rename Elixir/Source/Engine/Aether/{ => Core}/ParameterStore.cpp (97%) rename Elixir/Source/Engine/Aether/{ => Core}/ParameterStore.h (98%) rename Elixir/Source/Engine/Aether/{ => Core}/Particle.h (57%) rename Elixir/Source/Engine/Aether/{ => Core}/ParticleResourcePool.cpp (99%) rename Elixir/Source/Engine/Aether/{ => Core}/ParticleResourcePool.h (97%) rename Elixir/Source/Engine/Aether/{ => Core}/ParticleStateLayout.cpp (97%) rename Elixir/Source/Engine/Aether/{ => Core}/ParticleStateLayout.h (93%) rename Elixir/Source/Engine/Aether/{ => Effect}/Effect.cpp (97%) rename Elixir/Source/Engine/Aether/{ => Effect}/Effect.h (80%) rename Elixir/Source/Engine/Aether/{ParticleMaterialDefinition.h => Effect/MaterialDescription.h} (80%) rename Elixir/Source/Engine/Aether/{ParticleMaterialFactory.cpp => Effect/MaterialFactory.cpp} (69%) create mode 100644 Elixir/Source/Engine/Aether/Effect/MaterialFactory.h rename Elixir/Source/Engine/Aether/{EffectMaterialResolver.cpp => Effect/MaterialResolver.cpp} (57%) create mode 100644 Elixir/Source/Engine/Aether/Effect/MaterialResolver.h delete mode 100644 Elixir/Source/Engine/Aether/EffectMaterialResolver.h rename Elixir/Source/Engine/Aether/{ => Modules}/Modules.cpp (99%) rename Elixir/Source/Engine/Aether/{ => Modules}/Modules.h (96%) delete mode 100644 Elixir/Source/Engine/Aether/ParticleMaterialFactory.h rename Elixir/Source/Engine/Aether/{ => Rendering}/FrameSubmission.h (98%) rename Elixir/Source/Engine/Aether/{ => Rendering}/Renderer.cpp (99%) rename Elixir/Source/Engine/Aether/{ => Rendering}/Renderer.h (97%) rename Elixir/Source/Engine/Aether/{ => Rendering}/SystemInstanceRenderProxy.cpp (96%) rename Elixir/Source/Engine/Aether/{ => Rendering}/SystemInstanceRenderProxy.h (97%) rename Elixir/Source/Engine/Aether/{ => Rendering}/SystemInstanceRetirementQueue.h (98%) diff --git a/Elixir/Source/Engine.h b/Elixir/Source/Engine.h index bdf8b6fa..ddff6a12 100644 --- a/Elixir/Source/Engine.h +++ b/Elixir/Source/Engine.h @@ -60,4 +60,4 @@ #include #include -#include \ No newline at end of file +#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/Particle.h b/Elixir/Source/Engine/Aether/Core/Particle.h similarity index 57% rename from Elixir/Source/Engine/Aether/Particle.h rename to Elixir/Source/Engine/Aether/Core/Particle.h index 584d6a83..479c34dd 100644 --- a/Elixir/Source/Engine/Aether/Particle.h +++ b/Elixir/Source/Engine/Aether/Core/Particle.h @@ -1,8 +1,6 @@ #pragma once -#include - -namespace Elixir::Aether +namespace Elixir::Aether::Core { enum class EParticleAttribute : uint32_t { @@ -41,4 +39,29 @@ namespace Elixir::Aether { CoreV1 = 0 }; + + 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/ParticleResourcePool.cpp b/Elixir/Source/Engine/Aether/Core/ParticleResourcePool.cpp similarity index 99% rename from Elixir/Source/Engine/Aether/ParticleResourcePool.cpp rename to Elixir/Source/Engine/Aether/Core/ParticleResourcePool.cpp index 02b218e6..7da8426d 100644 --- a/Elixir/Source/Engine/Aether/ParticleResourcePool.cpp +++ b/Elixir/Source/Engine/Aether/Core/ParticleResourcePool.cpp @@ -1,7 +1,7 @@ #include "epch.h" #include "ParticleResourcePool.h" -namespace Elixir::Aether +namespace Elixir::Aether::Core { ParticleResourcePool::ParticleResourcePool( const SParticlePoolLimits& limits, diff --git a/Elixir/Source/Engine/Aether/ParticleResourcePool.h b/Elixir/Source/Engine/Aether/Core/ParticleResourcePool.h similarity index 97% rename from Elixir/Source/Engine/Aether/ParticleResourcePool.h rename to Elixir/Source/Engine/Aether/Core/ParticleResourcePool.h index b4a1018a..7de4a730 100644 --- a/Elixir/Source/Engine/Aether/ParticleResourcePool.h +++ b/Elixir/Source/Engine/Aether/Core/ParticleResourcePool.h @@ -1,9 +1,9 @@ #pragma once #include -#include +#include -namespace Elixir::Aether +namespace Elixir::Aether::Core { struct SBufferRange { diff --git a/Elixir/Source/Engine/Aether/ParticleStateLayout.cpp b/Elixir/Source/Engine/Aether/Core/ParticleStateLayout.cpp similarity index 97% rename from Elixir/Source/Engine/Aether/ParticleStateLayout.cpp rename to Elixir/Source/Engine/Aether/Core/ParticleStateLayout.cpp index 1bf99e7d..4355d336 100644 --- a/Elixir/Source/Engine/Aether/ParticleStateLayout.cpp +++ b/Elixir/Source/Engine/Aether/Core/ParticleStateLayout.cpp @@ -1,7 +1,7 @@ #include "epch.h" #include "ParticleStateLayout.h" -namespace Elixir::Aether +namespace Elixir::Aether::Core { ParticleStateLayoutRegistry::ParticleStateLayoutRegistry(uint32_t particleCapacity) { diff --git a/Elixir/Source/Engine/Aether/ParticleStateLayout.h b/Elixir/Source/Engine/Aether/Core/ParticleStateLayout.h similarity index 93% rename from Elixir/Source/Engine/Aether/ParticleStateLayout.h rename to Elixir/Source/Engine/Aether/Core/ParticleStateLayout.h index 4eeee551..654f9f04 100644 --- a/Elixir/Source/Engine/Aether/ParticleStateLayout.h +++ b/Elixir/Source/Engine/Aether/Core/ParticleStateLayout.h @@ -1,8 +1,8 @@ #pragma once -#include +#include -namespace Elixir::Aether +namespace Elixir::Aether::Core { // CoreV1 is six float4 values in both C++ and HLSL. constexpr uint32_t PARTICLE_STATE_CORE_V1_STRIDE = sizeof(glm::vec4) * 6; diff --git a/Elixir/Source/Engine/Aether/Effect.cpp b/Elixir/Source/Engine/Aether/Effect/Effect.cpp similarity index 97% rename from Elixir/Source/Engine/Aether/Effect.cpp rename to Elixir/Source/Engine/Aether/Effect/Effect.cpp index 8d7fe13c..f6579739 100644 --- a/Elixir/Source/Engine/Aether/Effect.cpp +++ b/Elixir/Source/Engine/Aether/Effect/Effect.cpp @@ -5,9 +5,10 @@ #include #include -#include +#include +#include -namespace Elixir::Aether +namespace Elixir::Aether::Effect { namespace { @@ -36,21 +37,6 @@ namespace Elixir::Aether return std::nullopt; } - EMaterialUsage GetParticleMaterialUsage(const EParticleRenderMode mode) - { - switch (mode) - { - case EParticleRenderMode::Sprite: - return EMaterialUsage::ParticleSprite; - case EParticleRenderMode::Ribbon: - return EMaterialUsage::ParticleRibbon; - case EParticleRenderMode::Mesh: - return EMaterialUsage::ParticleMesh; - } - - return EMaterialUsage::ParticleSprite; - } - // Parses an effect asset into a System. Every parsing step funnels // failures through Fail(), which logs and latches m_Failed; helpers // short-circuit once latched so a single root cause is reported and @@ -872,7 +858,7 @@ namespace Elixir::Aether return emitter.GetParameters().GetFloat4(field.Param, systemValue); } - std::optional ParseMaterial( + std::optional ParseMaterial( od::object& json, const Emitter& emitter, const System& system @@ -881,7 +867,7 @@ namespace Elixir::Aether auto field = json["material"]; if (field.error()) return std::nullopt; - SParticleMaterialDefinition definition{}; + SMaterialDescription desc{}; od::object material; if (field.get_object().get(material)) @@ -902,12 +888,12 @@ namespace Elixir::Aether system ); - definition.BaseColor = glm::vec3(color); - definition.Opacity = color.w; - definition.Emissive = glm::vec3(emissive); - definition.BaseColorTexturePath = ParseString(material, "texture"); + desc.BaseColor = glm::vec3(color); + desc.Opacity = color.w; + desc.Emissive = glm::vec3(emissive); + desc.BaseColorTexturePath = ParseString(material, "texture"); - return definition; + return desc; } void ParseEmitter(const Ref& system, od::object& json) @@ -962,7 +948,7 @@ namespace Elixir::Aether if (m_Failed) return; if (const auto material = ParseMaterial(json, emitter, *system)) - emitter.SetMaterialDefinition(std::move(*material)); + emitter.SetMaterialDescription(std::move(*material)); if (!spawnRate.Param.empty()) emitter.SetSpawnRateParamName(spawnRate.Param); diff --git a/Elixir/Source/Engine/Aether/Effect.h b/Elixir/Source/Engine/Aether/Effect/Effect.h similarity index 80% rename from Elixir/Source/Engine/Aether/Effect.h rename to Elixir/Source/Engine/Aether/Effect/Effect.h index ff74c561..523d3a1a 100644 --- a/Elixir/Source/Engine/Aether/Effect.h +++ b/Elixir/Source/Engine/Aether/Effect/Effect.h @@ -2,7 +2,7 @@ #include -namespace Elixir::Aether +namespace Elixir::Aether::Effect { ELIXIR_API Ref LoadEffectFile(const std::filesystem::path& filepath); } \ No newline at end of file diff --git a/Elixir/Source/Engine/Aether/ParticleMaterialDefinition.h b/Elixir/Source/Engine/Aether/Effect/MaterialDescription.h similarity index 80% rename from Elixir/Source/Engine/Aether/ParticleMaterialDefinition.h rename to Elixir/Source/Engine/Aether/Effect/MaterialDescription.h index f179dd77..0f2bef45 100644 --- a/Elixir/Source/Engine/Aether/ParticleMaterialDefinition.h +++ b/Elixir/Source/Engine/Aether/Effect/MaterialDescription.h @@ -2,10 +2,10 @@ #include -namespace Elixir::Aether +namespace Elixir::Aether::Effect { // Serialized authoring data local to the Aether effect format. - struct SParticleMaterialDefinition + struct SMaterialDescription { glm::vec3 BaseColor{ 1.0f }; float Opacity = 1.0f; diff --git a/Elixir/Source/Engine/Aether/ParticleMaterialFactory.cpp b/Elixir/Source/Engine/Aether/Effect/MaterialFactory.cpp similarity index 69% rename from Elixir/Source/Engine/Aether/ParticleMaterialFactory.cpp rename to Elixir/Source/Engine/Aether/Effect/MaterialFactory.cpp index 43589cd0..1742f17e 100644 --- a/Elixir/Source/Engine/Aether/ParticleMaterialFactory.cpp +++ b/Elixir/Source/Engine/Aether/Effect/MaterialFactory.cpp @@ -1,31 +1,31 @@ #include "epch.h" -#include "ParticleMaterialFactory.h" +#include "MaterialFactory.h" #include -namespace Elixir::Aether +namespace Elixir::Aether::Effect { - EMaterialUsage GetParticleMaterialUsage(const EParticleRenderMode mode) + EMaterialUsage GetMaterialUsage(const Core::EParticleRenderMode mode) { switch (mode) { - case EParticleRenderMode::Sprite: return EMaterialUsage::ParticleSprite; - case EParticleRenderMode::Ribbon: return EMaterialUsage::ParticleRibbon; - case EParticleRenderMode::Mesh: return EMaterialUsage::ParticleMesh; + 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 CreateParticleMaterial( + Ref CreateMaterial( std::string name, - const EParticleRenderMode renderMode, - const SParticleMaterialDefinition& definition + const Core::EParticleRenderMode renderMode, + const SMaterialDescription& desc ) { const auto material = CreateRef(std::move(name)); - const auto result = material->SetUsage(GetParticleMaterialUsage(renderMode), true); + const auto result = material->SetUsage(GetMaterialUsage(renderMode), true); EE_CORE_ASSERT(result, "Particle material usage must be enabled.") MaterialGraph graph; @@ -33,28 +33,28 @@ namespace Elixir::Aether const auto baseColor = graph.AddNode({ .Type = EMaterialNodeType::Constant, .OutputType = EMaterialGraphValueType::Float3, - .ConstantValue = { definition.BaseColor, 0.0f }, + .ConstantValue = { desc.BaseColor, 0.0f }, }); graph.SetChannel(EMaterialChannel::BaseColor, baseColor); const auto opacity = graph.AddNode({ .Type = EMaterialNodeType::Constant, .OutputType = EMaterialGraphValueType::Float, - .ConstantValue = { definition.Opacity, 0.0f, 0.0f, 0.0f }, + .ConstantValue = { desc.Opacity, 0.0f, 0.0f, 0.0f }, }); graph.SetChannel(EMaterialChannel::Opacity, opacity); const auto emissive = graph.AddNode({ .Type = EMaterialNodeType::Constant, .OutputType = EMaterialGraphValueType::Float3, - .ConstantValue = { definition.Emissive, 0.0f }, + .ConstantValue = { desc.Emissive, 0.0f }, }); graph.SetChannel(EMaterialChannel::Emissive, emissive); - if (renderMode == EParticleRenderMode::Sprite && !definition.BaseColorTexturePath.empty()) + if (renderMode == Core::EParticleRenderMode::Sprite && !desc.BaseColorTexturePath.empty()) { constexpr auto texParam = "BaseColorTexture"; - const auto tex = TextureLoader::Load(definition.BaseColorTexturePath); + const auto tex = TextureLoader::Load(desc.BaseColorTexturePath); material->DefineParameter(texParam, { .Kind = EMaterialParameterKind::Texture, .DefaultValue = SMaterialParam::MakeTexture(tex), diff --git a/Elixir/Source/Engine/Aether/Effect/MaterialFactory.h b/Elixir/Source/Engine/Aether/Effect/MaterialFactory.h new file mode 100644 index 00000000..56180620 --- /dev/null +++ b/Elixir/Source/Engine/Aether/Effect/MaterialFactory.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include + +namespace Elixir::Aether::Effect +{ + EMaterialUsage GetMaterialUsage(Core::EParticleRenderMode mode); + + Ref CreateMaterial( + std::string name, + Core::EParticleRenderMode renderMode, + const SMaterialDescription& desc + ); +} diff --git a/Elixir/Source/Engine/Aether/EffectMaterialResolver.cpp b/Elixir/Source/Engine/Aether/Effect/MaterialResolver.cpp similarity index 57% rename from Elixir/Source/Engine/Aether/EffectMaterialResolver.cpp rename to Elixir/Source/Engine/Aether/Effect/MaterialResolver.cpp index e564fc06..7ec11255 100644 --- a/Elixir/Source/Engine/Aether/EffectMaterialResolver.cpp +++ b/Elixir/Source/Engine/Aether/Effect/MaterialResolver.cpp @@ -1,20 +1,18 @@ #include "epch.h" -#include "EffectMaterialResolver.h" +#include "MaterialResolver.h" #include -#include +#include #include -#include "spdlog/fmt/bundled/base.h" - -namespace Elixir::Aether +namespace Elixir::Aether::Effect { - EffectMaterialResolver::EffectMaterialResolver(MaterialRegistry& registry) + MaterialResolver::MaterialResolver(MaterialRegistry& registry) : m_Registry(registry) {} - bool EffectMaterialResolver::Resolve(System& system) const + bool MaterialResolver::Resolve(const System& system) const { - for (const auto& emitter : system.m_Emitters) + for (const auto& emitter : system.GetEmitters()) { // A caller may replace an effect-authored instance before // Manager::Compile(). Do not overwrite that explicit choice. @@ -23,14 +21,14 @@ namespace Elixir::Aether Ref material; - if (const auto& definition = emitter->GetMaterialDefinition()) + if (const auto& desc = emitter->GetMaterialDescription()) { - const auto name = "Aether." + system.m_UUID.ToString() + "." + emitter->GetName(); + const auto name = "Aether." + system.GetId() + "." + emitter->GetName(); material = m_Registry.Find(name); if (!material) { - material = CreateParticleMaterial(name, emitter->GetRenderMode(), *definition); + material = CreateMaterial(name, emitter->GetRenderMode(), *desc); if (!m_Registry.Register(material)) { EE_CORE_ERROR("Aether material '{}' could not be registered.", name) @@ -40,7 +38,7 @@ namespace Elixir::Aether } else { - material = m_Registry.GetDefault(GetParticleMaterialUsage(emitter->GetRenderMode())); + material = m_Registry.GetDefault(GetMaterialUsage(emitter->GetRenderMode())); } emitter->SetMaterial(material); diff --git a/Elixir/Source/Engine/Aether/Effect/MaterialResolver.h b/Elixir/Source/Engine/Aether/Effect/MaterialResolver.h new file mode 100644 index 00000000..6745b58f --- /dev/null +++ b/Elixir/Source/Engine/Aether/Effect/MaterialResolver.h @@ -0,0 +1,19 @@ +#pragma once + +namespace Elixir { class MaterialRegistry; } +namespace Elixir::Aether { class System; } + +namespace Elixir::Aether::Effect +{ + // Resolves effect authoring data into emitter-owned material instances. + class ELIXIR_API MaterialResolver final + { + public: + explicit MaterialResolver(MaterialRegistry& registry); + + bool Resolve(const System& system) const; + + private: + MaterialRegistry& m_Registry; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Aether/EffectMaterialResolver.h b/Elixir/Source/Engine/Aether/EffectMaterialResolver.h deleted file mode 100644 index ae0b261b..00000000 --- a/Elixir/Source/Engine/Aether/EffectMaterialResolver.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -namespace Elixir -{ - class MaterialRegistry; -} - -namespace Elixir::Aether -{ - class System; - - // Resolves effect authoring data into emitter-owned material instances. - class ELIXIR_API EffectMaterialResolver final - { - public: - explicit EffectMaterialResolver(MaterialRegistry& registry); - - bool Resolve(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 6740191f..c1bb4f81 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( @@ -42,7 +40,7 @@ namespace Elixir::Aether { const auto proxy = materialResolver.Resolve(m_Material); if (!proxy || !proxy->GetCompiledMaterial()->SupportsUsage( - GetParticleMaterialUsage(m_RenderMode) + Effect::GetMaterialUsage(m_RenderMode) )) EE_CORE_ERROR( "Aether emitter '{}' material does not support its render mode.", diff --git a/Elixir/Source/Engine/Aether/Emitter.h b/Elixir/Source/Engine/Aether/Emitter.h index e5e7406d..04ad0173 100644 --- a/Elixir/Source/Engine/Aether/Emitter.h +++ b/Elixir/Source/Engine/Aether/Emitter.h @@ -1,19 +1,19 @@ #pragma once #include -#include -#include -#include -#include -#include -#include #include #include +#include +#include +#include +#include +#include +#include namespace Elixir::Aether { - class ParameterStore; - + using namespace Core; + using namespace Modules; /** * @brief Stores immutable GPU-ready data for one compiled emitter. * @@ -203,22 +203,22 @@ namespace Elixir::Aether /** * @brief Returns the material data parsed from an effect asset. - * @return The authored material definition, when one exists. + * @return The authored material description, when one exists. * @note This is effect-format data, not a Material instance. */ - const std::optional& GetMaterialDefinition() const + const std::optional& GetMaterialDescription() const { - return m_MaterialDefinition; + return m_MaterialDescription; } /** * @brief Stores material data parsed from an effect asset. - * @param definition Effect-format material data for this emitter. - * @note EffectMaterialResolver converts this data into a material instance. + * @param description Effect-format material data for this emitter. + * @note Effect::MaterialResolver converts this data into a material instance. */ - void SetMaterialDefinition(SParticleMaterialDefinition definition) + void SetMaterialDescription(Effect::SMaterialDescription description) { - m_MaterialDefinition = std::move(definition); + m_MaterialDescription = std::move(description); } /** @@ -336,7 +336,7 @@ namespace Elixir::Aether std::string m_Name; EParticleRenderMode m_RenderMode = EParticleRenderMode::Sprite; EParticleSimulationSpace m_SimulationSpace = EParticleSimulationSpace::World; - std::optional m_MaterialDefinition; + std::optional m_MaterialDescription; Ref m_Material; uint32_t m_MaxParticles; diff --git a/Elixir/Source/Engine/Aether/Manager.cpp b/Elixir/Source/Engine/Aether/Manager.cpp index 9302fa1d..49318b03 100644 --- a/Elixir/Source/Engine/Aether/Manager.cpp +++ b/Elixir/Source/Engine/Aether/Manager.cpp @@ -1,10 +1,12 @@ #include "epch.h" #include "Manager.h" -#include -#include #include #include +#include +#include + +using namespace Elixir::Aether::Effect; namespace Elixir::Aether { @@ -15,7 +17,7 @@ namespace Elixir::Aether MaterialSystem& materialSystem ) : m_EffectMaterials(materialRegistry), m_MaterialSystem(materialSystem), - m_Renderer(CreateScope(context, shaderLoader, materialSystem)) {} + m_Renderer(CreateScope(context, shaderLoader, materialSystem)) {} Manager::~Manager() = default; @@ -70,18 +72,21 @@ namespace Elixir::Aether RetireDestroyedInstances(); } - Ref Manager::CreateFrameSubmission() + Ref Manager::CreateFrameSubmission() { - return CreateRef(); + return CreateRef(); } - bool Manager::Submit(FrameSubmission& submission, const Ref& instance) const + bool Manager::Submit( + Rendering::FrameSubmission& submission, + const Ref& instance + ) const { const std::scoped_lock lock(m_InstancesMutex); return IsManagedInstance(instance) && submission.Submit(*instance); } - void Manager::PublishFrameSubmission(Ref submission) + void Manager::PublishFrameSubmission(Ref submission) { EE_CORE_ASSERT(submission, "Aether frame submission cannot be null.") @@ -105,12 +110,12 @@ namespace Elixir::Aether GetRenderer().Render(*submission, camera); } - const SParticleSubmissionMetrics& Manager::GetLastSubmissionMetrics() const + const Rendering::SParticleSubmissionMetrics& Manager::GetLastSubmissionMetrics() const { return GetRenderer().GetLastSubmissionMetrics(); } - Renderer& Manager::GetRenderer() const + Rendering::Renderer& Manager::GetRenderer() const { EE_CORE_ASSERT(m_Renderer, "Aether renderer is unavailable.") return *m_Renderer; diff --git a/Elixir/Source/Engine/Aether/Manager.h b/Elixir/Source/Engine/Aether/Manager.h index 904e4c43..fa3f1e7d 100644 --- a/Elixir/Source/Engine/Aether/Manager.h +++ b/Elixir/Source/Engine/Aether/Manager.h @@ -1,9 +1,9 @@ #pragma once -#include -#include #include -#include +#include +#include +#include namespace Elixir { @@ -15,13 +15,16 @@ namespace Elixir class MaterialRegistry; class MaterialResolver; class MaterialSystem; + + namespace Aether::Rendering + { + class Renderer; + struct SParticleSubmissionMetrics; + } } namespace Elixir::Aether { - class Renderer; - struct SParticleSubmissionMetrics; - /** * @brief Coordinates Aether effect compilation, runtime instances, and rendering. * @@ -145,7 +148,7 @@ namespace Elixir::Aether * * @return A new unsealed frame submission. */ - static Ref CreateFrameSubmission() ; + static Ref CreateFrameSubmission(); /** * @brief Captures an instance for a frame submission. @@ -159,7 +162,10 @@ namespace Elixir::Aether * @return False when the instance is unmanaged, null, duplicated, or the * submission is sealed. */ - bool Submit(FrameSubmission& submission, const Ref& instance) const; + bool Submit( + Rendering::FrameSubmission& submission, + const Ref& instance + ) const; /** * @brief Publishes a completed frame submission for rendering. @@ -173,7 +179,7 @@ namespace Elixir::Aether * @pre submission is not null. * @warning Do not modify the submission after publishing it. */ - void PublishFrameSubmission(Ref submission); + void PublishFrameSubmission(Ref submission); /** * @brief Simulates and renders the latest published submission. @@ -193,11 +199,11 @@ namespace Elixir::Aether * * @note Read the result at a frame boundary after rendering completes. */ - const SParticleSubmissionMetrics& GetLastSubmissionMetrics() const; + const Rendering::SParticleSubmissionMetrics& GetLastSubmissionMetrics() const; private: // Returns the owned renderer and verifies that construction completed. - Renderer& GetRenderer() const; + Rendering::Renderer& GetRenderer() const; // Forwards detached instances to the renderer for fence-safe GPU retirement. void RetireDestroyedInstances(); @@ -205,14 +211,14 @@ namespace Elixir::Aether // Checks ownership while the instance registry mutex is already held. bool IsManagedInstance(const Ref& instance) const; - EffectMaterialResolver m_EffectMaterials; + Effect::MaterialResolver m_EffectMaterials; MaterialSystem& m_MaterialSystem; std::unordered_map> m_Instances; mutable std::mutex m_InstancesMutex; - SystemInstanceRetirementQueue m_PendingRetirements; - FrameSubmissionPublisher m_FrameSubmissionPublisher; - Scope m_Renderer; + Rendering::SystemInstanceRetirementQueue m_PendingRetirements; + Rendering::FrameSubmissionPublisher m_FrameSubmissionPublisher; + Scope m_Renderer; }; -} \ No newline at end of file +} 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/ParticleMaterialFactory.h b/Elixir/Source/Engine/Aether/ParticleMaterialFactory.h deleted file mode 100644 index 45cf8c89..00000000 --- a/Elixir/Source/Engine/Aether/ParticleMaterialFactory.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include -#include -#include - -namespace Elixir::Aether -{ - EMaterialUsage GetParticleMaterialUsage(const EParticleRenderMode mode); - - Ref CreateParticleMaterial( - std::string name, - EParticleRenderMode renderMode, - const SParticleMaterialDefinition& definition - ); -} diff --git a/Elixir/Source/Engine/Aether/FrameSubmission.h b/Elixir/Source/Engine/Aether/Rendering/FrameSubmission.h similarity index 98% rename from Elixir/Source/Engine/Aether/FrameSubmission.h rename to Elixir/Source/Engine/Aether/Rendering/FrameSubmission.h index 82e21098..6b4d7d53 100644 --- a/Elixir/Source/Engine/Aether/FrameSubmission.h +++ b/Elixir/Source/Engine/Aether/Rendering/FrameSubmission.h @@ -1,12 +1,9 @@ #pragma once -#include -#include - #include -#include +#include -namespace Elixir::Aether +namespace Elixir::Aether::Rendering { /** * @brief Collects immutable system-instance render proxies for one frame. @@ -248,4 +245,4 @@ namespace Elixir::Aether mutable std::mutex m_Mutex; Ref m_Published; }; -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/Aether/Renderer.cpp b/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp similarity index 99% rename from Elixir/Source/Engine/Aether/Renderer.cpp rename to Elixir/Source/Engine/Aether/Rendering/Renderer.cpp index bd3fec54..32b66163 100644 --- a/Elixir/Source/Engine/Aether/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp @@ -3,10 +3,12 @@ #include #include -#include +#include -namespace Elixir::Aether +namespace Elixir::Aether::Rendering { + using namespace Elixir::Aether::Modules; + struct MeshVertex { glm::vec3 Position; diff --git a/Elixir/Source/Engine/Aether/Renderer.h b/Elixir/Source/Engine/Aether/Rendering/Renderer.h similarity index 97% rename from Elixir/Source/Engine/Aether/Renderer.h rename to Elixir/Source/Engine/Aether/Rendering/Renderer.h index 5fef4575..baeb1ada 100644 --- a/Elixir/Source/Engine/Aether/Renderer.h +++ b/Elixir/Source/Engine/Aether/Rendering/Renderer.h @@ -1,13 +1,13 @@ #pragma once #include -#include -#include -#include -#include -#include #include #include +#include +#include +#include +#include +#include namespace Elixir { @@ -15,8 +15,12 @@ namespace Elixir class MaterialRenderScene; } -namespace Elixir::Aether +namespace Elixir::Aether::Rendering { + using namespace Elixir; + using namespace Elixir::Aether; + using namespace Elixir::Aether::Core; + struct alignas(16) SFrameData { glm::mat4 View; diff --git a/Elixir/Source/Engine/Aether/SystemInstanceRenderProxy.cpp b/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.cpp similarity index 96% rename from Elixir/Source/Engine/Aether/SystemInstanceRenderProxy.cpp rename to Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.cpp index 8ebc15f9..6650e5cb 100644 --- a/Elixir/Source/Engine/Aether/SystemInstanceRenderProxy.cpp +++ b/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.cpp @@ -1,7 +1,7 @@ #include "epch.h" #include "SystemInstanceRenderProxy.h" -namespace Elixir::Aether +namespace Elixir::Aether::Rendering { SystemInstanceRenderProxy::SystemInstanceRenderProxy( const SSystemInstanceKey& key, diff --git a/Elixir/Source/Engine/Aether/SystemInstanceRenderProxy.h b/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.h similarity index 97% rename from Elixir/Source/Engine/Aether/SystemInstanceRenderProxy.h rename to Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.h index ceef5324..2b635755 100644 --- a/Elixir/Source/Engine/Aether/SystemInstanceRenderProxy.h +++ b/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.h @@ -2,7 +2,7 @@ #include -namespace Elixir::Aether +namespace Elixir::Aether::Rendering { /** * @brief Provides immutable SystemInstance data to the particle renderer. @@ -19,7 +19,7 @@ namespace Elixir::Aether */ class SystemInstanceRenderProxy { - friend class SystemInstanceSnapshot; + friend class Elixir::Aether::SystemInstanceSnapshot; friend class FrameSubmission; friend class Renderer; diff --git a/Elixir/Source/Engine/Aether/SystemInstanceRetirementQueue.h b/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRetirementQueue.h similarity index 98% rename from Elixir/Source/Engine/Aether/SystemInstanceRetirementQueue.h rename to Elixir/Source/Engine/Aether/Rendering/SystemInstanceRetirementQueue.h index 623ad79c..224641ea 100644 --- a/Elixir/Source/Engine/Aether/SystemInstanceRetirementQueue.h +++ b/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRetirementQueue.h @@ -3,7 +3,7 @@ #include #include -namespace Elixir::Aether +namespace Elixir::Aether::Rendering { /** * @brief Transfers detached system instances to the render-frame retirement path. diff --git a/Elixir/Source/Engine/Aether/System.h b/Elixir/Source/Engine/Aether/System.h index 6ae9d6e6..adb7c406 100644 --- a/Elixir/Source/Engine/Aether/System.h +++ b/Elixir/Source/Engine/Aether/System.h @@ -1,9 +1,9 @@ #pragma once #include -#include -#include -#include +#include +#include +#include namespace Elixir { @@ -12,8 +12,8 @@ namespace Elixir namespace Elixir::Aether { - class EffectMaterialResolver; - + using namespace Core; + using namespace Modules; /** * @brief Maps an exposed runtime parameter to the compiled parameter table. * @@ -85,8 +85,6 @@ namespace Elixir::Aether */ class ELIXIR_API System final { - friend class EffectMaterialResolver; - public: /** * @brief Creates an empty particle effect definition. @@ -135,12 +133,24 @@ namespace Elixir::Aether */ SCompiledSystem Compile(MaterialResolver& materialResolver) 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. diff --git a/Elixir/Source/Engine/Aether/SystemInstance.cpp b/Elixir/Source/Engine/Aether/SystemInstance.cpp index 68a65c2a..d997a02e 100644 --- a/Elixir/Source/Engine/Aether/SystemInstance.cpp +++ b/Elixir/Source/Engine/Aether/SystemInstance.cpp @@ -1,7 +1,7 @@ #include "epch.h" #include "SystemInstance.h" -#include +#include namespace Elixir::Aether { @@ -20,7 +20,7 @@ namespace Elixir::Aether m_CompiledSystem(std::move(system)), m_WorldTransform(worldTransform), m_ParameterOverrides(std::move(overrides)), - m_RenderProxy(new SystemInstanceRenderProxy( + m_RenderProxy(new Rendering::SystemInstanceRenderProxy( m_Key, m_Revision, m_ParameterRevision, diff --git a/Elixir/Source/Engine/Aether/SystemInstance.h b/Elixir/Source/Engine/Aether/SystemInstance.h index b6437724..7cfeb8e7 100644 --- a/Elixir/Source/Engine/Aether/SystemInstance.h +++ b/Elixir/Source/Engine/Aether/SystemInstance.h @@ -2,13 +2,17 @@ #include -namespace Elixir::Aether +namespace Elixir::Aether::Rendering { - class SystemInstanceRenderProxy; + class FrameSubmission; class FrameSubmissionPublisher; - class Manager; class Renderer; + class SystemInstanceRenderProxy; +} +namespace Elixir::Aether +{ + class Manager; /** * @brief Identifies one runtime SystemInstance inside Aether. * @@ -54,9 +58,9 @@ namespace Elixir::Aether class ELIXIR_API SystemInstanceSnapshot final { friend class SystemInstance; - friend class FrameSubmission; + friend class Rendering::FrameSubmission; friend class Manager; - friend class Renderer; + friend class Rendering::Renderer; public: /** @@ -110,7 +114,7 @@ namespace Elixir::Aether const SSystemInstanceKey& GetKey() const { return m_Key; } // Returns the immutable renderer-facing state derived from this snapshot. - const Ref& GetRenderProxy() const + const Ref& GetRenderProxy() const { return m_RenderProxy; } @@ -121,7 +125,7 @@ namespace Elixir::Aether Ref m_CompiledSystem; glm::mat4 m_WorldTransform{ 1.0f }; Ref m_ParameterOverrides; - Ref m_RenderProxy; + Ref m_RenderProxy; }; /** @@ -140,10 +144,10 @@ namespace Elixir::Aether */ class ELIXIR_API SystemInstance final { - friend class FrameSubmission; - friend class FrameSubmissionPublisher; + friend class Rendering::FrameSubmission; + friend class Rendering::FrameSubmissionPublisher; friend class Manager; - friend class Renderer; + friend class Rendering::Renderer; public: /** @@ -229,4 +233,4 @@ namespace Elixir::Aether }; } -GENERATE_HASH_FUNCTION(Elixir::Aether::SSystemInstanceKey) \ No newline at end of file +GENERATE_HASH_FUNCTION(Elixir::Aether::SSystemInstanceKey) 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/Tests/Engine/Aether/EffectMaterialResolverTest.cpp b/Elixir/Tests/Engine/Aether/EffectMaterialResolverTest.cpp index 94fd595b..22cc3b30 100644 --- a/Elixir/Tests/Engine/Aether/EffectMaterialResolverTest.cpp +++ b/Elixir/Tests/Engine/Aether/EffectMaterialResolverTest.cpp @@ -1,20 +1,21 @@ #include #include -#include +#include <../../../Source/Engine/Aether/Effect/MaterialResolver.h> #include using namespace Elixir; using namespace Elixir::Aether; +using namespace Elixir::Aether::Core; TEST(EffectMaterialResolverTest, CreatesAuthoredMaterialsAndUsesUsageDefaults) { MaterialRegistry registry; - const EffectMaterialResolver resolver{ registry }; + const Effect::MaterialResolver resolver{ registry }; System system{ "Effect material resolution" }; auto& sprite = system.AddEmitter("Sprite", 8, 0.0f); - sprite.SetMaterialDefinition({ + sprite.SetMaterialDescription({ .BaseColor = { 0.25f, 0.5f, 0.75f }, .Opacity = 0.4f, .Emissive = {0.1f, 0.0f, 0.0f }, @@ -34,4 +35,4 @@ TEST(EffectMaterialResolverTest, CreatesAuthoredMaterialsAndUsesUsageDefaults) EXPECT_EQ(ribbon.GetMaterial()->GetParent(), registry.GetDefault(EMaterialUsage::ParticleRibbon)); EXPECT_TRUE(resolver.Resolve(system)); -} \ No newline at end of file +} diff --git a/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp b/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp index 924f4252..033025db 100644 --- a/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp +++ b/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp @@ -5,10 +5,11 @@ #include #include -#include +#include <../../../Source/Engine/Aether/Rendering/FrameSubmission.h> using namespace Elixir; using namespace Elixir::Aether; +using namespace Elixir::Aether::Rendering; template concept HasPublicSystemInstanceId = requires(const T& instance) @@ -179,4 +180,4 @@ TEST(AetherFrameSubmissionPublisherTest, PublishesAndAcquiresSealedFramesConcurr ASSERT_TRUE(finalFrame); EXPECT_TRUE(finalFrame->IsSealed()); EXPECT_EQ(finalFrame->GetInstanceCount(), 1); -} \ No newline at end of file +} diff --git a/Elixir/Tests/Engine/Aether/ParticleResourcePoolTest.cpp b/Elixir/Tests/Engine/Aether/ParticleResourcePoolTest.cpp index 858b9e9f..b2750e0c 100644 --- a/Elixir/Tests/Engine/Aether/ParticleResourcePoolTest.cpp +++ b/Elixir/Tests/Engine/Aether/ParticleResourcePoolTest.cpp @@ -1,9 +1,10 @@ #include -#include +#include <../../../Source/Engine/Aether/Core/ParticleResourcePool.h> using namespace Elixir; using namespace Elixir::Aether; +using namespace Elixir::Aether::Core; namespace { @@ -127,4 +128,4 @@ TEST(AetherParticleResourcePoolTest, RejectsAnUnregisteredParticleStateLayout) system.ParticleStateLayout = (EParticleStateLayout)1; EXPECT_FALSE(pool.Allocate(system)); -} \ No newline at end of file +} diff --git a/Elixir/Tests/Engine/Aether/SystemInstanceRetirementQueueTest.cpp b/Elixir/Tests/Engine/Aether/SystemInstanceRetirementQueueTest.cpp index e8c7cf8e..fd689f7e 100644 --- a/Elixir/Tests/Engine/Aether/SystemInstanceRetirementQueueTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemInstanceRetirementQueueTest.cpp @@ -5,10 +5,11 @@ #include #include -#include +#include <../../../Source/Engine/Aether/Rendering/SystemInstanceRetirementQueue.h> using namespace Elixir; using namespace Elixir::Aether; +using namespace Elixir::Aether::Rendering; TEST(SystemInstanceRetirementQueueTest, TransfersPendingInstancesExactlyOnce) { @@ -65,4 +66,4 @@ TEST(SystemInstanceRetirementQueueTest, DrainsDestroyRequestsEnqueuedDuringUpdat retiredCount += queue.Drain().size(); EXPECT_EQ(retiredCount, destroyRequestCount); -} \ No newline at end of file +} diff --git a/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp b/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp index 3b138a01..c495ec83 100644 --- a/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp @@ -4,10 +4,11 @@ #include #include -#include +#include <../../../Source/Engine/Aether/Rendering/FrameSubmission.h> using namespace Elixir; using namespace Elixir::Aether; +using namespace Elixir::Aether::Rendering; template concept HasPublicSnapshotCapture = requires(const T& instance) @@ -180,4 +181,4 @@ TEST(AetherSystemInstanceTest, KeepsCapturedProxyImmutableDuringConcurrentOverri EXPECT_FLOAT_EQ(capturedBeforeOverrides->GetParameterValue(0).x, 1.0f); EXPECT_FLOAT_EQ(CaptureForTest(instance)->GetParameterValue(0).x, 64.0f); -} \ No newline at end of file +} diff --git a/Elixir/Tests/Engine/Aether/SystemTest.cpp b/Elixir/Tests/Engine/Aether/SystemTest.cpp index 490333b1..3282fcd4 100644 --- a/Elixir/Tests/Engine/Aether/SystemTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemTest.cpp @@ -10,6 +10,7 @@ using namespace Elixir; using namespace Elixir::Aether; +using namespace Elixir::Aether::Core; namespace { @@ -195,4 +196,4 @@ TEST(AetherSystemTest, KeepsAnEmitterWithoutAnExplicitMaterialUnbound) ASSERT_EQ(compiled.Emitters.size(), 1); EXPECT_FALSE(compiled.Emitters[0].Material); -} \ No newline at end of file +} From adbc7c148ebaf1d509707bfb6d93b23211c04231 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Sun, 9 Aug 2026 18:41:46 -0300 Subject: [PATCH 54/89] refactor(aether): rename resource pool Rename the logical particle resource pool and document the Aether effect and layout contracts. --- Elixir/Source/Engine/Aether/Core/Particle.h | 7 - .../Engine/Aether/Core/ParticleResourcePool.h | 87 ---------- .../Aether/Core/ParticleStateLayout.cpp | 2 +- .../Engine/Aether/Core/ParticleStateLayout.h | 66 +++++++- ...ticleResourcePool.cpp => ResourcePool.cpp} | 18 +- .../Source/Engine/Aether/Core/ResourcePool.h | 156 ++++++++++++++++++ Elixir/Source/Engine/Aether/Effect/Effect.h | 12 ++ .../Aether/Effect/MaterialDescription.h | 10 +- .../Engine/Aether/Effect/MaterialFactory.h | 26 +++ .../Engine/Aether/Effect/MaterialResolver.h | 31 +++- .../Engine/Aether/Rendering/Renderer.cpp | 38 ++--- .../Source/Engine/Aether/Rendering/Renderer.h | 8 +- Elixir/Source/Engine/Aether/System.h | 1 + .../Aether/ParticleResourcePoolTest.cpp | 12 +- 14 files changed, 333 insertions(+), 141 deletions(-) delete mode 100644 Elixir/Source/Engine/Aether/Core/ParticleResourcePool.h rename Elixir/Source/Engine/Aether/Core/{ParticleResourcePool.cpp => ResourcePool.cpp} (92%) create mode 100644 Elixir/Source/Engine/Aether/Core/ResourcePool.h diff --git a/Elixir/Source/Engine/Aether/Core/Particle.h b/Elixir/Source/Engine/Aether/Core/Particle.h index 479c34dd..d049f794 100644 --- a/Elixir/Source/Engine/Aether/Core/Particle.h +++ b/Elixir/Source/Engine/Aether/Core/Particle.h @@ -33,13 +33,6 @@ namespace Elixir::Aether::Core 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 - }; - enum class EParticleOp : uint32_t { SetLiteral = 0, diff --git a/Elixir/Source/Engine/Aether/Core/ParticleResourcePool.h b/Elixir/Source/Engine/Aether/Core/ParticleResourcePool.h deleted file mode 100644 index 7de4a730..00000000 --- a/Elixir/Source/Engine/Aether/Core/ParticleResourcePool.h +++ /dev/null @@ -1,87 +0,0 @@ -#pragma once - -#include -#include - -namespace Elixir::Aether::Core -{ - 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/Core/ParticleStateLayout.cpp b/Elixir/Source/Engine/Aether/Core/ParticleStateLayout.cpp index 4355d336..92a24438 100644 --- a/Elixir/Source/Engine/Aether/Core/ParticleStateLayout.cpp +++ b/Elixir/Source/Engine/Aether/Core/ParticleStateLayout.cpp @@ -3,7 +3,7 @@ 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 index 654f9f04..abf1efd5 100644 --- a/Elixir/Source/Engine/Aether/Core/ParticleStateLayout.h +++ b/Elixir/Source/Engine/Aether/Core/ParticleStateLayout.h @@ -1,12 +1,31 @@ #pragma once -#include - 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; @@ -14,19 +33,54 @@ namespace Elixir::Aether::Core uint32_t ParticleCapacity = 0; }; - // Immutable after renderer initialization. Each registered descriptor must - // have a corresponding renderer runtime with compatible GPU resources, - // shaders and pipelines. + /** + * @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; - const std::vector& GetDescriptors() const { return m_Descriptors;} + /** + * @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/ParticleResourcePool.cpp b/Elixir/Source/Engine/Aether/Core/ResourcePool.cpp similarity index 92% rename from Elixir/Source/Engine/Aether/Core/ParticleResourcePool.cpp rename to Elixir/Source/Engine/Aether/Core/ResourcePool.cpp index 7da8426d..a08495fa 100644 --- a/Elixir/Source/Engine/Aether/Core/ParticleResourcePool.cpp +++ b/Elixir/Source/Engine/Aether/Core/ResourcePool.cpp @@ -1,10 +1,10 @@ #include "epch.h" -#include "ParticleResourcePool.h" +#include "ResourcePool.h" 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::Core } } - std::optional ParticleResourcePool::Allocate( + std::optional ResourcePool::Allocate( const SCompiledSystem& system ) { @@ -95,7 +95,7 @@ namespace Elixir::Aether::Core 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::Core 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::Core return {}; } - void ParticleResourcePool::ReleaseRange( + void ResourcePool::ReleaseRange( std::vector& freeRanges, const SBufferRange range ) @@ -173,7 +173,7 @@ namespace Elixir::Aether::Core 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::Core return {{ 0, capacity }}; } - std::vector* ParticleResourcePool::FindParticleFreeRanges( + std::vector* ResourcePool::FindParticleFreeRanges( const EParticleStateLayout layout ) { diff --git a/Elixir/Source/Engine/Aether/Core/ResourcePool.h b/Elixir/Source/Engine/Aether/Core/ResourcePool.h new file mode 100644 index 00000000..fcc9e422 --- /dev/null +++ b/Elixir/Source/Engine/Aether/Core/ResourcePool.h @@ -0,0 +1,156 @@ +#pragma once + +#include +#include + +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; + }; + + /** + * @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/Effect/Effect.h b/Elixir/Source/Engine/Aether/Effect/Effect.h index 523d3a1a..fc6782ed 100644 --- a/Elixir/Source/Engine/Aether/Effect/Effect.h +++ b/Elixir/Source/Engine/Aether/Effect/Effect.h @@ -4,5 +4,17 @@ 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 index 0f2bef45..bc9819e4 100644 --- a/Elixir/Source/Engine/Aether/Effect/MaterialDescription.h +++ b/Elixir/Source/Engine/Aether/Effect/MaterialDescription.h @@ -4,7 +4,15 @@ namespace Elixir::Aether::Effect { - // Serialized authoring data local to the Aether effect format. + /** + * @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 }; diff --git a/Elixir/Source/Engine/Aether/Effect/MaterialFactory.h b/Elixir/Source/Engine/Aether/Effect/MaterialFactory.h index 56180620..3408e702 100644 --- a/Elixir/Source/Engine/Aether/Effect/MaterialFactory.h +++ b/Elixir/Source/Engine/Aether/Effect/MaterialFactory.h @@ -6,8 +6,34 @@ namespace Elixir::Aether::Effect { + /** + * @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, diff --git a/Elixir/Source/Engine/Aether/Effect/MaterialResolver.h b/Elixir/Source/Engine/Aether/Effect/MaterialResolver.h index 6745b58f..8e1315a0 100644 --- a/Elixir/Source/Engine/Aether/Effect/MaterialResolver.h +++ b/Elixir/Source/Engine/Aether/Effect/MaterialResolver.h @@ -5,12 +5,41 @@ namespace Elixir::Aether { class System; } namespace Elixir::Aether::Effect { - // Resolves effect authoring data into emitter-owned material instances. + /** + * @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 resolves 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: diff --git a/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp b/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp index 32b66163..4e437083 100644 --- a/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp @@ -150,10 +150,10 @@ namespace Elixir::Aether::Rendering const GraphicsContext* context, const ShaderLoader* shaderLoader, MaterialSystem& materialSystem, - const SParticlePoolLimits& limits - ) : m_ParticlePoolLimits(limits), - m_ParticleStateLayouts(m_ParticlePoolLimits.ParticleCapacity), - m_ParticleResourcePool(m_ParticlePoolLimits, m_ParticleStateLayouts), + const SResourcePoolLimits& limits + ) : m_ResourcePoolLimits(limits), + m_ParticleStateLayouts(m_ResourcePoolLimits.ParticleCapacity), + m_ResourcePool(m_ResourcePoolLimits, m_ParticleStateLayouts), m_MaterialSystem(materialSystem), m_GraphicsContext(context) { @@ -184,7 +184,7 @@ namespace Elixir::Aether::Rendering .ElapsedTimeSeconds = m_ElapsedTimeSeconds, .RequestedSystemInstanceCount = proxies.size(), .TriggerEventCapacityPerEmitter = - m_ParticlePoolLimits.TriggerEventCapacityPerEmitter, + m_ResourcePoolLimits.TriggerEventCapacityPerEmitter, }; m_RenderExtent = m_GraphicsContext->GetRenderTarget()->GetExtent(); @@ -463,17 +463,17 @@ namespace Elixir::Aether::Rendering m_EmitterStateBuffer = StorageBuffer::Create( m_GraphicsContext, - sizeof(SEmitterInstanceStateData) * m_ParticlePoolLimits.EmitterCapacity + sizeof(SEmitterInstanceStateData) * m_ResourcePoolLimits.EmitterCapacity ); m_SpawnRequestBuffer = StorageBuffer::Create( m_GraphicsContext, - sizeof(SSpawnRequestData) * m_ParticlePoolLimits.EmitterCapacity + sizeof(SSpawnRequestData) * m_ResourcePoolLimits.EmitterCapacity ); m_TriggerTargetBuffer = DynamicStorageBuffer::Create( m_GraphicsContext, - sizeof(STriggerTargetData) * m_ParticlePoolLimits.TriggerTargetCapacity + sizeof(STriggerTargetData) * m_ResourcePoolLimits.TriggerTargetCapacity ); for (auto& buffer : m_TriggerEventBuffers) @@ -481,25 +481,25 @@ namespace Elixir::Aether::Rendering buffer = StorageBuffer::Create( m_GraphicsContext, sizeof(STriggerEventData) * - m_ParticlePoolLimits.EmitterCapacity * - m_ParticlePoolLimits.TriggerEventCapacityPerEmitter + m_ResourcePoolLimits.EmitterCapacity * + m_ResourcePoolLimits.TriggerEventCapacityPerEmitter ); buffer->Clear(); } m_TriggerQueueStateBuffer = StorageBuffer::Create( m_GraphicsContext, - sizeof(STriggerQueueStateData) * m_ParticlePoolLimits.EmitterCapacity * 2 + sizeof(STriggerQueueStateData) * m_ResourcePoolLimits.EmitterCapacity * 2 ); m_SystemInstanceBuffer = DynamicStorageBuffer::Create( m_GraphicsContext, - sizeof(SSystemInstanceData) * m_ParticlePoolLimits.MaxSystemInstances + sizeof(SSystemInstanceData) * m_ResourcePoolLimits.MaxSystemInstances ); m_SystemSchedulerStateBuffer = StorageBuffer::Create( m_GraphicsContext, - sizeof(SSystemSchedulerStateData) * m_ParticlePoolLimits.MaxSystemInstances + sizeof(SSystemSchedulerStateData) * m_ResourcePoolLimits.MaxSystemInstances ); m_EmitterStateBuffer->Clear(); @@ -509,17 +509,17 @@ namespace Elixir::Aether::Rendering m_EmitterBuffer = DynamicStorageBuffer::Create( m_GraphicsContext, - sizeof(SEmitterData) * m_ParticlePoolLimits.EmitterCapacity + sizeof(SEmitterData) * m_ResourcePoolLimits.EmitterCapacity ); m_OpBuffer = DynamicStorageBuffer::Create( m_GraphicsContext, - sizeof(SParticleOpData) * m_ParticlePoolLimits.OpCapacity + sizeof(SParticleOpData) * m_ResourcePoolLimits.OpCapacity ); m_ParameterBuffer = DynamicStorageBuffer::Create( m_GraphicsContext, - sizeof(SParameterData) * m_ParticlePoolLimits.ParameterCapacity + sizeof(SParameterData) * m_ResourcePoolLimits.ParameterCapacity ); m_ParamsBuffer = UniformBuffer::Create( @@ -786,7 +786,7 @@ namespace Elixir::Aether::Rendering const auto& system = proxy.GetCompiledSystem(); - const auto replacementAllocation = m_ParticleResourcePool.Allocate(system); + const auto replacementAllocation = m_ResourcePool.Allocate(system); if (!replacementAllocation) { if (m_AllocationFailures.insert(proxy.GetKey()).second) @@ -888,7 +888,7 @@ namespace Elixir::Aether::Rendering auto& retirements = m_DeferredRetirements[frameIndex]; for (const auto& allocation : retirements) - m_ParticleResourcePool.Release(allocation); + m_ResourcePool.Release(allocation); retirements.clear(); } @@ -929,7 +929,7 @@ namespace Elixir::Aether::Rendering .TriggerQueueStateBaseOffset = record.Allocation.TriggerQueueStates.Offset, .ParticleCount = record.Allocation.Particles.Count, .EmitterCount = record.Allocation.Emitters.Count, - .TriggerEventCapacityPerEmitter = m_ParticlePoolLimits.TriggerEventCapacityPerEmitter, + .TriggerEventCapacityPerEmitter = m_ResourcePoolLimits.TriggerEventCapacityPerEmitter, .Generation = record.Allocation.Generation, .ParticleStateLayoutIndex = (uint32_t)system.ParticleStateLayout, }; diff --git a/Elixir/Source/Engine/Aether/Rendering/Renderer.h b/Elixir/Source/Engine/Aether/Rendering/Renderer.h index baeb1ada..f2afb328 100644 --- a/Elixir/Source/Engine/Aether/Rendering/Renderer.h +++ b/Elixir/Source/Engine/Aether/Rendering/Renderer.h @@ -6,7 +6,7 @@ #include #include #include -#include +#include #include namespace Elixir @@ -158,7 +158,7 @@ namespace Elixir::Aether::Rendering const GraphicsContext* context, const ShaderLoader* shaderLoader, MaterialSystem& materialSystem, - const SParticlePoolLimits& limits = {} + const SResourcePoolLimits& limits = {} ); void Update(const Timestep& timestep); @@ -302,10 +302,10 @@ namespace Elixir::Aether::Rendering Ref m_SchedulerFinalizeShader; Ref m_SchedulerFinalizePipeline; - SParticlePoolLimits m_ParticlePoolLimits; + SResourcePoolLimits m_ResourcePoolLimits; ParticleStateLayoutRegistry m_ParticleStateLayouts; std::vector m_ParticleStateLayoutRuntimes; - ParticleResourcePool m_ParticleResourcePool; + ResourcePool m_ResourcePool; std::unordered_map m_InstanceRecords; std::unordered_set m_AllocationFailures; std::unordered_set m_UnsupportedParticleStateLayoutInstances; diff --git a/Elixir/Source/Engine/Aether/System.h b/Elixir/Source/Engine/Aether/System.h index adb7c406..376ee018 100644 --- a/Elixir/Source/Engine/Aether/System.h +++ b/Elixir/Source/Engine/Aether/System.h @@ -4,6 +4,7 @@ #include #include #include +#include namespace Elixir { diff --git a/Elixir/Tests/Engine/Aether/ParticleResourcePoolTest.cpp b/Elixir/Tests/Engine/Aether/ParticleResourcePoolTest.cpp index b2750e0c..43d80c13 100644 --- a/Elixir/Tests/Engine/Aether/ParticleResourcePoolTest.cpp +++ b/Elixir/Tests/Engine/Aether/ParticleResourcePoolTest.cpp @@ -1,6 +1,6 @@ #include -#include <../../../Source/Engine/Aether/Core/ParticleResourcePool.h> +#include <../../../Source/Engine/Aether/Core/ResourcePool.h> using namespace Elixir; using namespace Elixir::Aether; @@ -25,7 +25,7 @@ namespace return system; } - SParticlePoolLimits MakePoolLimits() + SResourcePoolLimits MakePoolLimits() { return { .MaxSystemInstances = 4, @@ -43,7 +43,7 @@ TEST(AetherParticleResourcePoolTest, 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)); @@ -69,7 +69,7 @@ TEST(AetherParticleResourcePoolTest, 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); @@ -99,7 +99,7 @@ TEST(AetherParticleResourcePoolTest, RollsBackPartialAllocationFailure) 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))); @@ -122,7 +122,7 @@ TEST(AetherParticleResourcePoolTest, 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; From a690633b899e7d946ad64f87401d57ab8ff45c1a Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Sun, 9 Aug 2026 19:44:48 -0300 Subject: [PATCH 55/89] docs(aether): document renderer responsibilities Describe the renderer public contract and private rendering pipeline helpers. --- .../Source/Engine/Aether/Rendering/Renderer.h | 121 +++++++++++++++++- 1 file changed, 116 insertions(+), 5 deletions(-) diff --git a/Elixir/Source/Engine/Aether/Rendering/Renderer.h b/Elixir/Source/Engine/Aether/Rendering/Renderer.h index f2afb328..479f47d2 100644 --- a/Elixir/Source/Engine/Aether/Rendering/Renderer.h +++ b/Elixir/Source/Engine/Aether/Rendering/Renderer.h @@ -149,11 +149,44 @@ namespace Elixir::Aether::Rendering size_t SubmittedMaterialCount = 0; }; + /** + * @brief Simulates and renders Aether system instances on the GPU. + * + * Renderer consumes immutable SystemInstanceRenderProxy objects from a + * FrameSubmission. It allocates the shared logical ranges required by compiled + * systems, uploads changed system and parameter data, runs particle simulation, + * and submits geometry to MaterialSystem. + * + * Renderer owns Aether GPU resource lifetime. It defers released allocations + * until the frame slot that used them has completed its GPU work. + * + * Renderer does not inspect mutable SystemInstance state and does not author or + * resolve materials. It builds renderer-owned geometry data and delegates + * material drawing to MaterialSystem. + * + * @thread_safety Render-thread confined. Call all public methods from the + * render-frame path. + */ class ELIXIR_API Renderer final { public: + /** Number of threads used by Aether compute shader dispatches. */ static constexpr uint32_t COMPUTE_GROUP_SIZE = 256; + /** + * @brief Creates the GPU renderer for Aether system instances. + * + * @param context Graphics context that owns frame synchronization and GPU + * resources. + * @param shaderLoader Loader used to obtain Aether compute shaders. + * @param materialSystem Application material system used to render particle + * geometry. + * @param limits Logical capacities for shared Aether resource tables. + * + * @pre context is not null. + * @pre shaderLoader is not null. + * @pre context, shaderLoader, and materialSystem outlive this renderer. + */ Renderer( const GraphicsContext* context, const ShaderLoader* shaderLoader, @@ -161,17 +194,55 @@ namespace Elixir::Aether::Rendering const SResourcePoolLimits& limits = {} ); + /** + * @brief Advances renderer frame state. + * + * Updates frame timing data and processes allocations whose deferred GPU + * retirement is now safe. + * + * @param timestep Elapsed time for the current frame. + * + * @pre Call once per render frame before Render(). + */ void Update(const Timestep& timestep); + + /** + * @brief Simulates and renders a published Aether frame submission. + * + * The method resolves per-instance GPU allocations, uploads data whose + * revision changed, executes particle compute passes, and submits the + * resulting geometry through MaterialSystem. + * + * @param submission Immutable instance proxies to simulate and render. + * @param camera Camera used to build particle render data. + * + * @pre Update() was called for the current frame. + */ 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. + /** + * @brief Retires the GPU allocation owned by one detached system instance. + * + * The allocation is removed from active renderer records immediately and is + * released to ResourcePool only after the applicable frame fence completes. + * + * @param key Internal identity of the detached system instance. + * + * @note Calling this method for an unknown key has no effect. + */ void Retire(const SSystemInstanceKey& key); - // Read only at the frame boundary after Render() returns. + /** + * @brief Returns metrics collected for the most recent rendered submission. + * + * @return Read-only frame metrics. + * + * @note Read the result after Render() completes at a frame boundary. + */ const SParticleSubmissionMetrics& GetLastSubmissionMetrics() const; private: + // Owns GPU resources and compute pipelines for one particle-state layout. struct SParticleStateLayoutRuntime { EParticleStateLayout Key = EParticleStateLayout::CoreV1; @@ -195,17 +266,34 @@ namespace Elixir::Aether::Rendering } }; + // 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(); + + // Creates the unit mesh geometry used by mesh particle rendering. void CreateMeshVertexBuffer(); + + // Initializes per-frame constant-buffer data. void InitPerFrameData(); + + // 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; + // Begins the graphics rendering scope for particle material passes. void BeginRendering(const Ref& cmd) const; + + // Ends the graphics rendering scope for particle material passes. void EndRendering(const Ref& cmd) const; + // Tracks the GPU allocation and uploaded revisions for one live system instance. struct SInstanceRecord { SSystemInstanceKey SystemInstanceKey; @@ -216,8 +304,7 @@ namespace Elixir::Aether::Rendering SSystemInstanceAllocation Allocation; }; - // Frame-local, renderer-owned snapshot paired with an immutable - // SystemInstance state captured at the start of Render(). + // Pairs one immutable render proxy with its renderer-owned GPU allocation. struct SSubmittedSystemInstance { Ref Proxy; @@ -225,51 +312,72 @@ namespace Elixir::Aether::Rendering EParticleStateLayout ParticleStateLayout = EParticleStateLayout::CoreV1; }; + // Groups submitted instances that use the same particle-state layout. struct SSimulationBatch { EParticleStateLayout ParticleStateLayout = EParticleStateLayout::CoreV1; std::vector Instances; }; + // Finds or creates the renderer record required by an immutable instance proxy. SInstanceRecord* ResolveInstanceRecord(const SystemInstanceRenderProxy& proxy); + + // Uploads compiled emitter, operation, trigger, and system data for an allocation. void UploadCompiledSystem( const SystemInstanceRenderProxy& proxy, const SSystemInstanceAllocation& allocation ) const; + + // Uploads resolved instance parameter values for an allocation. void UploadInstanceParameters( const SystemInstanceRenderProxy& proxy, const SSystemInstanceAllocation& allocation ) 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(); + // Updates GPU tables when the proxy revisions differ from the instance record. void UpdateBuffers(const SystemInstanceRenderProxy& proxy, SInstanceRecord& record); + // 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 renderer has a ready GPU runtime for the layout. bool IsParticleStateLayoutSupported(EParticleStateLayout layout) const; + // Groups submitted instances into simulation batches by particle-state layout. std::vector BuildSimulationBatches( const std::vector& instances ) const; + // Build material render items from submitted particle instances. MaterialRenderScene BuildMaterialRenderScene( const std::vector& instances ) const; + // Dispatch GPU simulation passes for all instances in one layout batch. void SimulateBatch( const Ref& cmd, const SSimulationBatch& batch ); + // Makes scheduler writes visibility to subsequent Aether compute passes. void BarrierSchedulingBuffers(const Ref& cmd) const; + + // Clears persistent particle state before a released allocation is reused. void ClearParticleAllocation(const SSystemInstanceAllocation& allocation); SFrameData m_FrameData{}; Ref m_FrameConstantBuffer; + // Mirrors one CoreV1 particle state in GPU storage. struct alignas(16) SGPUParticleState { glm::vec4 PositionSize{}; @@ -280,17 +388,20 @@ namespace Elixir::Aether::Rendering 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; Ref m_SchedulerBeginShader; From a9e9dd64c6925420cec645fc6a6f20edbc86d1af Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Mon, 10 Aug 2026 17:24:05 -0300 Subject: [PATCH 56/89] refactor(aether): separate simulation from rendering Move particle simulation and resource ownership into Simulator. Publish immutable render frames for the Renderer to consume through Manager orchestration. --- Dissolve/Source/Dissolve.cpp | 3 +- .../Engine/Aether/Core/ResourceAllocation.h | 62 + Elixir/Source/Engine/Aether/Manager.cpp | 48 +- Elixir/Source/Engine/Aether/Manager.h | 51 +- .../Engine/Aether/Rendering/Renderer.cpp | 1244 +++-------------- .../Source/Engine/Aether/Rendering/Renderer.h | 424 +----- .../Rendering/SystemInstanceRenderProxy.h | 9 +- .../Engine/Aether/Simulation/RenderFrame.h | 92 ++ .../{Core => Simulation}/ResourcePool.cpp | 0 .../{Core => Simulation}/ResourcePool.h | 59 +- .../Engine/Aether/Simulation/Simulator.cpp | 1089 +++++++++++++++ .../Engine/Aether/Simulation/Simulator.h | 239 ++++ Elixir/Source/Engine/Aether/SystemInstance.h | 1 + .../Aether/ParticleResourcePoolTest.cpp | 2 +- 14 files changed, 1765 insertions(+), 1558 deletions(-) create mode 100644 Elixir/Source/Engine/Aether/Core/ResourceAllocation.h create mode 100644 Elixir/Source/Engine/Aether/Simulation/RenderFrame.h rename Elixir/Source/Engine/Aether/{Core => Simulation}/ResourcePool.cpp (100%) rename Elixir/Source/Engine/Aether/{Core => Simulation}/ResourcePool.h (67%) create mode 100644 Elixir/Source/Engine/Aether/Simulation/Simulator.cpp create mode 100644 Elixir/Source/Engine/Aether/Simulation/Simulator.h diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index 43acb53d..b34e3919 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -274,7 +274,8 @@ void Dissolve::Render(const Timestep frameTime) //DrawGeometry(); aether.Render(m_CameraController->GetCamera()); - const auto& metrics = aether.GetLastSubmissionMetrics(); + const auto& simulationMetrics = aether.GetLastSimulationMetrics(); + const auto& renderMetrics = aether.GetLastRenderingMetrics(); } void Dissolve::OnEvent(Event& event) 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/Manager.cpp b/Elixir/Source/Engine/Aether/Manager.cpp index 49318b03..dbcfb4d3 100644 --- a/Elixir/Source/Engine/Aether/Manager.cpp +++ b/Elixir/Source/Engine/Aether/Manager.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include using namespace Elixir::Aether::Effect; @@ -17,7 +18,9 @@ namespace Elixir::Aether MaterialSystem& materialSystem ) : m_EffectMaterials(materialRegistry), m_MaterialSystem(materialSystem), - m_Renderer(CreateScope(context, shaderLoader, materialSystem)) {} + m_Simulator(CreateScope(context, shaderLoader)), + m_Renderer(CreateScope(context, materialSystem)), + m_GraphicsContext(context) {} Manager::~Manager() = default; @@ -68,17 +71,17 @@ namespace Elixir::Aether void Manager::BeginFrame(const Timestep& timestep) { - GetRenderer().Update(timestep); + GetSimulator().BeginFrame(timestep); RetireDestroyedInstances(); } - Ref Manager::CreateFrameSubmission() + Ref Manager::CreateFrameSubmission() { - return CreateRef(); + return CreateRef(); } bool Manager::Submit( - Rendering::FrameSubmission& submission, + FrameSubmission& submission, const Ref& instance ) const { @@ -86,7 +89,7 @@ namespace Elixir::Aether return IsManagedInstance(instance) && submission.Submit(*instance); } - void Manager::PublishFrameSubmission(Ref submission) + void Manager::PublishFrameSubmission(Ref submission) { EE_CORE_ASSERT(submission, "Aether frame submission cannot be null.") @@ -107,15 +110,38 @@ namespace Elixir::Aether const auto submission = m_FrameSubmissionPublisher.Acquire(); if (!submission) return; - GetRenderer().Render(*submission, camera); + 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); + GetRenderer().Render(*frame, camera, cmd); + + cmd->End(); + m_GraphicsContext->EnqueueSecondaryCommandBuffer(cmd); + } + + const SSimulationMetrics& Manager::GetLastSimulationMetrics() const + { + return GetSimulator().GetLastMetrics(); + } + + const SRenderingMetrics& Manager::GetLastRenderingMetrics() const + { + return GetRenderer().GetLastMetrics(); } - const Rendering::SParticleSubmissionMetrics& Manager::GetLastSubmissionMetrics() const + Simulator& Manager::GetSimulator() const { - return GetRenderer().GetLastSubmissionMetrics(); + EE_CORE_ASSERT(m_Simulator, "Aether simulator is unavailable.") + return *m_Simulator; } - Rendering::Renderer& Manager::GetRenderer() const + Renderer& Manager::GetRenderer() const { EE_CORE_ASSERT(m_Renderer, "Aether renderer is unavailable.") return *m_Renderer; @@ -126,7 +152,7 @@ namespace Elixir::Aether const auto instances = m_PendingRetirements.Drain(); for (const auto& instance : instances) - GetRenderer().Retire(instance->GetKey()); + GetSimulator().Retire(instance->GetKey()); } bool Manager::IsManagedInstance(const Ref& instance) const diff --git a/Elixir/Source/Engine/Aether/Manager.h b/Elixir/Source/Engine/Aether/Manager.h index fa3f1e7d..d6791ad3 100644 --- a/Elixir/Source/Engine/Aether/Manager.h +++ b/Elixir/Source/Engine/Aether/Manager.h @@ -16,15 +16,27 @@ namespace Elixir class MaterialResolver; class MaterialSystem; - namespace Aether::Rendering + namespace Aether { - class Renderer; - struct SParticleSubmissionMetrics; + namespace Simulation + { + class Simulator; + struct SSimulationMetrics; + } + + namespace Rendering + { + class Renderer; + struct SRenderingMetrics; + } } } namespace Elixir::Aether { + using namespace Simulation; + using namespace Rendering; + /** * @brief Coordinates Aether effect compilation, runtime instances, and rendering. * @@ -148,7 +160,7 @@ namespace Elixir::Aether * * @return A new unsealed frame submission. */ - static Ref CreateFrameSubmission(); + static Ref CreateFrameSubmission(); /** * @brief Captures an instance for a frame submission. @@ -163,7 +175,7 @@ namespace Elixir::Aether * submission is sealed. */ bool Submit( - Rendering::FrameSubmission& submission, + FrameSubmission& submission, const Ref& instance ) const; @@ -179,7 +191,7 @@ namespace Elixir::Aether * @pre submission is not null. * @warning Do not modify the submission after publishing it. */ - void PublishFrameSubmission(Ref submission); + void PublishFrameSubmission(Ref submission); /** * @brief Simulates and renders the latest published submission. @@ -192,18 +204,15 @@ namespace Elixir::Aether */ void Render(const Camera& camera); - /** - * @brief Returns metrics for the most recently rendered particle frame. - * - * @return Read-only metrics collected during the latest Render() call. - * - * @note Read the result at a frame boundary after rendering completes. - */ - const Rendering::SParticleSubmissionMetrics& GetLastSubmissionMetrics() const; + + const SSimulationMetrics& GetLastSimulationMetrics() const; + + const SRenderingMetrics& GetLastRenderingMetrics() const; private: - // Returns the owned renderer and verifies that construction completed. - Rendering::Renderer& GetRenderer() const; + Simulator& GetSimulator() const; + + Renderer& GetRenderer() const; // Forwards detached instances to the renderer for fence-safe GPU retirement. void RetireDestroyedInstances(); @@ -217,8 +226,12 @@ namespace Elixir::Aether std::unordered_map> m_Instances; mutable std::mutex m_InstancesMutex; - Rendering::SystemInstanceRetirementQueue m_PendingRetirements; - Rendering::FrameSubmissionPublisher m_FrameSubmissionPublisher; - Scope m_Renderer; + Scope m_Simulator; + + SystemInstanceRetirementQueue m_PendingRetirements; + FrameSubmissionPublisher m_FrameSubmissionPublisher; + Scope m_Renderer; + + const GraphicsContext* m_GraphicsContext = nullptr; }; } diff --git a/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp b/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp index 4e437083..b3b313c4 100644 --- a/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp @@ -1,403 +1,97 @@ #include "epch.h" #include "Renderer.h" -#include #include -#include namespace Elixir::Aether::Rendering { - using namespace Elixir::Aether::Modules; + using namespace Core; - struct MeshVertex + namespace { - 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; - }; - - 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, + struct SMeshVertex + { + glm::vec3 Position; + glm::vec3 Normal; }; - } - SParticleOpData ToOpDescription(const SGPUParticleOp& op, uint32_t parameterBaseOffset) - { - const auto ResolveParameterIndex = [parameterBaseOffset](const uint32_t parameterIndex) + struct SSpritePushConstants { - return parameterIndex == UINT32_MAX - ? -1.0f - : (float)(parameterBaseOffset + parameterIndex); + glm::mat4 WorldTransform{ 1.0f }; + uint32_t MaterialIndex = UINT32_MAX; }; - SParticleOpData desc{}; - - desc.Header = { - (float)(uint32_t)op.Type, - (float)op.Target, - ResolveParameterIndex(op.Parameter0Index), - ResolveParameterIndex(op.Parameter1Index) + struct SMeshPushConstants + { + glm::mat4 WorldTransform{ 1.0f }; + uint32_t MaterialIndex = UINT32_MAX; }; - 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; - } - - glm::mat4 GetParticleRenderTransform( - const SCompiledEmitter& emitter, - const SystemInstanceRenderProxy& proxy - ) - { - return emitter.SimulationSpace == EParticleSimulationSpace::Local - ? proxy.GetWorldTransform() - : glm::mat4{ 1.0f }; - } - - bool TryToGetParticleMaterialPass( - const EParticleRenderMode renderMode, - EMaterialPass& pass - ) - { - switch (renderMode) + struct SRibbonPushConstants { - case EParticleRenderMode::Sprite: - pass = EMaterialPass::ParticleSprite; - return true; - case EParticleRenderMode::Ribbon: - pass = EMaterialPass::ParticleRibbon; - return true; - case EParticleRenderMode::Mesh: - pass = EMaterialPass::ParticleMesh; - return true; - } - - return false; + glm::mat4 WorldTransform{ 1.0f }; + uint32_t EmitterIndex = 0; + uint32_t ParticleBaseOffset = 0; + uint32_t MaterialIndex = UINT32_MAX; + }; } - Renderer::Renderer( - const GraphicsContext* context, - const ShaderLoader* shaderLoader, - MaterialSystem& materialSystem, - const SResourcePoolLimits& limits - ) : m_ResourcePoolLimits(limits), - m_ParticleStateLayouts(m_ResourcePoolLimits.ParticleCapacity), - m_ResourcePool(m_ResourcePoolLimits, m_ParticleStateLayouts), - m_MaterialSystem(materialSystem), + Renderer::Renderer(const GraphicsContext* context, MaterialSystem& materialSystem) + : m_MaterialSystem(materialSystem), m_GraphicsContext(context) { - static_assert(sizeof(SGPUParticleState) == PARTICLE_STATE_CORE_V1_STRIDE); + EE_CORE_ASSERT(context, "Aether Renderer requires a graphics context.") EE_CORE_INFO("Initializing Aether Renderer.") - Init(shaderLoader); - CreateBuffers(); + CreateCoreV1GraphicsLayout(); + CreateMeshVertexBuffer(); 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) + void Renderer::Render( + const RenderFrame& frame, + const Camera& camera, + const Ref& cmd + ) { - const auto& proxies = submission.GetRenderProxies(); - - m_LastSubmissionMetrics = { - .SubmissionSerial = ++m_SubmissionSerial, - .DeltaTimeSeconds = m_LastDeltaTimeSeconds, - .ElapsedTimeSeconds = m_ElapsedTimeSeconds, - .RequestedSystemInstanceCount = proxies.size(), - .TriggerEventCapacityPerEmitter = - m_ResourcePoolLimits.TriggerEventCapacityPerEmitter, - }; + EE_CORE_ASSERT(cmd, "Aether rendering requires a command buffer.") + m_LastMetrics = { .SubmissionSerial = frame.GetSubmissionSerial() }; 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_FrameData.Time = m_ElapsedTimeSeconds; - m_FrameConstantBuffer->UpdateData(&m_FrameData, sizeof(SFrameData)); - - std::vector submittedInstances; - submittedInstances.reserve(proxies.size()); - - for (const auto& proxy : proxies) - { - const auto& system = proxy->GetCompiledSystem(); + m_FrameData.Time = frame.GetElapsedTimeSeconds(); + m_FrameConstantBuffer->UpdateData(&m_FrameData, sizeof(m_FrameData)); - m_LastSubmissionMetrics.RequestedEmitterCount += system.Emitters.size(); - m_LastSubmissionMetrics.RequestedParticleCapacity += system.TotalMaxParticles; - - if (!IsParticleStateLayoutSupported(system.ParticleStateLayout)) - { - if (m_UnsupportedParticleStateLayoutInstances.insert(proxy->GetKey()).second) - { - EE_CORE_ERROR( - "Aether does not support particle state layout '{}' for system instance '{}'.", - (uint32_t)system.ParticleStateLayout, - system.Name - ) - } - - continue; - } - - m_UnsupportedParticleStateLayoutInstances.erase(proxy->GetKey()); - - auto* record = ResolveInstanceRecord(*proxy); - if (!record) continue; - - UpdateBuffers(*proxy, *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({ - .Proxy = proxy, - .Allocation = record->Allocation, - .ParticleStateLayout = system.ParticleStateLayout, - }); - } - - if (submittedInstances.empty()) + if (frame.GetItems().empty()) return; - const auto materialScene = BuildMaterialRenderScene(submittedInstances); - const auto materialSnapshot = m_MaterialSystem.BuildFrameSnapshot( - materialScene, - m_SubmissionSerial + const auto scene = BuildMaterialRenderScene(frame); + const auto snapshot = m_MaterialSystem.BuildFrameSnapshot( + scene, + frame.GetSubmissionSerial() ); - const auto simulationBatches = BuildSimulationBatches(submittedInstances); - - m_LastSubmissionMetrics.SimulationBatchCount = simulationBatches.size(); - m_LastSubmissionMetrics.SubmittedMaterialCount = materialSnapshot.MaterialCount; - - 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 - ); - } + m_LastMetrics.SubmittedMaterialCount = snapshot.MaterialCount; BeginRendering(cmd); - - const auto materialResult = m_MaterialSystem.Render( - cmd, - materialScene, - materialSnapshot - ); - - m_LastSubmissionMetrics.RenderBatchCount = materialResult.BatchCount; - m_LastSubmissionMetrics.SubmittedRenderItemCount = materialResult.DrawCount; - + const auto result = m_MaterialSystem.Render(cmd, scene, snapshot); EndRendering(cmd); - } - - void Renderer::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); - } - - 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); - - CreateCoreV1ParticleStateLayoutRuntime(shaderLoader); + m_LastMetrics.RenderBatchCount = result.BatchCount; + m_LastMetrics.SubmittedRenderItemCount = result.DrawCount; } - void Renderer::CreateCoreV1ParticleStateLayoutRuntime(const ShaderLoader* shaderLoader) + void Renderer::CreateCoreV1GraphicsLayout() { - 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({ + SParticleGraphicsLayout layout{ .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); + }; - runtime.SpriteVertexLayout = {{ + layout.SpriteVertexLayout = {{ { { { EDataType::Vec4, "PositionSize" }, @@ -411,7 +105,7 @@ namespace Elixir::Aether::Rendering } }}; - runtime.MeshVertexLayout = {{ + layout.MeshVertexLayout = {{ { { { EDataType::Vec3, "Position" }, @@ -431,108 +125,13 @@ namespace Elixir::Aether::Rendering EInputRate::Instance } }}; - } - - 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_ResourcePoolLimits.EmitterCapacity - ); - - m_SpawnRequestBuffer = StorageBuffer::Create( - m_GraphicsContext, - sizeof(SSpawnRequestData) * m_ResourcePoolLimits.EmitterCapacity - ); - - m_TriggerTargetBuffer = DynamicStorageBuffer::Create( - m_GraphicsContext, - sizeof(STriggerTargetData) * m_ResourcePoolLimits.TriggerTargetCapacity - ); - - for (auto& buffer : m_TriggerEventBuffers) - { - buffer = StorageBuffer::Create( - m_GraphicsContext, - sizeof(STriggerEventData) * - m_ResourcePoolLimits.EmitterCapacity * - m_ResourcePoolLimits.TriggerEventCapacityPerEmitter - ); - buffer->Clear(); - } - - m_TriggerQueueStateBuffer = StorageBuffer::Create( - m_GraphicsContext, - sizeof(STriggerQueueStateData) * m_ResourcePoolLimits.EmitterCapacity * 2 - ); - - m_SystemInstanceBuffer = DynamicStorageBuffer::Create( - m_GraphicsContext, - sizeof(SSystemInstanceData) * m_ResourcePoolLimits.MaxSystemInstances - ); - - m_SystemSchedulerStateBuffer = StorageBuffer::Create( - m_GraphicsContext, - sizeof(SSystemSchedulerStateData) * m_ResourcePoolLimits.MaxSystemInstances - ); - - m_EmitterStateBuffer->Clear(); - m_SpawnRequestBuffer->Clear(); - m_TriggerQueueStateBuffer->Clear(); - m_SystemSchedulerStateBuffer->Clear(); - - m_EmitterBuffer = DynamicStorageBuffer::Create( - m_GraphicsContext, - sizeof(SEmitterData) * m_ResourcePoolLimits.EmitterCapacity - ); - - m_OpBuffer = DynamicStorageBuffer::Create( - m_GraphicsContext, - sizeof(SParticleOpData) * m_ResourcePoolLimits.OpCapacity - ); - m_ParameterBuffer = DynamicStorageBuffer::Create( - m_GraphicsContext, - sizeof(SParameterData) * m_ResourcePoolLimits.ParameterCapacity - ); - - m_ParamsBuffer = UniformBuffer::Create( - m_GraphicsContext, - sizeof(SParamsData) - ); - - CreateMeshVertexBuffer(); + m_GraphicsLayouts.push_back(std::move(layout)); } void Renderer::CreateMeshVertexBuffer() { - static constexpr std::array vertices = {{ + 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}}, @@ -579,166 +178,26 @@ namespace Elixir::Aether::Rendering m_MeshVertexCount = (uint32_t)vertices.size(); m_MeshVertexBuffer = VertexBuffer::Create( m_GraphicsContext, - sizeof(MeshVertex) * vertices.size(), + sizeof(vertices), vertices.data() ); - const auto* coreV1Runtime = FindParticleStateLayoutRuntime(EParticleStateLayout::CoreV1); - EE_CORE_ASSERT( - coreV1Runtime, - "Aether CoreV1 particle state runtime is missing." - ) - if (!coreV1Runtime) return; + const auto* layout = FindGraphicsLayout(EParticleStateLayout::CoreV1); + EE_CORE_ASSERT(layout, "Aether CoreV1 graphics layout is missing.") + if (!layout) return; - m_MeshVertexBuffer->SetLayout(coreV1Runtime->MeshVertexLayout); + m_MeshVertexBuffer->SetLayout(layout->MeshVertexLayout); } void Renderer::InitPerFrameData() { m_FrameConstantBuffer = UniformBuffer::Create( m_GraphicsContext, - sizeof(SFrameData), + sizeof(m_FrameData), &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); - - 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); - } - void Renderer::BeginRendering(const Ref& cmd) const { const auto renderingInfo = SRenderingInfo @@ -749,8 +208,8 @@ namespace Elixir::Aether::Rendering }; Viewport viewport = {}; - viewport.X = 0; - viewport.Y = 0; + viewport.X = 0.0f; + viewport.Y = 0.0f; viewport.Width = (float)m_RenderExtent.Width; viewport.Height = (float)m_RenderExtent.Height; viewport.MinDepth = 0.0f; @@ -765,250 +224,32 @@ namespace Elixir::Aether::Rendering cmd->SetScissors({ scissor }); } - void Renderer::EndRendering(const Ref& cmd) const + void Renderer::EndRendering(const Ref& cmd) { cmd->EndRendering(); - m_GraphicsContext->EnqueueSecondaryCommandBuffer(cmd); - } - - Renderer::SInstanceRecord* Renderer::ResolveInstanceRecord( - const SystemInstanceRenderProxy& proxy - ) - { - const auto instanceRevision = proxy.GetRevision(); - const auto found = m_InstanceRecords.find(proxy.GetKey()); - - if (found != m_InstanceRecords.end() && - found->second.SystemInstanceRevision == instanceRevision) - { - return &found->second; - } - - const auto& system = proxy.GetCompiledSystem(); - - const auto replacementAllocation = m_ResourcePool.Allocate(system); - if (!replacementAllocation) - { - if (m_AllocationFailures.insert(proxy.GetKey()).second) - { - EE_CORE_ERROR( - "Aether GPU resource pool exhausted while creating system instance '{}'.", - system.Name - ) - } - - return nullptr; - } - - ClearParticleAllocation(*replacementAllocation); - UploadCompiledSystem(proxy, *replacementAllocation); - - const SInstanceRecord replacement{ - .SystemInstanceKey = proxy.GetKey(), - .SystemInstanceRevision = instanceRevision, - .CompiledSystemId = system.SourceId, - .CompilationRevision = system.CompilationRevision, - .ParameterRevision = proxy.GetParameterRevision(), - .Allocation = *replacementAllocation, - }; - - 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 Renderer::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] = 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(proxy, 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 SystemInstanceRenderProxy& proxy, - const SSystemInstanceAllocation& allocation + const Renderer::SParticleGraphicsLayout* Renderer::FindGraphicsLayout( + const EParticleStateLayout key ) 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); - } - - 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_ResourcePool.Release(allocation); - - retirements.clear(); - } - - void Renderer::UpdateBuffers( - const SystemInstanceRenderProxy& proxy, - SInstanceRecord& record - ) - { - if (record.ParameterRevision != proxy.GetParameterRevision()) - { - UploadInstanceParameters(proxy, record.Allocation); - record.ParameterRevision = proxy.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 = proxy.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_ResourcePoolLimits.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; - } - + for (const auto& layout : m_GraphicsLayouts) + if (layout.Key == key) return &layout; return nullptr; } - const Renderer::SParticleStateLayoutRuntime* Renderer::FindParticleStateLayoutRuntime( - const EParticleStateLayout layout - ) const + const SParticleStateRenderResource* Renderer::FindRenderResource( + const RenderFrame& frame, + const EParticleStateLayout key + ) { - for (const auto& runtime : m_ParticleStateLayoutRuntimes) - { - if (runtime.Key == layout) - return &runtime; - } + for (const auto& resource : frame.GetResources()) + if (resource.Layout == key) return &resource; 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; - } - - MaterialRenderScene Renderer::BuildMaterialRenderScene( - const std::vector& instances - ) const + MaterialRenderScene Renderer::BuildMaterialRenderScene(const RenderFrame& frame) const { MaterialRenderScene scene; @@ -1023,16 +264,22 @@ namespace Elixir::Aether::Rendering std::unordered_map geometries; - const auto getGeometry = [this, &scene, &geometries]( - const EParticleStateLayout layout - ) + const auto getGeometry = [this, &frame, &scene, &geometries]( + const EParticleStateLayout key + ) -> std::optional { - const auto key = (uint32_t)layout; - if (const auto found = geometries.find(key); found != geometries.end()) + const auto cacheKey = (uint32_t)key; + if (const auto found = geometries.find(cacheKey); found != geometries.end()) return found->second; - const auto* runtime = FindParticleStateLayoutRuntime(layout); - EE_CORE_ASSERT(runtime, "Aether particle state layout runtime is missing.") + 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{ SMaterialConstantBufferBinding{ @@ -1044,28 +291,28 @@ namespace Elixir::Aether::Rendering const std::array ribbonStorageBuffers{ SMaterialStorageBufferBinding{ .Name = "particles", - .Buffer = MaterialStorageBuffer{ runtime->ParticleStateBuffer }, + .Buffer = MaterialStorageBuffer{ resource->ParticleStateBuffer }, }, SMaterialStorageBufferBinding{ .Name = "emitters", - .Buffer = MaterialStorageBuffer{ m_EmitterBuffer }, + .Buffer = MaterialStorageBuffer{ frame.GetEmitterBuffer() }, }, }; const SGeometryIndices indices{ .Sprite = scene.AddGeometry({ .Pipeline = { - .VertexLayoutKey = (uint64_t)runtime->Key, - .VertexLayout = &runtime->SpriteVertexLayout, + .VertexLayoutKey = cacheKey, + .VertexLayout = &layout->SpriteVertexLayout, }, .ConstantBuffers = { constantBuffers.begin(), constantBuffers.end() }, .VertexBuffers = { - { .Buffer = runtime->ParticleStateBuffer.get(), .Binding = 0 } + { .Buffer = resource->ParticleStateBuffer.get(), .Binding = 0 } }, }), .Ribbon = scene.AddGeometry({ .Pipeline = { - .VertexLayoutKey = (uint64_t)runtime->Key, + .VertexLayoutKey = cacheKey, .VertexLayout = &ribbonVertexLayout, }, .ConstantBuffers = { constantBuffers.begin(), constantBuffers.end() }, @@ -1073,267 +320,102 @@ namespace Elixir::Aether::Rendering }), .Mesh = scene.AddGeometry({ .Pipeline = { - .VertexLayoutKey = (uint64_t)runtime->Key, - .VertexLayout = &runtime->MeshVertexLayout, + .VertexLayoutKey = cacheKey, + .VertexLayout = &layout->MeshVertexLayout, }, .ConstantBuffers = { constantBuffers.begin(), constantBuffers.end() }, .VertexBuffers = { { .Buffer = m_MeshVertexBuffer.get(), .Binding = 0 }, - { .Buffer = runtime->ParticleStateBuffer.get(), .Binding = 1 }, + { .Buffer = resource->ParticleStateBuffer.get(), .Binding = 1 }, }, }), }; - geometries.emplace(key, indices); + geometries.emplace(cacheKey, indices); return indices; }; - for (const auto& instance : instances) + for (const auto& item : frame.GetItems()) { - const auto& emitters = instance.Proxy->GetCompiledSystem().Emitters; - const auto geometry = getGeometry(instance.ParticleStateLayout); + const auto geometry = getGeometry(item.ParticleStateLayout); + if (!geometry) continue; - for (uint32_t emitterIndex = 0; emitterIndex < emitters.size(); ++emitterIndex) + switch (item.RenderMode) { - const auto& emitter = emitters[emitterIndex]; - if (emitter.MaxParticles == 0) continue; - - const auto worldTransform = GetParticleRenderTransform( - emitter, - *instance.Proxy - ); - - switch (emitter.RenderMode) + case EParticleRenderMode::Sprite: { - case EParticleRenderMode::Sprite: - { - const SSpritePushConstants constants{ - .WorldTransform = worldTransform, - }; - - scene.Add({ - .Pass = EMaterialPass::ParticleSprite, - .Material = emitter.Material, - .DebugName = emitter.Name, - .GeometryIndex = geometry.Sprite, - .PushConstants = SMaterialPushConstants::Create( - constants, - offsetof(SSpritePushConstants, MaterialIndex) - ), - .Draw = { - .VertexCount = 6, - .InstanceCount = emitter.MaxParticles, - .FirstInstance = instance.Allocation.Particles.Offset + - emitter.LocalParticleOffset, - }, - }); - break; - } - - case EParticleRenderMode::Ribbon: - { - const SRibbonPushConstants constants{ - .WorldTransform = worldTransform, - .EmitterIndex = instance.Allocation.Emitters.Offset + emitterIndex, - .ParticleBaseOffset = instance.Allocation.Particles.Offset, - }; - - scene.Add({ - .Pass = EMaterialPass::ParticleRibbon, - .Material = emitter.Material, - .DebugName = emitter.Name, - .GeometryIndex = geometry.Ribbon, - .PushConstants = SMaterialPushConstants::Create( - constants, - offsetof(SRibbonPushConstants, MaterialIndex) - ), - .Draw = { .VertexCount = emitter.MaxParticles * 6 }, - }); - break; - } - - case EParticleRenderMode::Mesh: - { - const SMeshPushConstants constants{ - .WorldTransform = worldTransform, - }; - - scene.Add({ - .Pass = EMaterialPass::ParticleMesh, - .Material = emitter.Material, - .DebugName = emitter.Name, - .GeometryIndex = geometry.Mesh, - .PushConstants = SMaterialPushConstants::Create( - constants, - offsetof(SMeshPushConstants, MaterialIndex) - ), - .Draw = { - .VertexCount = m_MeshVertexCount, - .InstanceCount = emitter.MaxParticles, - .FirstInstance = instance.Allocation.Particles.Offset + - emitter.LocalParticleOffset, - }, - }); - break; - } + const SSpritePushConstants constants{ + .WorldTransform = item.WorldTransform, + }; + + scene.Add({ + .Pass = EMaterialPass::ParticleSprite, + .Material = item.Material, + .DebugName = item.DebugName, + .GeometryIndex = geometry->Sprite, + .PushConstants = SMaterialPushConstants::Create( + constants, + offsetof(SSpritePushConstants, MaterialIndex) + ), + .Draw = { + .VertexCount = 6, + .InstanceCount = item.ParticleCount, + .FirstInstance = item.Allocation.Particles.Offset + + item.LocalParticleOffset, + }, + }); + break; } - } - } - - return scene; - } - - 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->Proxy->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 + case EParticleRenderMode::Ribbon: { - .InstanceIndex = instance->Allocation.InstanceIndex, - .EmitterIndex = i, - }; + 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, + .DebugName = item.DebugName, + .GeometryIndex = geometry->Ribbon, + .PushConstants = SMaterialPushConstants::Create( + constants, + offsetof(SRibbonPushConstants, MaterialIndex) + ), + .Draw = { .VertexCount = item.ParticleCount * 6 }, + }); + break; + } - runtime->SpawnShader->SetPushConstant(cmd, "pc", (void*)&pc, sizeof(pc)); - cmd->Dispatch((maxParticles + COMPUTE_GROUP_SIZE - 1) / COMPUTE_GROUP_SIZE); + case EParticleRenderMode::Mesh: + { + const SMeshPushConstants constants{ + .WorldTransform = item.WorldTransform, + }; + + scene.Add({ + .Pass = EMaterialPass::ParticleMesh, + .Material = item.Material, + .DebugName = item.DebugName, + .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; + } } } - // 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::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 - ); + return scene; } } diff --git a/Elixir/Source/Engine/Aether/Rendering/Renderer.h b/Elixir/Source/Engine/Aether/Rendering/Renderer.h index 479f47d2..22185a6e 100644 --- a/Elixir/Source/Engine/Aether/Rendering/Renderer.h +++ b/Elixir/Source/Engine/Aether/Rendering/Renderer.h @@ -1,25 +1,18 @@ #pragma once -#include #include -#include -#include -#include +#include +#include +#include #include -#include -#include +#include -namespace Elixir -{ - class MaterialSystem; - class MaterialRenderScene; -} +namespace Elixir { class MaterialSystem; } namespace Elixir::Aether::Rendering { - using namespace Elixir; - using namespace Elixir::Aether; - using namespace Elixir::Aether::Core; + using namespace Core; + using namespace Simulation; struct alignas(16) SFrameData { @@ -30,250 +23,51 @@ namespace Elixir::Aether::Rendering float Time = 0.0f; }; - 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 + struct SRenderingMetrics { 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; - size_t SubmittedMaterialCount = 0; + size_t RenderBatchCount = 0; + size_t SubmittedRenderItemCount = 0; + size_t SubmittedMaterialCount = 0; }; /** - * @brief Simulates and renders Aether system instances on the GPU. - * - * Renderer consumes immutable SystemInstanceRenderProxy objects from a - * FrameSubmission. It allocates the shared logical ranges required by compiled - * systems, uploads changed system and parameter data, runs particle simulation, - * and submits geometry to MaterialSystem. + * @brief Records material-based particle graphics passes. * - * Renderer owns Aether GPU resource lifetime. It defers released allocations - * until the frame slot that used them has completed its GPU work. + * Renderer consumes an immutable RenderFrame produced by Simulator. + * It owns graphics layouts, frame constants, mesh geometry, and + * MaterialSystem scene construction. It does not own simulation state. * - * Renderer does not inspect mutable SystemInstance state and does not author or - * resolve materials. It builds renderer-owned geometry data and delegates - * material drawing to MaterialSystem. - * - * @thread_safety Render-thread confined. Call all public methods from the - * render-frame path. + * @thread_safety Render-thread confined. */ class ELIXIR_API Renderer final { public: - /** Number of threads used by Aether compute shader dispatches. */ - static constexpr uint32_t COMPUTE_GROUP_SIZE = 256; - - /** - * @brief Creates the GPU renderer for Aether system instances. - * - * @param context Graphics context that owns frame synchronization and GPU - * resources. - * @param shaderLoader Loader used to obtain Aether compute shaders. - * @param materialSystem Application material system used to render particle - * geometry. - * @param limits Logical capacities for shared Aether resource tables. - * - * @pre context is not null. - * @pre shaderLoader is not null. - * @pre context, shaderLoader, and materialSystem outlive this renderer. - */ Renderer( const GraphicsContext* context, - const ShaderLoader* shaderLoader, - MaterialSystem& materialSystem, - const SResourcePoolLimits& limits = {} + MaterialSystem& materialSystem ); - /** - * @brief Advances renderer frame state. - * - * Updates frame timing data and processes allocations whose deferred GPU - * retirement is now safe. - * - * @param timestep Elapsed time for the current frame. - * - * @pre Call once per render frame before Render(). - */ - void Update(const Timestep& timestep); - - /** - * @brief Simulates and renders a published Aether frame submission. - * - * The method resolves per-instance GPU allocations, uploads data whose - * revision changed, executes particle compute passes, and submits the - * resulting geometry through MaterialSystem. - * - * @param submission Immutable instance proxies to simulate and render. - * @param camera Camera used to build particle render data. - * - * @pre Update() was called for the current frame. - */ - void Render(const FrameSubmission& submission, const Camera& camera); - - /** - * @brief Retires the GPU allocation owned by one detached system instance. - * - * The allocation is removed from active renderer records immediately and is - * released to ResourcePool only after the applicable frame fence completes. - * - * @param key Internal identity of the detached system instance. - * - * @note Calling this method for an unknown key has no effect. - */ - void Retire(const SSystemInstanceKey& key); + void Render( + const RenderFrame& frame, + const Camera& camera, + const Ref& cmd + ); - /** - * @brief Returns metrics collected for the most recent rendered submission. - * - * @return Read-only frame metrics. - * - * @note Read the result after Render() completes at a frame boundary. - */ - const SParticleSubmissionMetrics& GetLastSubmissionMetrics() const; + const SRenderingMetrics& GetLastMetrics() const + { + return m_LastMetrics; + } private: - // Owns GPU resources and compute pipelines for one particle-state layout. - struct SParticleStateLayoutRuntime + struct SParticleGraphicsLayout { EParticleStateLayout Key = EParticleStateLayout::CoreV1; - Ref ParticleStateBuffer; - - Ref SpawnShader; - Ref SpawnPipeline; - Ref UpdateShader; - Ref UpdatePipeline; - - // Geometry ABI owned by Particles System. MaterialRenderer receives these - // layouts to create the pipeline for the selected material pass. BufferLayout SpriteVertexLayout; BufferLayout MeshVertexLayout; - - bool IsReady() const - { - return ParticleStateBuffer && - SpawnShader && SpawnPipeline && - UpdateShader && UpdatePipeline; - } }; - // 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(); + void CreateCoreV1GraphicsLayout(); // Creates the unit mesh geometry used by mesh particle rendering. void CreateMeshVertexBuffer(); @@ -281,175 +75,35 @@ namespace Elixir::Aether::Rendering // Initializes per-frame constant-buffer data. void InitPerFrameData(); - // 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; - // Begins the graphics rendering scope for particle material passes. void BeginRendering(const Ref& cmd) const; // Ends the graphics rendering scope for particle material passes. - void EndRendering(const Ref& cmd) const; - - // 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 renderer-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; - }; - - // Finds or creates the renderer record required by an immutable instance proxy. - SInstanceRecord* ResolveInstanceRecord(const SystemInstanceRenderProxy& proxy); - - // Uploads compiled emitter, operation, trigger, and system data for an allocation. - void UploadCompiledSystem( - const SystemInstanceRenderProxy& proxy, - const SSystemInstanceAllocation& allocation - ) const; - - // Uploads resolved instance parameter values for an allocation. - void UploadInstanceParameters( - const SystemInstanceRenderProxy& proxy, - const SSystemInstanceAllocation& allocation - ) 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(); + static void EndRendering(const Ref& cmd); - // Updates GPU tables when the proxy revisions differ from the instance record. - void UpdateBuffers(const SystemInstanceRenderProxy& proxy, SInstanceRecord& record); + const SParticleGraphicsLayout* FindGraphicsLayout(EParticleStateLayout key) 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 renderer has a ready GPU runtime for the layout. - bool IsParticleStateLayoutSupported(EParticleStateLayout layout) const; - - // Groups submitted instances into simulation batches by particle-state layout. - std::vector BuildSimulationBatches( - const std::vector& instances - ) const; - - // Build material render items from submitted particle instances. - MaterialRenderScene BuildMaterialRenderScene( - const std::vector& instances - ) const; - - // Dispatch GPU simulation passes for all instances in one layout batch. - void SimulateBatch( - const Ref& cmd, - const SSimulationBatch& batch + static const SParticleStateRenderResource* FindRenderResource( + const RenderFrame& frame, + EParticleStateLayout key ); - // Makes scheduler writes visibility to subsequent Aether compute passes. - void BarrierSchedulingBuffers(const Ref& cmd) const; - - // Clears persistent particle state before a released allocation is reused. - void ClearParticleAllocation(const SSystemInstanceAllocation& allocation); + // Build material render items from submitted particle instances. + MaterialRenderScene BuildMaterialRenderScene(const RenderFrame& frame) const; SFrameData m_FrameData{}; Ref m_FrameConstantBuffer; - // 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; - - Ref m_SchedulerBeginShader; - Ref m_SchedulerBeginPipeline; - Ref m_SchedulerInitEmittersShader; - Ref m_SchedulerInitEmittersPipeline; - Ref m_SchedulerScheduleEmittersShader; - Ref m_SchedulerScheduleEmittersPipeline; - Ref m_SchedulerFinalizeShader; - Ref m_SchedulerFinalizePipeline; - - SResourcePoolLimits m_ResourcePoolLimits; - ParticleStateLayoutRegistry m_ParticleStateLayouts; - std::vector m_ParticleStateLayoutRuntimes; - ResourcePool m_ResourcePool; - 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; - - MaterialSystem& m_MaterialSystem; + std::vector m_GraphicsLayouts; uint32_t m_MeshVertexCount = 0; Ref m_MeshVertexBuffer; - float m_LastDeltaTimeSeconds = 0.0f; - float m_ElapsedTimeSeconds = 0.0f; + MaterialSystem& m_MaterialSystem; - uint64_t m_SubmissionSerial = 0; - SParticleSubmissionMetrics m_LastSubmissionMetrics{}; + SRenderingMetrics m_LastMetrics{}; Extent2D m_RenderExtent{}; - const GraphicsContext* m_GraphicsContext; + const GraphicsContext* m_GraphicsContext = nullptr; }; } diff --git a/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.h b/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.h index 2b635755..a93f384f 100644 --- a/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.h +++ b/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.h @@ -2,8 +2,13 @@ #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. * @@ -19,9 +24,9 @@ namespace Elixir::Aether::Rendering */ class SystemInstanceRenderProxy { - friend class Elixir::Aether::SystemInstanceSnapshot; + friend class SystemInstanceSnapshot; friend class FrameSubmission; - friend class Renderer; + friend class Simulator; public: /** diff --git a/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h b/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h new file mode 100644 index 00000000..20dcaad3 --- /dev/null +++ b/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace Elixir::Aether::Simulation +{ + /** + * @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 }; + std::string DebugName; + uint32_t EmitterIndex = 0; + uint32_t LocalParticleOffset = 0; + uint32_t ParticleCount = 0; + }; + + /** + * @brief Publishes immutable particle render data for one submission. + * + * Simulator creates one frame after recording compute work and the required + * compute-to-graphics barriers. Renderer consumes its resources and items + * synchronously while recording graphics commands. + * + * RenderFrame owns frame-local metadata and strong references to shared GPU + * resources. It never stores a command buffer or mutable SystemInstance + * state. + * + * @thread_safety Immutable after construction. Concurrent readers are safe + * when the referenced GPU wrappers are used according to their own contract. + */ + class ELIXIR_API RenderFrame final + { + public: + 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; + + const std::vector& GetResources() const { return m_Resources; } + + const Ref& GetEmitterBuffer() const { return m_EmitterBuffer; } + const std::vector& GetItems() const { return m_Items; } + uint64_t GetSubmissionSerial() const { return m_SubmissionSerial; } + 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/Core/ResourcePool.cpp b/Elixir/Source/Engine/Aether/Simulation/ResourcePool.cpp similarity index 100% rename from Elixir/Source/Engine/Aether/Core/ResourcePool.cpp rename to Elixir/Source/Engine/Aether/Simulation/ResourcePool.cpp diff --git a/Elixir/Source/Engine/Aether/Core/ResourcePool.h b/Elixir/Source/Engine/Aether/Simulation/ResourcePool.h similarity index 67% rename from Elixir/Source/Engine/Aether/Core/ResourcePool.h rename to Elixir/Source/Engine/Aether/Simulation/ResourcePool.h index fcc9e422..f782294c 100644 --- a/Elixir/Source/Engine/Aether/Core/ResourcePool.h +++ b/Elixir/Source/Engine/Aether/Simulation/ResourcePool.h @@ -2,67 +2,10 @@ #include #include +#include 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; - }; - /** * @brief Allocates shared logical resource ranges for Aether system instances. * diff --git a/Elixir/Source/Engine/Aether/Simulation/Simulator.cpp b/Elixir/Source/Engine/Aether/Simulation/Simulator.cpp new file mode 100644 index 00000000..f2be4595 --- /dev/null +++ b/Elixir/Source/Engine/Aether/Simulation/Simulator.cpp @@ -0,0 +1,1089 @@ +#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; + + 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.Name) + } + 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.Name + ) + } + 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), + .DebugName = emitter.Name, + .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..3368735d --- /dev/null +++ b/Elixir/Source/Engine/Aether/Simulation/Simulator.h @@ -0,0 +1,239 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace Elixir +{ + class CommandBuffer; + class GraphicsContext; + class ShaderLoader; + class Timestep; +} + +namespace Elixir::Aether::Simulation +{ + using namespace Core; + + 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 Owns persistent GPU particle simulation state. + * + * Simulator consumes immutable system-instance proxies, updates persistent GPU + * simulation tables, records scheduler/spawn/update compute work, and produces + * an immutable RenderFrame. It owns resource allocation and fence-safe + * retirement; it has no dependency on MaterialSystem. + */ + class ELIXIR_API Simulator final + { + public: + /** Number of threads used by Aether compute shader dispatches. */ + static constexpr uint32_t COMPUTE_GROUP_SIZE = 256; + + Simulator( + const GraphicsContext* context, + const ShaderLoader* shaderLoader, + const SResourcePoolLimits& limits = {} + ); + + void BeginFrame(const Timestep& timestep); + + Ref Simulate( + const Rendering::FrameSubmission& submission, + const Ref& cmd + ); + + void Retire(const SSystemInstanceKey& key); + + 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 renderer-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 renderer 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 renderer has a ready GPU runtime for the layout. + bool IsParticleStateLayoutSupported(EParticleStateLayout layout) const; + + 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 + ); + + std::vector BuildRenderResources() const; + + 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); + + 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 visibility to subsequent Aether 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/SystemInstance.h b/Elixir/Source/Engine/Aether/SystemInstance.h index 7cfeb8e7..b78dd5ab 100644 --- a/Elixir/Source/Engine/Aether/SystemInstance.h +++ b/Elixir/Source/Engine/Aether/SystemInstance.h @@ -13,6 +13,7 @@ namespace Elixir::Aether::Rendering namespace Elixir::Aether { class Manager; + /** * @brief Identifies one runtime SystemInstance inside Aether. * diff --git a/Elixir/Tests/Engine/Aether/ParticleResourcePoolTest.cpp b/Elixir/Tests/Engine/Aether/ParticleResourcePoolTest.cpp index 43d80c13..dfa06aba 100644 --- a/Elixir/Tests/Engine/Aether/ParticleResourcePoolTest.cpp +++ b/Elixir/Tests/Engine/Aether/ParticleResourcePoolTest.cpp @@ -1,6 +1,6 @@ #include -#include <../../../Source/Engine/Aether/Core/ResourcePool.h> +#include <../../../Source/Engine/Aether/Simulation/ResourcePool.h> using namespace Elixir; using namespace Elixir::Aether; From 67464053aea9714407c3c1146bcdcf43fbe012b5 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Mon, 10 Aug 2026 20:36:05 -0300 Subject: [PATCH 57/89] test(aether): organize tests by runtime module Mirror the Aether namespace layout in the test tree and split mixed test files by entity. Add explicit Simulator, Renderer, and RenderFrame separation contracts. --- .../MaterialResolverTest.cpp} | 4 +- .../Engine/Aether/FrameSubmissionTest.cpp | 183 ------------------ .../FrameSubmissionPublisherTest.cpp | 93 +++++++++ .../Aether/Rendering/FrameSubmissionTest.cpp | 98 ++++++++++ .../Engine/Aether/Rendering/RendererTest.cpp | 54 ++++++ .../SystemInstanceRetirementQueueTest.cpp | 2 +- .../Aether/Simulation/RenderFrameTest.cpp | 94 +++++++++ .../ResourcePoolTest.cpp} | 10 +- .../Aether/Simulation/SimulatorTest.cpp | 48 +++++ .../Engine/Aether/SystemInstanceTest.cpp | 16 +- Elixir/Tests/Engine/Aether/SystemTest.cpp | 18 +- 11 files changed, 412 insertions(+), 208 deletions(-) rename Elixir/Tests/Engine/Aether/{EffectMaterialResolverTest.cpp => Effect/MaterialResolverTest.cpp} (88%) delete mode 100644 Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp create mode 100644 Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionPublisherTest.cpp create mode 100644 Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionTest.cpp create mode 100644 Elixir/Tests/Engine/Aether/Rendering/RendererTest.cpp rename Elixir/Tests/Engine/Aether/{ => Rendering}/SystemInstanceRetirementQueueTest.cpp (95%) create mode 100644 Elixir/Tests/Engine/Aether/Simulation/RenderFrameTest.cpp rename Elixir/Tests/Engine/Aether/{ParticleResourcePoolTest.cpp => Simulation/ResourcePoolTest.cpp} (92%) create mode 100644 Elixir/Tests/Engine/Aether/Simulation/SimulatorTest.cpp diff --git a/Elixir/Tests/Engine/Aether/EffectMaterialResolverTest.cpp b/Elixir/Tests/Engine/Aether/Effect/MaterialResolverTest.cpp similarity index 88% rename from Elixir/Tests/Engine/Aether/EffectMaterialResolverTest.cpp rename to Elixir/Tests/Engine/Aether/Effect/MaterialResolverTest.cpp index 22cc3b30..f4a09276 100644 --- a/Elixir/Tests/Engine/Aether/EffectMaterialResolverTest.cpp +++ b/Elixir/Tests/Engine/Aether/Effect/MaterialResolverTest.cpp @@ -1,14 +1,14 @@ #include #include -#include <../../../Source/Engine/Aether/Effect/MaterialResolver.h> +#include #include using namespace Elixir; using namespace Elixir::Aether; using namespace Elixir::Aether::Core; -TEST(EffectMaterialResolverTest, CreatesAuthoredMaterialsAndUsesUsageDefaults) +TEST(MaterialResolverTest, CreatesAuthoredMaterialsAndUsesUsageDefaults) { MaterialRegistry registry; const Effect::MaterialResolver resolver{ registry }; diff --git a/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp b/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp deleted file mode 100644 index 033025db..00000000 --- a/Elixir/Tests/Engine/Aether/FrameSubmissionTest.cpp +++ /dev/null @@ -1,183 +0,0 @@ -#include - -#include -#include -#include -#include - -#include <../../../Source/Engine/Aether/Rendering/FrameSubmission.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(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_NE(submission.GetRenderProxies()[0], submission.GetRenderProxies()[1]); -} - -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); -} - -TEST(AetherFrameSubmissionTest, RemovesAnInstanceBeforeItIsRetired) -{ - const auto compiledSystem = CreateRef(); - const SystemInstance instance{ compiledSystem }; - 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(AetherFrameSubmissionTest, RetainsTheStateCapturedAtSubmission) -{ - const auto compiledSystem = CreateRef(); - SystemInstance instance{ compiledSystem }; - 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); -} - -TEST(AetherFrameSubmissionPublisherTest, PublishesOnlySealedSubmissions) -{ - const auto compiledSystem = CreateRef(); - const SystemInstance instance{ compiledSystem }; - 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(AetherFrameSubmissionPublisherTest, RemovesDestroyedInstanceFromPublishedFrame) -{ - const auto compiledSystem = CreateRef(); - const SystemInstance instance{ compiledSystem }; - const auto submission = CreateRef(); - FrameSubmissionPublisher publisher; - - ASSERT_TRUE(submission->Submit(instance)); - publisher.Publish(submission); - publisher.Remove(instance); - - EXPECT_TRUE(publisher.Acquire()->IsEmpty()); -} - -TEST(AetherFrameSubmissionPublisherTest, FiltersInstanceRejectedAtPublication) -{ - const auto system = CreateRef(); - const SystemInstance instance{ system }; - const auto submission = CreateRef(); - FrameSubmissionPublisher publisher; - - ASSERT_TRUE(submission->Submit(instance)); - publisher.Publish(submission, [](const SSystemInstanceKey&) - { - return false; - }); - - EXPECT_TRUE(publisher.Acquire()->IsEmpty()); -} - -TEST(AetherFrameSubmissionPublisherTest, PublishesAndAcquiresSealedFramesConcurrently) -{ - const auto system = CreateRef(); - const SystemInstance instance{ system }; - 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/FrameSubmissionPublisherTest.cpp b/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionPublisherTest.cpp new file mode 100644 index 00000000..08878d6b --- /dev/null +++ b/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionPublisherTest.cpp @@ -0,0 +1,93 @@ +#include + +#include +#include + +#include + +using namespace Elixir; +using namespace Elixir::Aether; +using namespace Elixir::Aether::Rendering; + +TEST(FrameSubmissionPublisherTest, PublishesOnlySealedSubmissions) +{ + const auto compiledSystem = CreateRef(); + const SystemInstance instance{ compiledSystem }; + 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) +{ + const auto compiledSystem = CreateRef(); + const SystemInstance instance{ compiledSystem }; + 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) +{ + const auto system = CreateRef(); + const SystemInstance instance{ system }; + 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) +{ + const auto system = CreateRef(); + const SystemInstance instance{ system }; + 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..d01af50b --- /dev/null +++ b/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionTest.cpp @@ -0,0 +1,98 @@ +#include + +#include +#include + +#include + +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) +{ + 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_NE(submission.GetRenderProxies()[0], submission.GetRenderProxies()[1]); +} + +TEST(FrameSubmissionTest, 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); +} + +TEST(FrameSubmissionTest, RemovesAnInstanceBeforeItIsRetired) +{ + const auto compiledSystem = CreateRef(); + const SystemInstance instance{ compiledSystem }; + 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) +{ + const auto compiledSystem = CreateRef(); + SystemInstance instance{ compiledSystem }; + 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..0d79b54a --- /dev/null +++ b/Elixir/Tests/Engine/Aether/Rendering/RendererTest.cpp @@ -0,0 +1,54 @@ +#include + +#include +#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< + Renderer, + const GraphicsContext*, + MaterialSystem& +>); +static_assert(!std::is_constructible_v< + Renderer, + const GraphicsContext*, + const ShaderLoader* +>); + +TEST(RendererTest, MetricsContainOnlyRenderingResults) +{ + const 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/SystemInstanceRetirementQueueTest.cpp b/Elixir/Tests/Engine/Aether/Rendering/SystemInstanceRetirementQueueTest.cpp similarity index 95% rename from Elixir/Tests/Engine/Aether/SystemInstanceRetirementQueueTest.cpp rename to Elixir/Tests/Engine/Aether/Rendering/SystemInstanceRetirementQueueTest.cpp index fd689f7e..82112314 100644 --- a/Elixir/Tests/Engine/Aether/SystemInstanceRetirementQueueTest.cpp +++ b/Elixir/Tests/Engine/Aether/Rendering/SystemInstanceRetirementQueueTest.cpp @@ -5,7 +5,7 @@ #include #include -#include <../../../Source/Engine/Aether/Rendering/SystemInstanceRetirementQueue.h> +#include using namespace Elixir; using namespace Elixir::Aether; diff --git a/Elixir/Tests/Engine/Aether/Simulation/RenderFrameTest.cpp b/Elixir/Tests/Engine/Aether/Simulation/RenderFrameTest.cpp new file mode 100644 index 00000000..f0165b69 --- /dev/null +++ b/Elixir/Tests/Engine/Aether/Simulation/RenderFrameTest.cpp @@ -0,0 +1,94 @@ +#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, + .DebugName = "Trail", + .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.DebugName, "Trail"); + 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 92% rename from Elixir/Tests/Engine/Aether/ParticleResourcePoolTest.cpp rename to Elixir/Tests/Engine/Aether/Simulation/ResourcePoolTest.cpp index dfa06aba..a321a6f0 100644 --- a/Elixir/Tests/Engine/Aether/ParticleResourcePoolTest.cpp +++ b/Elixir/Tests/Engine/Aether/Simulation/ResourcePoolTest.cpp @@ -1,6 +1,6 @@ #include -#include <../../../Source/Engine/Aether/Simulation/ResourcePool.h> +#include using namespace Elixir; using namespace Elixir::Aether; @@ -39,7 +39,7 @@ namespace } } -TEST(AetherParticleResourcePoolTest, AllocatesDisjointRangesForLiveInstances) +TEST(ResourcePoolTest, AllocatesDisjointRangesForLiveInstances) { const auto limits = MakePoolLimits(); const ParticleStateLayoutRegistry layouts{ limits.ParticleCapacity }; @@ -65,7 +65,7 @@ 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 }; @@ -93,7 +93,7 @@ TEST(AetherParticleResourcePoolTest, ReusesReleasedRanges) EXPECT_EQ(reused->TriggerQueueStates.Offset, first->TriggerQueueStates.Offset); } -TEST(AetherParticleResourcePoolTest, RollsBackPartialAllocationFailure) +TEST(ResourcePoolTest, RollsBackPartialAllocationFailure) { auto limits = MakePoolLimits(); limits.ParticleCapacity = 4; @@ -118,7 +118,7 @@ TEST(AetherParticleResourcePoolTest, RollsBackPartialAllocationFailure) EXPECT_EQ(allocation->TriggerQueueStates.Offset, 0u); } -TEST(AetherParticleResourcePoolTest, RejectsAnUnregisteredParticleStateLayout) +TEST(ResourcePoolTest, RejectsAnUnregisteredParticleStateLayout) { const auto limits = MakePoolLimits(); const ParticleStateLayoutRegistry layouts{ limits.ParticleCapacity }; diff --git a/Elixir/Tests/Engine/Aether/Simulation/SimulatorTest.cpp b/Elixir/Tests/Engine/Aether/Simulation/SimulatorTest.cpp new file mode 100644 index 00000000..bb2155e8 --- /dev/null +++ b/Elixir/Tests/Engine/Aether/Simulation/SimulatorTest.cpp @@ -0,0 +1,48 @@ +#include + +#include +#include +#include + +#include +#include + +namespace Elixir { 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*, + 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 c495ec83..59220abe 100644 --- a/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp @@ -4,7 +4,7 @@ #include #include -#include <../../../Source/Engine/Aether/Rendering/FrameSubmission.h> +#include using namespace Elixir; using namespace Elixir::Aether; @@ -44,7 +44,7 @@ namespace } } -TEST(AetherSystemInstanceTest, ReplacesCompiledSystemAndIncrementsRevision) +TEST(SystemInstanceTest, ReplacesCompiledSystemAndIncrementsRevision) { const auto initialSystem = CreateRef(); const auto replacementSystem = CreateRef(); @@ -60,7 +60,7 @@ TEST(AetherSystemInstanceTest, ReplacesCompiledSystemAndIncrementsRevision) EXPECT_EQ(&snapshot->GetCompiledSystem(), replacementSystem.get()); } -TEST(AetherSystemInstanceTest, DoesNotIncrementRevisionForSameCompiledSystem) +TEST(SystemInstanceTest, DoesNotIncrementRevisionForSameCompiledSystem) { const auto compiledSystem = CreateRef(); SystemInstance instance{ compiledSystem }; @@ -69,7 +69,7 @@ TEST(AetherSystemInstanceTest, DoesNotIncrementRevisionForSameCompiledSystem) EXPECT_EQ(CaptureForTest(instance)->GetRevision(), 1); } -TEST(AetherSystemInstanceTest, AppliesOverridesOnlyToExposedParameters) +TEST(SystemInstanceTest, AppliesOverridesOnlyToExposedParameters) { const auto compiledSystem = MakeCompiledSystem(); SystemInstance instance{ compiledSystem }; @@ -95,7 +95,7 @@ TEST(AetherSystemInstanceTest, AppliesOverridesOnlyToExposedParameters) EXPECT_FLOAT_EQ(colorChunk.w, 1.0f); } -TEST(AetherSystemInstanceTest, ClearsOverridesAndRestoresCompiledDefaults) +TEST(SystemInstanceTest, ClearsOverridesAndRestoresCompiledDefaults) { const auto compiledSystem = MakeCompiledSystem(); SystemInstance instance{ compiledSystem }; @@ -111,7 +111,7 @@ TEST(AetherSystemInstanceTest, ClearsOverridesAndRestoresCompiledDefaults) EXPECT_FLOAT_EQ(tint.w, 1.0f); } -TEST(AetherSystemInstanceTest, RetainsOnlyOverridesExposedByReplacementSystem) +TEST(SystemInstanceTest, RetainsOnlyOverridesExposedByReplacementSystem) { const auto initialSystem = MakeCompiledSystem(); SystemInstance instance{ initialSystem }; @@ -135,7 +135,7 @@ TEST(AetherSystemInstanceTest, RetainsOnlyOverridesExposedByReplacementSystem) EXPECT_FLOAT_EQ(tint.w, 1.0f); } -TEST(AetherSystemInstanceTest, StoresWorldTransformWithoutChangingCompiledSystem) +TEST(SystemInstanceTest, StoresWorldTransformWithoutChangingCompiledSystem) { const auto compiledSystem = MakeCompiledSystem(); SystemInstance instance{ compiledSystem }; @@ -153,7 +153,7 @@ TEST(AetherSystemInstanceTest, StoresWorldTransformWithoutChangingCompiledSystem EXPECT_EQ(&snapshot->GetCompiledSystem(), compiledSystem.get()); } -TEST(AetherSystemInstanceTest, KeepsCapturedProxyImmutableDuringConcurrentOverrides) +TEST(SystemInstanceTest, KeepsCapturedProxyImmutableDuringConcurrentOverrides) { const auto system = MakeCompiledSystem(); SystemInstance instance{ system }; diff --git a/Elixir/Tests/Engine/Aether/SystemTest.cpp b/Elixir/Tests/Engine/Aether/SystemTest.cpp index 3282fcd4..cedbfc85 100644 --- a/Elixir/Tests/Engine/Aether/SystemTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemTest.cpp @@ -21,7 +21,7 @@ namespace } } -TEST(AetherSystemTest, CompilePreservesEmitterSimulationSpace) +TEST(SystemTest, CompilePreservesEmitterSimulationSpace) { System system{ "Simulation space contract" }; system.AddEmitter("World", 8, 0.0f); // world emitter @@ -35,7 +35,7 @@ TEST(AetherSystemTest, CompilePreservesEmitterSimulationSpace) 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); @@ -52,7 +52,7 @@ TEST(AetherSystemTest, CompileAssignsContiguousLocalEmitterParticleOffsets) EXPECT_EQ(compiled.TotalMaxParticles, 21u); } -TEST(AetherSystemTest, CompileResolvesTriggerEmitterByCompiledIndex) +TEST(SystemTest, CompileResolvesTriggerEmitterByCompiledIndex) { System system{ "Trigger contract" }; system.AddEmitter("Source", 8, 0.0f); @@ -75,7 +75,7 @@ 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); @@ -97,7 +97,7 @@ TEST(AetherSystemTest, CompileExposesOnlyAuthoredParameters) EXPECT_EQ(compiled.Parameters[3].Name, "SizeOverLife:1"); } -TEST(AetherSystemTest, FindsNamedEmitterForMaterialPublication) +TEST(SystemTest, FindsNamedEmitterForMaterialPublication) { System system{ "Named emitters" }; auto& flame = system.AddEmitter("FlameCore", 8, 0.0f); @@ -108,7 +108,7 @@ TEST(AetherSystemTest, FindsNamedEmitterForMaterialPublication) EXPECT_EQ(system.FindEmitter("Missing"), nullptr); } -TEST(AetherSystemTest, CompileSnapshotsParticleSpriteMaterialForRenderData) +TEST(SystemTest, CompileSnapshotsParticleSpriteMaterialForRenderData) { const auto material = CreateRef("Particle tint"); ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleSprite, true)); @@ -145,7 +145,7 @@ TEST(AetherSystemTest, CompileSnapshotsParticleSpriteMaterialForRenderData) EXPECT_FLOAT_EQ(second.Emitters[0].Material->GetValues()[0].x, 0.75f); } -TEST(AetherSystemTest, CompileSnapshotsParticleRibbonMaterialForRenderData) +TEST(SystemTest, CompileSnapshotsParticleRibbonMaterialForRenderData) { const auto material = CreateRef("Particle ribbon"); ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleRibbon, true)); @@ -166,7 +166,7 @@ TEST(AetherSystemTest, CompileSnapshotsParticleRibbonMaterialForRenderData) )); } -TEST(AetherSystemTest, CompileSnapshotsParticleMeshMaterialForRenderData) +TEST(SystemTest, CompileSnapshotsParticleMeshMaterialForRenderData) { const auto material = CreateRef("Particle mesh"); material->SetUsage(EMaterialUsage::ParticleMesh, true); @@ -187,7 +187,7 @@ TEST(AetherSystemTest, CompileSnapshotsParticleMeshMaterialForRenderData) )); } -TEST(AetherSystemTest, KeepsAnEmitterWithoutAnExplicitMaterialUnbound) +TEST(SystemTest, KeepsAnEmitterWithoutAnExplicitMaterialUnbound) { System system{ "Explicit material contract" }; system.AddEmitter("Smoke", 8, 0.0f); From de210ace02896b1eaa5406814b97f80b739bda06 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Mon, 10 Aug 2026 23:27:44 -0300 Subject: [PATCH 58/89] docs(aether): document simulation rendering boundary Update Manager, Simulator, RenderFrame, and Renderer documentation to describe their current ownership, data flow, and runtime responsibilities. --- Elixir/Source/Engine/Aether/Manager.h | 73 +++++++++------- .../Source/Engine/Aether/Rendering/Renderer.h | 52 ++++++++++-- .../Engine/Aether/Simulation/RenderFrame.h | 51 ++++++++++-- .../Engine/Aether/Simulation/Simulator.h | 83 +++++++++++++++++-- 4 files changed, 206 insertions(+), 53 deletions(-) diff --git a/Elixir/Source/Engine/Aether/Manager.h b/Elixir/Source/Engine/Aether/Manager.h index d6791ad3..d73d67f3 100644 --- a/Elixir/Source/Engine/Aether/Manager.h +++ b/Elixir/Source/Engine/Aether/Manager.h @@ -38,20 +38,22 @@ namespace Elixir::Aether using namespace Rendering; /** - * @brief Coordinates Aether effect compilation, runtime instances, and rendering. + * @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. * - * The manager owns the Aether renderer. The renderer owns GPU allocations, - * synchronization, simulation and draw execution. + * 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. * * A frame producer creates and fills a FrameSubmission. Publishing the - * submission transfers an immutable view of its instances to the render path. + * submission makes its immutable instance data available to Simulator. + * Simulator produces a RenderFrame for Renderer. * - * @note The GraphicsContext, ShaderLoader, MaterialRegistry, and MaterialSystem - * must outlive this manager. + * @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. @@ -62,10 +64,15 @@ namespace Elixir::Aether /** * @brief Creates an Aether manager. * - * @param context Provides graphics resources and frame synchronization. - * @param shaderLoader Loads the shaders required by the particle renderer. + * @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 Resolves material instances for rendering. + * @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, @@ -75,9 +82,7 @@ namespace Elixir::Aether ); /** - * @brief Destroys the manager and its owned renderer. - * - * Runtime instances must not be used after the manager is destroyed. + * @brief Destroys the manager and its simulation and rendering services. */ ~Manager(); @@ -114,9 +119,10 @@ namespace Elixir::Aether /** * @brief Creates and registers a runtime instance of a compiled system. * - * The returned instance exposes the runtime API for transforms and - * parameter overrides. The manager retains registration and GPU lifetime - * ownership. + * The instance provides the runtime API for transforms and parameter + * overrides. Manager keeps the instance registered until DestroyInstance() + * removes it. Simulator creates its GPU allocation when it first processes + * the instance. * * @param system Immutable compiled system data. * @return The registered runtime instance. @@ -128,27 +134,28 @@ namespace Elixir::Aether /** * @brief Detaches a runtime instance from future frames. * - * The method removes the instance from the manager and from the published - * submission. The renderer retires its GPU allocation during a later frame - * and releases it after the required GPU fence completes. + * 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. * - * @warning Do not submit the instance after this method returns true. + * @note Submit() rejects the instance after this method returns true. */ bool DestroyInstance(const Ref& instance); /** - * @brief Starts an Aether render frame. + * @brief Prepares Aether for a new frame. * - * Updates renderer frame state and forwards pending instance retirements to - * the renderer. + * The method updates simulation time, releases allocations whose GPU work + * has completed, and forwards destroyed instances to Simulator. * * @param timestep Elapsed time for the current frame. * - * @note Call this method from the render-frame path. + * @note Call this method once per frame, after the graphics context prepares + * the current frame slot and before Render(). */ void BeginFrame(const Timestep& timestep); @@ -182,9 +189,9 @@ namespace Elixir::Aether /** * @brief Publishes a completed frame submission for rendering. * - * The method seals the submission and removes instances that were detached - * before publication. The renderer consumes the latest published - * submission. + * The method seals the submission and removes instances that Manager no + * longer owns. Simulator consumes the latest published submission when + * Render() runs. * * @param submission Submission to publish. * @@ -204,17 +211,25 @@ namespace Elixir::Aether */ 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: Simulator& GetSimulator() const; - Renderer& GetRenderer() const; - // Forwards detached instances to the renderer for fence-safe GPU retirement. + // Forwards detached instances to Simulator for fence-safe GPU retirement. void RetireDestroyedInstances(); // Checks ownership while the instance registry mutex is already held. diff --git a/Elixir/Source/Engine/Aether/Rendering/Renderer.h b/Elixir/Source/Engine/Aether/Rendering/Renderer.h index 22185a6e..84b32ec2 100644 --- a/Elixir/Source/Engine/Aether/Rendering/Renderer.h +++ b/Elixir/Source/Engine/Aether/Rendering/Renderer.h @@ -23,6 +23,12 @@ namespace Elixir::Aether::Rendering 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; @@ -32,34 +38,65 @@ namespace Elixir::Aether::Rendering }; /** - * @brief Records material-based particle graphics passes. + * @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 consumes an immutable RenderFrame produced by Simulator. - * It owns graphics layouts, frame constants, mesh geometry, and - * MaterialSystem scene construction. It does not own simulation state. + * Renderer does not simulate particles, allocate simulation resources, or + * submit command buffers. * - * @thread_safety Render-thread confined. + * @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. + * @param materialSystem Material system used to render particle materials. + * + * @pre context is not null and outlives the renderer. + * @pre materialSystem outlives the renderer. + */ Renderer( const GraphicsContext* context, MaterialSystem& materialSystem ); + /** + * @brief Records the draw commands for a simulated particle frame. + * + * The method updates the frame constants, creates a material render scene, + * and records the particle graphics passes. An empty frame records no + * graphics pass. + * + * @param frame Particle data produced by Simulator. + * @param camera Camera used to transform and project the particles. + * @param cmd Command buffer that receives the graphics commands. + * + * @pre cmd is not null and is recording commands. + */ void Render( const RenderFrame& frame, const Camera& camera, const Ref& cmd ); + /** + * @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; @@ -67,6 +104,7 @@ namespace Elixir::Aether::Rendering BufferLayout MeshVertexLayout; }; + // Creates the sprite and mesh vertex layouts for CoreV1 particles. void CreateCoreV1GraphicsLayout(); // Creates the unit mesh geometry used by mesh particle rendering. @@ -81,14 +119,16 @@ namespace Elixir::Aether::Rendering // Ends the graphics rendering scope for particle material passes. static void EndRendering(const Ref& cmd); + // 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 ); - // Build material render items from submitted particle instances. + // Converts particle render items into material geometry and draw commands. MaterialRenderScene BuildMaterialRenderScene(const RenderFrame& frame) const; SFrameData m_FrameData{}; diff --git a/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h b/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h index 20dcaad3..eeb2ef0f 100644 --- a/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h +++ b/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h @@ -42,22 +42,32 @@ namespace Elixir::Aether::Simulation }; /** - * @brief Publishes immutable particle render data for one submission. + * @brief Provides immutable particle render data for one simulated frame. * - * Simulator creates one frame after recording compute work and the required - * compute-to-graphics barriers. Renderer consumes its resources and items - * synchronously while recording graphics commands. + * 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. * - * RenderFrame owns frame-local metadata and strong references to shared GPU - * resources. It never stores a command buffer or mutable SystemInstance - * state. + * 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 readers are safe - * when the referenced GPU wrappers are used according to their own contract. + * @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, @@ -75,11 +85,34 @@ namespace Elixir::Aether::Simulation 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: diff --git a/Elixir/Source/Engine/Aether/Simulation/Simulator.h b/Elixir/Source/Engine/Aether/Simulation/Simulator.h index 3368735d..33e881d1 100644 --- a/Elixir/Source/Engine/Aether/Simulation/Simulator.h +++ b/Elixir/Source/Engine/Aether/Simulation/Simulator.h @@ -18,6 +18,16 @@ 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; @@ -41,12 +51,16 @@ namespace Elixir::Aether::Simulation }; /** - * @brief Owns persistent GPU particle simulation state. + * @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. * - * Simulator consumes immutable system-instance proxies, updates persistent GPU - * simulation tables, records scheduler/spawn/update compute work, and produces - * an immutable RenderFrame. It owns resource allocation and fence-safe - * retirement; it has no dependency on MaterialSystem. + * @thread_safety Use this class only from the render-frame thread. */ class ELIXIR_API Simulator final { @@ -54,21 +68,68 @@ namespace Elixir::Aether::Simulation /** 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; @@ -105,7 +166,7 @@ namespace Elixir::Aether::Simulation SSystemInstanceAllocation Allocation; }; - // Pairs one immutable render proxy with its renderer-owned GPU allocation. + // Pairs one immutable render proxy with its simulator-owned GPU allocation. struct SSubmittedSystemInstance { Ref Proxy; @@ -135,7 +196,7 @@ namespace Elixir::Aether::Simulation // Binds buffers and constants specific to one particle-state layout. void BindParticleStateLayoutShaderParameters(const SParticleStateLayoutRuntime& runtime) const; - // Finds or creates the renderer record required by an immutable instance proxy. + // 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. @@ -159,9 +220,10 @@ namespace Elixir::Aether::Simulation // Finds the read-only GPU runtime for a registered particle-state layout. const SParticleStateLayoutRuntime* FindParticleStateLayoutRuntime(EParticleStateLayout layout) const; - // Returns whether the renderer has a ready GPU runtime for the layout. + // 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 ); @@ -171,8 +233,10 @@ namespace Elixir::Aether::Simulation 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 ); @@ -180,6 +244,7 @@ namespace Elixir::Aether::Simulation // 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. @@ -188,7 +253,7 @@ namespace Elixir::Aether::Simulation // Returns allocations whose associated GPU work has completed to ResourcePool. void ProcessCompletedRetirements(); - // Makes scheduler writes visibility to subsequent Aether compute passes. + // Makes scheduler writes visible to later compute passes. void BarrierSchedulingBuffers(const Ref& cmd) const; // Clears persistent particle state before a released allocation is reused. From 7bcf8d9dd49bf72e7e9e4b6da96e9a7743482fe4 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Tue, 11 Aug 2026 00:48:45 -0300 Subject: [PATCH 59/89] refactor(aether): remove names from runtime data Keep authored system and emitter names outside compiled simulation and render structures. Use stable source identities in runtime diagnostics. --- Elixir/Source/Engine/Aether/Emitter.cpp | 1 - Elixir/Source/Engine/Aether/Emitter.h | 7 ++++++- .../Source/Engine/Aether/Rendering/Renderer.cpp | 3 --- .../Source/Engine/Aether/Simulation/RenderFrame.h | 1 - .../Source/Engine/Aether/Simulation/Simulator.cpp | 8 +++++--- Elixir/Source/Engine/Aether/System.cpp | 15 +++++++++------ Elixir/Source/Engine/Aether/System.h | 1 - .../Source/Engine/Material/MaterialRenderScene.h | 1 - Elixir/Source/Engine/Material/MaterialSystem.cpp | 5 +---- .../Engine/Aether/Simulation/RenderFrameTest.cpp | 2 -- 10 files changed, 21 insertions(+), 23 deletions(-) diff --git a/Elixir/Source/Engine/Aether/Emitter.cpp b/Elixir/Source/Engine/Aether/Emitter.cpp index c1bb4f81..0c6ab96e 100644 --- a/Elixir/Source/Engine/Aether/Emitter.cpp +++ b/Elixir/Source/Engine/Aether/Emitter.cpp @@ -20,7 +20,6 @@ namespace Elixir::Aether { SCompiledEmitter emitter; emitter.Id = m_Id; - emitter.Name = m_Name; emitter.RenderMode = m_RenderMode; emitter.SimulationSpace = m_SimulationSpace; emitter.MaxParticles = m_MaxParticles; diff --git a/Elixir/Source/Engine/Aether/Emitter.h b/Elixir/Source/Engine/Aether/Emitter.h index 04ad0173..a3c8e5aa 100644 --- a/Elixir/Source/Engine/Aether/Emitter.h +++ b/Elixir/Source/Engine/Aether/Emitter.h @@ -30,7 +30,6 @@ namespace Elixir::Aether struct SCompiledEmitter { UUID Id; - std::string Name; EParticleRenderMode RenderMode = EParticleRenderMode::Sprite; EParticleSimulationSpace SimulationSpace = EParticleSimulationSpace::World; @@ -164,6 +163,12 @@ namespace Elixir::Aether MaterialResolver& materialResolver ) const; + /** + * @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. diff --git a/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp b/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp index b3b313c4..54fa3a92 100644 --- a/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp @@ -351,7 +351,6 @@ namespace Elixir::Aether::Rendering scene.Add({ .Pass = EMaterialPass::ParticleSprite, .Material = item.Material, - .DebugName = item.DebugName, .GeometryIndex = geometry->Sprite, .PushConstants = SMaterialPushConstants::Create( constants, @@ -378,7 +377,6 @@ namespace Elixir::Aether::Rendering scene.Add({ .Pass = EMaterialPass::ParticleRibbon, .Material = item.Material, - .DebugName = item.DebugName, .GeometryIndex = geometry->Ribbon, .PushConstants = SMaterialPushConstants::Create( constants, @@ -398,7 +396,6 @@ namespace Elixir::Aether::Rendering scene.Add({ .Pass = EMaterialPass::ParticleMesh, .Material = item.Material, - .DebugName = item.DebugName, .GeometryIndex = geometry->Mesh, .PushConstants = SMaterialPushConstants::Create( constants, diff --git a/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h b/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h index eeb2ef0f..66912207 100644 --- a/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h +++ b/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h @@ -35,7 +35,6 @@ namespace Elixir::Aether::Simulation Core::EParticleRenderMode RenderMode = Core::EParticleRenderMode::Sprite; Ref Material; glm::mat4 WorldTransform{ 1.0f }; - std::string DebugName; uint32_t EmitterIndex = 0; uint32_t LocalParticleOffset = 0; uint32_t ParticleCount = 0; diff --git a/Elixir/Source/Engine/Aether/Simulation/Simulator.cpp b/Elixir/Source/Engine/Aether/Simulation/Simulator.cpp index f2be4595..b189cc7c 100644 --- a/Elixir/Source/Engine/Aether/Simulation/Simulator.cpp +++ b/Elixir/Source/Engine/Aether/Simulation/Simulator.cpp @@ -631,7 +631,10 @@ namespace Elixir::Aether::Simulation { if (m_AllocationFailures.insert(proxy.GetKey()).second) { - EE_CORE_ERROR("Aether GPU resource pool exhausted for system '{}'.", system.Name) + EE_CORE_ERROR( + "Aether GPU resource pool exhausted for system '{}'.", + system.SourceId + ) } return nullptr; } @@ -792,7 +795,7 @@ namespace Elixir::Aether::Simulation EE_CORE_ERROR( "Aether particle state layout '{}' is unsupported for system '{}'.", (uint32_t)system.ParticleStateLayout, - system.Name + system.SourceId ) } continue; @@ -883,7 +886,6 @@ namespace Elixir::Aether::Simulation .RenderMode = emitter.RenderMode, .Material = emitter.Material, .WorldTransform = GetParticleRenderTransform(emitter, *instance.Proxy), - .DebugName = emitter.Name, .EmitterIndex = emitterIndex, .LocalParticleOffset = emitter.LocalParticleOffset, .ParticleCount = emitter.MaxParticles, diff --git a/Elixir/Source/Engine/Aether/System.cpp b/Elixir/Source/Engine/Aether/System.cpp index bfc76846..66646159 100644 --- a/Elixir/Source/Engine/Aether/System.cpp +++ b/Elixir/Source/Engine/Aether/System.cpp @@ -31,7 +31,6 @@ namespace Elixir::Aether SCompiledSystem system; system.SourceId = m_UUID; system.CompilationRevision = ++m_CompilationRevision; - system.Name = m_Name; system.Parameters = m_Parameters.Compile(); @@ -114,14 +113,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; @@ -135,7 +134,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 376ee018..d9cbb14e 100644 --- a/Elixir/Source/Engine/Aether/System.h +++ b/Elixir/Source/Engine/Aether/System.h @@ -55,7 +55,6 @@ namespace Elixir::Aether UUID SourceId; uint32_t CompilationRevision = 0; - std::string Name; EParticleStateLayout ParticleStateLayout = EParticleStateLayout::CoreV1; std::vector Emitters; diff --git a/Elixir/Source/Engine/Material/MaterialRenderScene.h b/Elixir/Source/Engine/Material/MaterialRenderScene.h index 1c6f3ea0..f822f2d7 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderScene.h +++ b/Elixir/Source/Engine/Material/MaterialRenderScene.h @@ -65,7 +65,6 @@ namespace Elixir { EMaterialPass Pass = EMaterialPass::ParticleSprite; Ref Material; - std::string_view DebugName; uint32_t GeometryIndex = UINT32_MAX; SMaterialPushConstants PushConstants; SMaterialDrawCommand Draw; diff --git a/Elixir/Source/Engine/Material/MaterialSystem.cpp b/Elixir/Source/Engine/Material/MaterialSystem.cpp index a8e4aa6d..d5538bc4 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.cpp +++ b/Elixir/Source/Engine/Material/MaterialSystem.cpp @@ -119,10 +119,7 @@ namespace Elixir { if (!item.Material) { - EE_CORE_ERROR( - "Material render item '{}' has no compiled material proxy.", - item.DebugName - ) + EE_CORE_ERROR("Material render item has no compiled material proxy.") continue; } diff --git a/Elixir/Tests/Engine/Aether/Simulation/RenderFrameTest.cpp b/Elixir/Tests/Engine/Aether/Simulation/RenderFrameTest.cpp index f0165b69..6a556dc7 100644 --- a/Elixir/Tests/Engine/Aether/Simulation/RenderFrameTest.cpp +++ b/Elixir/Tests/Engine/Aether/Simulation/RenderFrameTest.cpp @@ -54,7 +54,6 @@ TEST(RenderFrameTest, PublishesResolvedSimulationData) .RenderMode = EParticleRenderMode::Ribbon, .Material = {}, .WorldTransform = transform, - .DebugName = "Trail", .EmitterIndex = 1u, .LocalParticleOffset = 16u, .ParticleCount = 48u, @@ -82,7 +81,6 @@ TEST(RenderFrameTest, PublishesResolvedSimulationData) EXPECT_EQ(item.Allocation.Emitters.Offset, 11u); EXPECT_EQ(item.ParticleStateLayout, EParticleStateLayout::CoreV1); EXPECT_EQ(item.RenderMode, EParticleRenderMode::Ribbon); - EXPECT_EQ(item.DebugName, "Trail"); EXPECT_EQ(item.EmitterIndex, 1u); EXPECT_EQ(item.LocalParticleOffset, 16u); EXPECT_EQ(item.ParticleCount, 48u); From cd95faa94ce218ceedf842cb3c841154b91fb2ed Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Tue, 11 Aug 2026 13:02:12 -0300 Subject: [PATCH 60/89] refactor(aether): resolve parameters in system instances Keep mutable overrides in SystemInstance and publish immutable resolved parameter tables. Make snapshots and render proxies consume values without interpreting override state. --- .../Rendering/SystemInstanceRenderProxy.cpp | 21 +- .../Rendering/SystemInstanceRenderProxy.h | 21 +- .../Source/Engine/Aether/SystemInstance.cpp | 182 +++++++++--------- Elixir/Source/Engine/Aether/SystemInstance.h | 60 ++++-- .../Engine/Aether/SystemInstanceTest.cpp | 29 +++ 5 files changed, 184 insertions(+), 129 deletions(-) diff --git a/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.cpp b/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.cpp index 6650e5cb..a8da84a7 100644 --- a/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.cpp +++ b/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.cpp @@ -9,20 +9,21 @@ namespace Elixir::Aether::Rendering const uint32_t parameterRevision, Ref system, const glm::mat4& worldTransform, - const ParameterOverridesMap& overrides + Ref parameters ) : m_Key(key), m_Revision(revision), m_ParameterRevision(parameterRevision), m_CompiledSystem(std::move(system)), - m_WorldTransform(worldTransform) + m_WorldTransform(worldTransform), + m_Parameters(std::move(parameters)) { - m_ParameterValues.reserve(m_CompiledSystem->Parameters.size()); - - for (const auto& parameter : m_CompiledSystem->Parameters) - { - const auto found = overrides.find(parameter.Name); - const auto value = found != overrides.end() ? found->second : parameter.Value; - m_ParameterValues.push_back(value); - } + 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 index a93f384f..8711cc45 100644 --- a/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.h +++ b/Elixir/Source/Engine/Aether/Rendering/SystemInstanceRenderProxy.h @@ -13,12 +13,11 @@ namespace Elixir::Aether::Rendering * @brief Provides immutable SystemInstance data to the particle renderer. * * SystemInstanceSnapshot creates this proxy from one compiled system, world - * transform, and parameter-override set. FrameSubmission stores the proxy, so - * Renderer never reads mutable SystemInstance state. + * transform, and resolved parameter table. FrameSubmission stores the proxy, + * so Renderer never reads mutable SystemInstance state. * - * The proxy resolves each compiled parameter to either its instance override or - * its compiled default value. Its parameter table has the same order as - * SCompiledSystem::Parameters. + * The proxy shares the immutable parameter table created by SystemInstance. + * Its values have the same order as SCompiledSystem::Parameters. * * @thread_safety Immutable after construction. */ @@ -43,11 +42,11 @@ namespace Elixir::Aether::Rendering glm::vec4 GetParameterValue(const uint32_t parameterIndex) const { EE_CORE_ASSERT( - parameterIndex < m_ParameterValues.size(), + parameterIndex < m_Parameters->size(), "Aether parameter index is outside the render proxy table." ) - return parameterIndex < m_ParameterValues.size() - ? m_ParameterValues[parameterIndex] + return parameterIndex < m_Parameters->size() + ? (*m_Parameters)[parameterIndex] : glm::vec4{}; } @@ -76,14 +75,14 @@ namespace Elixir::Aether::Rendering const glm::mat4& GetWorldTransform() const { return m_WorldTransform; } private: - // Resolves compiled defaults and instance overrides into a dense parameter table. + // Stores immutable state already resolved by SystemInstance. SystemInstanceRenderProxy( const SSystemInstanceKey& key, uint32_t revision, uint32_t parameterRevision, Ref system, const glm::mat4& worldTransform, - const ParameterOverridesMap& overrides + Ref parameters ); // Returns the internal identity used by Renderer instance records. @@ -94,6 +93,6 @@ namespace Elixir::Aether::Rendering uint32_t m_ParameterRevision = 1; Ref m_CompiledSystem; glm::mat4 m_WorldTransform{ 1.0f }; - std::vector m_ParameterValues; + Ref m_Parameters; }; } diff --git a/Elixir/Source/Engine/Aether/SystemInstance.cpp b/Elixir/Source/Engine/Aether/SystemInstance.cpp index d997a02e..a826659d 100644 --- a/Elixir/Source/Engine/Aether/SystemInstance.cpp +++ b/Elixir/Source/Engine/Aether/SystemInstance.cpp @@ -13,35 +13,29 @@ namespace Elixir::Aether const uint32_t parameterRevision, Ref system, const glm::mat4& worldTransform, - Ref overrides + Ref parameters ) : m_Key(std::move(key)), m_Revision(revision), m_ParameterRevision(parameterRevision), m_CompiledSystem(std::move(system)), m_WorldTransform(worldTransform), - m_ParameterOverrides(std::move(overrides)), + m_Parameters(std::move(parameters)), m_RenderProxy(new Rendering::SystemInstanceRenderProxy( m_Key, m_Revision, m_ParameterRevision, m_CompiledSystem, m_WorldTransform, - *m_ParameterOverrides + m_Parameters )) {} /* SystemInstance */ SystemInstance::SystemInstance(Ref system) + : m_CompiledSystem(system) { - EE_CORE_ASSERT(system, "SystemInstance requires a compiled system.") - m_Snapshot = CreateSnapshot( - m_Key, - 1, - 1, - std::move(system), - glm::mat4{ 1.0f }, - CreateRef() - ); + EE_CORE_ASSERT(m_CompiledSystem, "SystemInstance requires a compiled system.") + PublishSnapshot(); } void SystemInstance::SetCompiledSystem(Ref system) @@ -49,26 +43,21 @@ namespace Elixir::Aether EE_CORE_ASSERT(system, "SystemInstance requires a compiled system.") const std::scoped_lock lock(m_SnapshotMutex); - if (m_Snapshot->m_CompiledSystem == system) + if (m_CompiledSystem == system) return; - auto overrides = CreateRef( - *m_Snapshot->m_ParameterOverrides - ); - - const auto removed = std::erase_if(*overrides, [&system](const auto& entry) + const auto removed = std::erase_if(m_ParameterOverrides, [&system](const auto& entry) { - return !IsExposedParameter(*system, entry.first); + return FindExposedParameter(*system, entry.first) == nullptr; }); - m_Snapshot = CreateSnapshot( - m_Key, - m_Snapshot->m_Revision + 1, - m_Snapshot->m_ParameterRevision + (removed > 0 ? 1 : 0), - std::move(system), - m_Snapshot->m_WorldTransform, - std::move(overrides) - ); + m_CompiledSystem = std::move(system); + ++m_Revision; + + if (removed > 0) + ++m_ParameterRevision; + + PublishSnapshot(); } bool SystemInstance::SetParameterOverride( @@ -78,22 +67,12 @@ namespace Elixir::Aether { const std::scoped_lock lock(m_SnapshotMutex); - if (!IsExposedParameter(*m_Snapshot->m_CompiledSystem, name)) + if (!FindExposedParameter(*m_CompiledSystem, name)) return false; - auto overrides = CreateRef( - *m_Snapshot->m_ParameterOverrides - ); - - overrides->insert_or_assign(std::string(name), value); - m_Snapshot = CreateSnapshot( - m_Key, - m_Snapshot->m_Revision, - m_Snapshot->m_ParameterRevision + 1, - m_Snapshot->m_CompiledSystem, - m_Snapshot->m_WorldTransform, - std::move(overrides) - ); + m_ParameterOverrides.insert_or_assign(name, value); + ++m_ParameterRevision; + PublishSnapshot(); return true; } @@ -102,24 +81,11 @@ namespace Elixir::Aether { const std::scoped_lock lock(m_SnapshotMutex); - const auto found = m_Snapshot->m_ParameterOverrides->find(name); - if (found == m_Snapshot->m_ParameterOverrides->end()) + if (m_ParameterOverrides.erase(name) == 0) return false; - auto overrides = CreateRef( - *m_Snapshot->m_ParameterOverrides - ); - - overrides->erase(found->first); - - m_Snapshot = CreateSnapshot( - m_Key, - m_Snapshot->m_Revision, - m_Snapshot->m_ParameterRevision + 1, - m_Snapshot->m_CompiledSystem, - m_Snapshot->m_WorldTransform, - std::move(overrides) - ); + ++m_ParameterRevision; + PublishSnapshot(); return true; } @@ -128,66 +94,100 @@ namespace Elixir::Aether { const std::scoped_lock lock(m_SnapshotMutex); - if (m_Snapshot->m_ParameterOverrides->empty()) + if (m_ParameterOverrides.empty()) return; - m_Snapshot = CreateSnapshot( - m_Key, - m_Snapshot->m_Revision, - m_Snapshot->m_ParameterRevision + 1, - m_Snapshot->m_CompiledSystem, - m_Snapshot->m_WorldTransform, - CreateRef() - ); + m_ParameterOverrides.clear(); + ++m_ParameterRevision; + PublishSnapshot(); } - void SystemInstance::SetWorldTransform(const glm::mat4& worldTransform) + std::optional SystemInstance::GetParameterValue(const std::string& name) const { const std::scoped_lock lock(m_SnapshotMutex); - m_Snapshot = CreateSnapshot( - m_Key, - m_Snapshot->m_Revision, - m_Snapshot->m_ParameterRevision, - m_Snapshot->m_CompiledSystem, - worldTransform, - m_Snapshot->m_ParameterOverrides + const auto* exposedParameter = FindExposedParameter(*m_CompiledSystem, name); + if (!exposedParameter) return std::nullopt; + + 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; + PublishSnapshot(); + } + Ref SystemInstance::CaptureSnapshot() const { const std::scoped_lock lock(m_SnapshotMutex); return m_Snapshot; } - Ref SystemInstance::CreateSnapshot( - const SSystemInstanceKey& key, - uint32_t revision, - uint32_t parameterRevision, - Ref system, - const glm::mat4& worldTransform, - Ref overrides - ) + void SystemInstance::PublishSnapshot() { - return CreateRef( - key, - revision, - parameterRevision, - std::move(system), - worldTransform, - std::move(overrides) + m_Snapshot = CreateRef( + m_Key, + m_Revision, + m_ParameterRevision, + m_CompiledSystem, + m_WorldTransform, + ResolveParameterValues(*m_CompiledSystem, m_ParameterOverrides) ); } - bool SystemInstance::IsExposedParameter(const SCompiledSystem& system, std::string_view name) + const SExposedParameter* SystemInstance::FindExposedParameter( + const SCompiledSystem& system, + std::string_view name + ) { - return std::ranges::any_of( + 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 b78dd5ab..dcb64863 100644 --- a/Elixir/Source/Engine/Aether/SystemInstance.h +++ b/Elixir/Source/Engine/Aether/SystemInstance.h @@ -42,13 +42,14 @@ namespace Elixir::Aether }; 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 parameter - * overrides selected at one point in time. It also creates the corresponding - * SystemInstanceRenderProxy for the renderer. + * 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 @@ -72,10 +73,10 @@ namespace Elixir::Aether * @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 overrides Named parameter overrides selected by the instance. + * @param parameters Resolved values in compiled parameter order. * * @pre system is not null. - * @pre overrides is not null. + * @pre parameters is not null. */ SystemInstanceSnapshot( SSystemInstanceKey key, @@ -83,7 +84,7 @@ namespace Elixir::Aether uint32_t parameterRevision, Ref system, const glm::mat4& worldTransform, - Ref overrides + Ref parameters ); /** @@ -125,7 +126,7 @@ namespace Elixir::Aether uint32_t m_ParameterRevision = 1; Ref m_CompiledSystem; glm::mat4 m_WorldTransform{ 1.0f }; - Ref m_ParameterOverrides; + Ref m_Parameters; Ref m_RenderProxy; }; @@ -202,6 +203,17 @@ namespace Elixir::Aether */ void ClearParameterOverrides(); + /** + * @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 Replaces the world transform for future frame submissions. * @param worldTransform Transform applied to this system instance. @@ -212,23 +224,37 @@ namespace Elixir::Aether // Captures the current immutable state without retaining the mutex. Ref CaptureSnapshot() const; - // Builds a snapshot and its renderer-facing proxy from resolved state. - static Ref CreateSnapshot( - const SSystemInstanceKey& key, - uint32_t revision, - uint32_t parameterRevision, - Ref system, - const glm::mat4& worldTransform, - Ref overrides + // 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 ); - // Checks whether a parameter can be changed through the runtime API. - static bool IsExposedParameter(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; + uint32_t m_Revision = 1; + uint32_t m_ParameterRevision = 1; + Ref m_CompiledSystem; + glm::mat4 m_WorldTransform{ 1.0f }; + ParameterOverridesMap m_ParameterOverrides; + Ref m_Snapshot; mutable std::mutex m_SnapshotMutex; }; diff --git a/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp b/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp index 59220abe..60284d71 100644 --- a/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp @@ -69,6 +69,35 @@ TEST(SystemInstanceTest, DoesNotIncrementRevisionForSameCompiledSystem) EXPECT_EQ(CaptureForTest(instance)->GetRevision(), 1); } +TEST(SystemInstanceTest, ReturnsOverrideOrCompiledDefaultForExposedParameter) +{ + const auto compiledSystem = MakeCompiledSystem(); + SystemInstance instance{ compiledSystem }; + + 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(SystemInstanceTest, AppliesOverridesOnlyToExposedParameters) { const auto compiledSystem = MakeCompiledSystem(); From adaa90be51004bbbe4860261139bba26e5542dd6 Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Tue, 11 Aug 2026 20:13:54 -0300 Subject: [PATCH 61/89] refactor(aether): centralize runtime instance management Add InstanceRegistry as the boundary for system compilation, caching, instance ownership, and frame publication. Keep compiled-system construction private and migrate Aether tests to create instances through the runtime registry. --- Dissolve/Source/Dissolve.cpp | 13 +- .../Engine/Aether/Effect/MaterialResolver.cpp | 4 +- .../Engine/Aether/Effect/MaterialResolver.h | 2 +- Elixir/Source/Engine/Aether/Emitter.cpp | 46 +++--- Elixir/Source/Engine/Aether/Emitter.h | 31 ++-- Elixir/Source/Engine/Aether/Manager.cpp | 75 +++------- Elixir/Source/Engine/Aether/Manager.h | 44 ++---- .../Aether/Runtime/InstanceRegistry.cpp | 136 ++++++++++++++++++ .../Engine/Aether/Runtime/InstanceRegistry.h | 123 ++++++++++++++++ Elixir/Source/Engine/Aether/System.h | 21 +-- .../Source/Engine/Aether/SystemInstance.cpp | 13 +- Elixir/Source/Engine/Aether/SystemInstance.h | 57 ++++---- .../FrameSubmissionPublisherTest.cpp | 40 ++++-- .../Aether/Rendering/FrameSubmissionTest.cpp | 58 +++++--- .../SystemInstanceRetirementQueueTest.cpp | 17 ++- .../Aether/Runtime/InstanceRegistryTest.cpp | 126 ++++++++++++++++ .../Engine/Aether/SystemInstanceTest.cpp | 127 ++++++++-------- Elixir/Tests/Engine/Aether/SystemTest.cpp | 80 +++++++---- .../Engine/Aether/TestInstanceRegistry.h | 27 ++++ 19 files changed, 721 insertions(+), 319 deletions(-) create mode 100644 Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp create mode 100644 Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.h create mode 100644 Elixir/Tests/Engine/Aether/Runtime/InstanceRegistryTest.cpp create mode 100644 Elixir/Tests/Engine/Aether/TestInstanceRegistry.h diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index b34e3919..d77318c6 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -218,13 +218,10 @@ Dissolve::Dissolve() } } - auto fireAndFireworks = GetAetherManager().Compile(*m_ParticleSystems[0]); - EE_CORE_ASSERT(fireAndFireworks, "Could not compile FireAndFireworks effect.") - m_ParticleSystemInstances[0] = GetAetherManager().CreateInstance(fireAndFireworks); - - auto ribbonVortex = GetAetherManager().Compile(*m_ParticleSystems[1]); - EE_CORE_ASSERT(ribbonVortex, "Could not compile RibbonVortex effect.") - m_ParticleSystemInstances[1] = GetAetherManager().CreateInstance(ribbonVortex); + m_ParticleSystemInstances[0] = GetAetherManager().CreateInstance(m_ParticleSystems[0]); + m_ParticleSystemInstances[1] = GetAetherManager().CreateInstance(m_ParticleSystems[1]); + EE_CORE_ASSERT(m_ParticleSystemInstances[0], "Could not create FireAndFireworks instance.") + EE_CORE_ASSERT(m_ParticleSystemInstances[1], "Could not create RibbonVortex instance.") m_GraphicsContext->SetClearColor({ 0.015f, 0.025f, 0.06f, 1.0f }); } @@ -323,4 +320,4 @@ Application* Elixir::CreateApplication() { EE_PROFILE_ZONE_SCOPED() return new Dissolve(); -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/Aether/Effect/MaterialResolver.cpp b/Elixir/Source/Engine/Aether/Effect/MaterialResolver.cpp index 7ec11255..9c0125f3 100644 --- a/Elixir/Source/Engine/Aether/Effect/MaterialResolver.cpp +++ b/Elixir/Source/Engine/Aether/Effect/MaterialResolver.cpp @@ -14,8 +14,8 @@ namespace Elixir::Aether::Effect { for (const auto& emitter : system.GetEmitters()) { - // A caller may replace an effect-authored instance before - // Manager::Compile(). Do not overwrite that explicit choice. + // A caller may replace an effect-authored instance before creating a + // SystemInstance. Do not overwrite that explicit choice. if (emitter->GetMaterial()) continue; diff --git a/Elixir/Source/Engine/Aether/Effect/MaterialResolver.h b/Elixir/Source/Engine/Aether/Effect/MaterialResolver.h index 8e1315a0..65a4a3de 100644 --- a/Elixir/Source/Engine/Aether/Effect/MaterialResolver.h +++ b/Elixir/Source/Engine/Aether/Effect/MaterialResolver.h @@ -13,7 +13,7 @@ namespace Elixir::Aether::Effect * values, retrieves default materials when no description is present, and * assigns an instance to each unresolved emitter. * - * The resolves does not compile materials or create GPU render proxies. + * 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. diff --git a/Elixir/Source/Engine/Aether/Emitter.cpp b/Elixir/Source/Engine/Aether/Emitter.cpp index 0c6ab96e..9b7cb13c 100644 --- a/Elixir/Source/Engine/Aether/Emitter.cpp +++ b/Elixir/Source/Engine/Aether/Emitter.cpp @@ -11,6 +11,29 @@ 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; + m_BurstIntervalSeconds = intervalSeconds; + } + + void Emitter::SetTriggerEmitter(std::string emitterName, const float delaySeconds) + { + m_TriggerEmitterName = std::move(emitterName); + m_TriggerDelaySeconds = delaySeconds; + } + SCompiledEmitter Emitter::Compile( const ParameterStore& paramStore, const std::vector& params, @@ -390,27 +413,4 @@ namespace Elixir::Aether return emitter; } - - 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; - m_BurstIntervalSeconds = intervalSeconds; - } - - void Emitter::SetTriggerEmitter(std::string emitterName, const float delaySeconds) - { - m_TriggerEmitterName = std::move(emitterName); - m_TriggerDelaySeconds = delaySeconds; - } } diff --git a/Elixir/Source/Engine/Aether/Emitter.h b/Elixir/Source/Engine/Aether/Emitter.h index a3c8e5aa..49990029 100644 --- a/Elixir/Source/Engine/Aether/Emitter.h +++ b/Elixir/Source/Engine/Aether/Emitter.h @@ -88,6 +88,8 @@ namespace Elixir::Aether */ class ELIXIR_API Emitter final { + friend class System; + public: /** * @brief Creates an emitter with a default spawn rate. @@ -142,27 +144,6 @@ namespace Elixir::Aether return ref; } - /** - * @brief Compiles this emitter into GPU-ready data. - * - * The method resolves parameter bindings, converts modules into GPU - * operations, resolves the selected material, and records operation ranges. - * - * @param paramStore Parent system parameter store. - * @param params Compiled parameter table for the parent system. - * @param ops Output operation stream to append to. - * @param materialResolver Resolves the selected material instance. - * @return Immutable compiled data for this emitter. - * - * @warning The selected material must support the current render mode. - */ - SCompiledEmitter Compile( - const ParameterStore& paramStore, - const std::vector& params, - std::vector& ops, - MaterialResolver& materialResolver - ) const; - /** * @brief Returns the stable identity of this emitter. * @return The UUID of this emitter. @@ -337,6 +318,14 @@ namespace Elixir::Aether 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, + MaterialResolver& materialResolver + ) const; + UUID m_Id; std::string m_Name; EParticleRenderMode m_RenderMode = EParticleRenderMode::Sprite; diff --git a/Elixir/Source/Engine/Aether/Manager.cpp b/Elixir/Source/Engine/Aether/Manager.cpp index dbcfb4d3..4fca4035 100644 --- a/Elixir/Source/Engine/Aether/Manager.cpp +++ b/Elixir/Source/Engine/Aether/Manager.cpp @@ -1,23 +1,24 @@ #include "epch.h" #include "Manager.h" -#include #include #include #include #include -using namespace Elixir::Aether::Effect; - namespace Elixir::Aether { + using namespace Effect; + Manager::Manager( const GraphicsContext* context, const ShaderLoader* shaderLoader, MaterialRegistry& materialRegistry, MaterialSystem& materialSystem - ) : m_EffectMaterials(materialRegistry), - m_MaterialSystem(materialSystem), + ) : m_Runtime(CreateScope( + materialRegistry, + materialSystem + )), m_Simulator(CreateScope(context, shaderLoader)), m_Renderer(CreateScope(context, materialSystem)), m_GraphicsContext(context) {} @@ -29,42 +30,22 @@ namespace Elixir::Aether return LoadEffectFile(filepath); } - Ref Manager::Compile(System& system) const + Ref Manager::CreateInstance(const Ref& system) { - if (!m_EffectMaterials.Resolve(system)) - { - EE_CORE_ERROR("Could not resolve materials for Aether system '{}'.", system.GetName()) - return nullptr; - } - - return CreateRef(system.Compile(m_MaterialSystem)); + return GetRuntime().CreateInstance(system); } - Ref Manager::CreateInstance(Ref system) + bool Manager::Recompile(const Ref& system) { - EE_CORE_ASSERT(system, "Aether system instance requires a compiled system.") - - auto instance = CreateRef(std::move(system)); - const auto key = instance->GetKey(); - const std::scoped_lock lock(m_InstancesMutex); - - const auto [_, inserted] = m_Instances.emplace(key, instance); - EE_CORE_ASSERT(inserted, "Aether system instance UUID must be unique.") - - return instance; + return GetRuntime().Recompile(system); } bool Manager::DestroyInstance(const Ref& instance) { - const std::scoped_lock lock(m_InstancesMutex); - if (!IsManagedInstance(instance)) return false; - - const auto found = m_Instances.find(instance->GetKey()); - if (found == m_Instances.end()) return false; + const auto detached = GetRuntime().DetachInstance(instance); + if (!detached) return false; - m_FrameSubmissionPublisher.Remove(*instance); - m_PendingRetirements.Enqueue(found->second); - m_Instances.erase(found); + m_PendingRetirements.Enqueue(detached); return true; } @@ -85,29 +66,18 @@ namespace Elixir::Aether const Ref& instance ) const { - const std::scoped_lock lock(m_InstancesMutex); - return IsManagedInstance(instance) && submission.Submit(*instance); + return GetRuntime().Submit(submission, instance); } void Manager::PublishFrameSubmission(Ref submission) { EE_CORE_ASSERT(submission, "Aether frame submission cannot be null.") - - // Keep the registry lock until publishing is complete. DestroyInstance() - // takes the same lock before removing a published frame. - const std::lock_guard lock(m_InstancesMutex); - m_FrameSubmissionPublisher.Publish( - std::move(submission), - [this](const SSystemInstanceKey& key) - { - return m_Instances.contains(key); - } - ); + GetRuntime().Publish(std::move(submission)); } void Manager::Render(const Camera& camera) { - const auto submission = m_FrameSubmissionPublisher.Acquire(); + const auto submission = GetRuntime().AcquireSubmission(); if (!submission) return; const auto cmd = m_GraphicsContext->GetSecondaryCommandBuffer(); @@ -135,6 +105,12 @@ namespace Elixir::Aether 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.") @@ -154,11 +130,4 @@ namespace Elixir::Aether for (const auto& instance : instances) GetSimulator().Retire(instance->GetKey()); } - - bool Manager::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/Manager.h b/Elixir/Source/Engine/Aether/Manager.h index d73d67f3..a9d34f3e 100644 --- a/Elixir/Source/Engine/Aether/Manager.h +++ b/Elixir/Source/Engine/Aether/Manager.h @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -34,6 +35,7 @@ namespace Elixir namespace Elixir::Aether { + using namespace Runtime; using namespace Simulation; using namespace Rendering; @@ -103,33 +105,23 @@ namespace Elixir::Aether static Ref LoadEffect(const std::filesystem::path& filepath); /** - * @brief Resolves materials and compiles a system for runtime use. + * @brief Creates and registers an instance of a System asset. * - * The method resolves effect-authored materials definitions before compiling - * the system into an immutable SCompiledSystem. + * Runtime compiles and caches the asset without retaining the authored + * System. * - * @param system Mutable system authoring data to compile. - * @return The compiled system, or null when material resolution fails. - * - * @note This method assigns a default material when emitters have no - * explicit material. + * @param system System asset to instantiate. + * @return Registered instance, or null when compilation fails. */ - Ref Compile(System& system) const; + Ref CreateInstance(const Ref& system); /** - * @brief Creates and registers a runtime instance of a compiled system. - * - * The instance provides the runtime API for transforms and parameter - * overrides. Manager keeps the instance registered until DestroyInstance() - * removes it. Simulator creates its GPU allocation when it first processes - * the instance. - * - * @param system Immutable compiled system data. - * @return The registered runtime instance. + * @brief Recompiles a System and updates its registered instances. * - * @pre system is not null. + * @param system System asset to recompile. + * @return True when compilation succeeds. */ - Ref CreateInstance(Ref system); + bool Recompile(const Ref& system); /** * @brief Detaches a runtime instance from future frames. @@ -226,25 +218,17 @@ namespace Elixir::Aether 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(); - // Checks ownership while the instance registry mutex is already held. - bool IsManagedInstance(const Ref& instance) const; - - Effect::MaterialResolver m_EffectMaterials; - - MaterialSystem& m_MaterialSystem; - std::unordered_map> m_Instances; - mutable std::mutex m_InstancesMutex; - + Scope m_Runtime; Scope m_Simulator; SystemInstanceRetirementQueue m_PendingRetirements; - FrameSubmissionPublisher m_FrameSubmissionPublisher; Scope m_Renderer; const GraphicsContext* m_GraphicsContext = nullptr; diff --git a/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp new file mode 100644 index 00000000..4dc1fffe --- /dev/null +++ b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp @@ -0,0 +1,136 @@ +#include "epch.h" +#include "InstanceRegistry.h" + +#include +#include + +namespace Elixir::Aether::Runtime +{ + InstanceRegistry::InstanceRegistry( + MaterialRegistry& materialRegistry, + MaterialResolver& materialResolver + ) : m_EffectMaterials(materialRegistry), + m_MaterialResolver(materialResolver) {} + + Ref InstanceRegistry::CreateInstance(const Ref& system) + { + EE_CORE_ASSERT(system, "Aether requires a System asset.") + if (!system) return nullptr; + + const std::scoped_lock lock(m_Mutex); + + auto compiled = m_CompiledSystems.find(system->GetId()); + if (compiled == m_CompiledSystems.end()) + { + const auto result = CompileSystem(*system); + if (!result) return nullptr; + + compiled = m_CompiledSystems.emplace(system->GetId(), result).first; + } + + auto instance = Ref(new SystemInstance(compiled->second)); + const auto [_, inserted] = m_Instances.emplace(instance->GetKey(), instance); + + EE_CORE_ASSERT(inserted, "Aether system instance UUID must be unique.") + + return instance; + } + + 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; + } + + Ref InstanceRegistry::DetachInstance( + 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; + } + + bool InstanceRegistry::Submit( + FrameSubmission& submission, + const Ref& instance + ) + { + const std::scoped_lock lock(m_Mutex); + return IsManagedInstance(instance) && submission.Submit(*instance); + } + + void InstanceRegistry::Publish(Ref submission) + { + EE_CORE_ASSERT(submission, "Aether frame submission cannot be null.") + + const std::scoped_lock lock(m_Mutex); + m_Publisher.Publish( + std::move(submission), + [this](const SSystemInstanceKey& key) + { + return m_Instances.contains(key); + } + ); + } + + 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(m_MaterialResolver) + ); + } + + 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..2e341d4f --- /dev/null +++ b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.h @@ -0,0 +1,123 @@ +#pragma once + +#include +#include +#include + +namespace Elixir +{ + class MaterialRegistry; + class MaterialResolver; +} + +namespace Elixir::Aether::Runtime +{ + using Rendering::FrameSubmission; + using Rendering::FrameSubmissionPublisher; + + /** + * @brief Manages compiled Aether systems and their runtime instances. + * + * InstanceRegistry resolves effect materials, compiles System assets, caches + * immutable runtime data, and owns registered SystemInstance objects. It also + * publishes immutable frame submissions without depending on GPU services. + * + * 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 and MaterialResolver 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. + * @param materialResolver Compiles material instances into render proxies. + */ + InstanceRegistry( + MaterialRegistry& materialRegistry, + MaterialResolver& materialResolver + ); + + /** + * @brief Creates and registers an instance of a System asset. + * + * The method reuses an existing compiled representation when available. + * It does not retain the authored System. + * + * @param system System asset to instantiate. + * @return Registered instance, or null when compilation fails. + */ + Ref CreateInstance(const Ref& system); + + /** + * @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 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 DetachInstance(const Ref& instance); + + /** + * @brief Adds a registered instance to a frame submission. + * + * @param submission Submission to update. + * @param instance Instance to capture. + * @return True when the instance was captured. + */ + bool Submit(FrameSubmission& submission, const Ref& instance); + + /** + * @brief Publishes a frame submission containing managed instances. + * + * Instances detached before publication are removed from the published + * submission. + * + * @param submission Submission to publish. + */ + void Publish(Ref submission); + + /** + * @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; + MaterialResolver& m_MaterialResolver; + + 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/System.h b/Elixir/Source/Engine/Aether/System.h index d9cbb14e..fa504599 100644 --- a/Elixir/Source/Engine/Aether/System.h +++ b/Elixir/Source/Engine/Aether/System.h @@ -9,12 +9,14 @@ namespace Elixir { class MaterialResolver; + namespace Aether::Runtime { class InstanceRegistry; } } namespace Elixir::Aether { using namespace Core; using namespace Modules; + /** * @brief Maps an exposed runtime parameter to the compiled parameter table. * @@ -85,6 +87,8 @@ namespace Elixir::Aether */ class ELIXIR_API System final { + friend class Runtime::InstanceRegistry; + public: /** * @brief Creates an empty particle effect definition. @@ -119,20 +123,6 @@ namespace Elixir::Aether */ Emitter* FindEmitter(std::string_view name) const; - /** - * @brief Compiles the authored effect into immutable runtime data. - * - * The method compiles system and emitter parameters, bake curves into - * parameter chunks, compile emitter modules into GPU operations, resolves - * material render proxies, and builds trigger targets. - * - * @param materialResolver Resolves emitter material instances for rendering. - * @return Immutable data for a SystemInstance and the particle renderer. - * - * @warning Any later change to this System requires a new compilation. - */ - SCompiledSystem Compile(MaterialResolver& materialResolver) const; - /** * @brief Returns the UUID of this effect system. * @return The system UUID. @@ -176,6 +166,9 @@ namespace Elixir::Aether ColorCurveStore& GetColorCurves() { return m_ColorCurves; } private: + // Compiles the authored system into immutable runtime data. + SCompiledSystem Compile(MaterialResolver& materialResolver) 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 a826659d..dd192ae1 100644 --- a/Elixir/Source/Engine/Aether/SystemInstance.cpp +++ b/Elixir/Source/Engine/Aether/SystemInstance.cpp @@ -32,17 +32,26 @@ namespace Elixir::Aether /* SystemInstance */ SystemInstance::SystemInstance(Ref system) - : m_CompiledSystem(system) + : m_SourceSystemId(system->SourceId), + m_CompiledSystem(std::move(system)) { EE_CORE_ASSERT(m_CompiledSystem, "SystemInstance requires a compiled system.") PublishSnapshot(); } - void SystemInstance::SetCompiledSystem(Ref system) + void SystemInstance::ApplyCompilation(Ref system) { 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; + if (m_CompiledSystem == system) return; diff --git a/Elixir/Source/Engine/Aether/SystemInstance.h b/Elixir/Source/Engine/Aether/SystemInstance.h index dcb64863..f83d42fa 100644 --- a/Elixir/Source/Engine/Aether/SystemInstance.h +++ b/Elixir/Source/Engine/Aether/SystemInstance.h @@ -2,12 +2,16 @@ #include -namespace Elixir::Aether::Rendering +namespace Elixir::Aether { - class FrameSubmission; - class FrameSubmissionPublisher; - class Renderer; - class SystemInstanceRenderProxy; + namespace Runtime { class InstanceRegistry; } + namespace Rendering + { + class FrameSubmission; + class FrameSubmissionPublisher; + class Renderer; + class SystemInstanceRenderProxy; + } } namespace Elixir::Aether @@ -59,10 +63,10 @@ namespace Elixir::Aether */ class ELIXIR_API SystemInstanceSnapshot final { - friend class SystemInstance; - friend class Rendering::FrameSubmission; friend class Manager; + friend class SystemInstance; friend class Rendering::Renderer; + friend class Rendering::FrameSubmission; public: /** @@ -146,38 +150,18 @@ namespace Elixir::Aether */ class ELIXIR_API SystemInstance final { - friend class Rendering::FrameSubmission; - friend class Rendering::FrameSubmissionPublisher; friend class Manager; + friend class Runtime::InstanceRegistry; friend class Rendering::Renderer; + friend class Rendering::FrameSubmission; + friend class Rendering::FrameSubmissionPublisher; public: - /** - * @brief Creates a runtime instance for a compiled system. - * - * @param system Immutable compiled system to select initially. - * - * @pre system is not null. - */ - explicit SystemInstance(Ref system); - SystemInstance(const SystemInstance&) = delete; SystemInstance& operator=(const SystemInstance&) = delete; SystemInstance(SystemInstance&&) = delete; SystemInstance& operator=(SystemInstance&&) = delete; - /** - * @brief Replaces the selected compiled system. - * - * Compatible parameter overrides are preserved. Overrides that do not - * exist in the replacement system are removed. - * - * @param system Replacement compiled system. - * - * @pre system is not null. - */ - void SetCompiledSystem(Ref system); - /** * @brief Sets an override for an exposed compiled-system parameter. * @@ -214,6 +198,12 @@ namespace Elixir::Aether */ 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. @@ -221,6 +211,12 @@ namespace Elixir::Aether void SetWorldTransform(const glm::mat4& worldTransform); private: + // Creates an instance from data compiled internally by Aether. + explicit SystemInstance(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; @@ -249,6 +245,7 @@ namespace Elixir::Aether 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_CompiledSystem; diff --git a/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionPublisherTest.cpp b/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionPublisherTest.cpp index 08878d6b..1b4da3e7 100644 --- a/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionPublisherTest.cpp +++ b/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionPublisherTest.cpp @@ -5,48 +5,56 @@ #include +#include "../TestInstanceRegistry.h" + using namespace Elixir; using namespace Elixir::Aether; using namespace Elixir::Aether::Rendering; TEST(FrameSubmissionPublisherTest, PublishesOnlySealedSubmissions) { - const auto compiledSystem = CreateRef(); - const SystemInstance instance{ compiledSystem }; + TestInstanceRegistry runtime; + const auto instance = runtime.CreateInstance("Sealed submission system"); + ASSERT_TRUE(instance); + const auto submission = CreateRef(); FrameSubmissionPublisher publisher; - ASSERT_TRUE(submission->Submit(instance)); + 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)); + EXPECT_FALSE(submission->Submit(*instance)); } TEST(FrameSubmissionPublisherTest, RemovesDestroyedInstanceFromPublishedFrame) { - const auto compiledSystem = CreateRef(); - const SystemInstance instance{ compiledSystem }; + TestInstanceRegistry runtime; + const auto instance = runtime.CreateInstance("Removed published system"); + ASSERT_TRUE(instance); + const auto submission = CreateRef(); FrameSubmissionPublisher publisher; - ASSERT_TRUE(submission->Submit(instance)); + ASSERT_TRUE(submission->Submit(*instance)); publisher.Publish(submission); - publisher.Remove(instance); + publisher.Remove(*instance); EXPECT_TRUE(publisher.Acquire()->IsEmpty()); } TEST(FrameSubmissionPublisherTest, FiltersInstanceRejectedAtPublication) { - const auto system = CreateRef(); - const SystemInstance instance{ system }; + TestInstanceRegistry runtime; + const auto instance = runtime.CreateInstance("Filtered published system"); + ASSERT_TRUE(instance); + const auto submission = CreateRef(); FrameSubmissionPublisher publisher; - ASSERT_TRUE(submission->Submit(instance)); + ASSERT_TRUE(submission->Submit(*instance)); publisher.Publish(submission, [](const SSystemInstanceKey&) { return false; @@ -57,16 +65,18 @@ TEST(FrameSubmissionPublisherTest, FiltersInstanceRejectedAtPublication) TEST(FrameSubmissionPublisherTest, PublishesAndAcquiresSealedFramesConcurrently) { - const auto system = CreateRef(); - const SystemInstance instance{ system }; + TestInstanceRegistry runtime; + const auto instance = runtime.CreateInstance("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)); + ASSERT_TRUE(firstSubmission->Submit(*instance)); + ASSERT_TRUE(replacementSubmission->Submit(*instance)); std::thread publicationThread([&] { diff --git a/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionTest.cpp b/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionTest.cpp index d01af50b..d27ab3ca 100644 --- a/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionTest.cpp +++ b/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionTest.cpp @@ -5,6 +5,8 @@ #include +#include "../TestInstanceRegistry.h" + using namespace Elixir; using namespace Elixir::Aether; using namespace Elixir::Aether::Rendering; @@ -34,15 +36,19 @@ static_assert( TEST(FrameSubmissionTest, RetainsEachSystemInstanceAtMostOnce) { - const auto compiledSystem = CreateRef(); - const SystemInstance firstInstance{ compiledSystem }; - const SystemInstance secondInstance{ compiledSystem }; + TestInstanceRegistry runtime; + const auto system = CreateRef("Frame submission system"); + const auto firstInstance = runtime.Registry.CreateInstance(system); + const auto secondInstance = runtime.Registry.CreateInstance(system); + + ASSERT_TRUE(firstInstance); + ASSERT_TRUE(secondInstance); FrameSubmission submission; - EXPECT_TRUE(submission.Submit(firstInstance)); - EXPECT_FALSE(submission.Submit(firstInstance)); - EXPECT_TRUE(submission.Submit(secondInstance)); + 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]); @@ -50,46 +56,54 @@ TEST(FrameSubmissionTest, RetainsEachSystemInstanceAtMostOnce) TEST(FrameSubmissionTest, ResetKeepsTheSubmissionReusable) { - const auto compiledSystem = CreateRef(); - const SystemInstance firstInstance{ compiledSystem }; - const SystemInstance secondInstance{ compiledSystem }; + TestInstanceRegistry runtime; + const auto system = CreateRef("Reusable submission system"); + const auto firstInstance = runtime.Registry.CreateInstance(system); + const auto secondInstance = runtime.Registry.CreateInstance(system); + + ASSERT_TRUE(firstInstance); + ASSERT_TRUE(secondInstance); FrameSubmission submission; - ASSERT_TRUE(submission.Submit(firstInstance)); + 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_TRUE(submission.Submit(*firstInstance)); + EXPECT_TRUE(submission.Submit(*secondInstance)); EXPECT_EQ(submission.GetInstanceCount(), 2); } TEST(FrameSubmissionTest, RemovesAnInstanceBeforeItIsRetired) { - const auto compiledSystem = CreateRef(); - const SystemInstance instance{ compiledSystem }; + TestInstanceRegistry runtime; + const auto instance = runtime.CreateInstance("Removed submission system"); + ASSERT_TRUE(instance); + FrameSubmission submission; - ASSERT_TRUE(submission.Submit(instance)); - EXPECT_TRUE(submission.Remove(instance)); + 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)); + EXPECT_FALSE(submission.Remove(*instance)); + EXPECT_TRUE(submission.Submit(*instance)); } TEST(FrameSubmissionTest, RetainsTheStateCapturedAtSubmission) { - const auto compiledSystem = CreateRef(); - SystemInstance instance{ compiledSystem }; + TestInstanceRegistry runtime; + const auto instance = runtime.CreateInstance("Captured submission system"); + ASSERT_TRUE(instance); + FrameSubmission submission; - ASSERT_TRUE(submission.Submit(instance)); + ASSERT_TRUE(submission.Submit(*instance)); glm::mat4 transform{ 1.0f }; transform[3] = { 3.0f, 2.0f, 1.0f, 1.0f }; - instance.SetWorldTransform(transform); + instance->SetWorldTransform(transform); const auto& proxy = submission.GetRenderProxies().front(); EXPECT_FLOAT_EQ(proxy->GetWorldTransform()[3].x, 0.0f); diff --git a/Elixir/Tests/Engine/Aether/Rendering/SystemInstanceRetirementQueueTest.cpp b/Elixir/Tests/Engine/Aether/Rendering/SystemInstanceRetirementQueueTest.cpp index 82112314..4686c549 100644 --- a/Elixir/Tests/Engine/Aether/Rendering/SystemInstanceRetirementQueueTest.cpp +++ b/Elixir/Tests/Engine/Aether/Rendering/SystemInstanceRetirementQueueTest.cpp @@ -7,15 +7,21 @@ #include +#include "../TestInstanceRegistry.h" + using namespace Elixir; using namespace Elixir::Aether; using namespace Elixir::Aether::Rendering; TEST(SystemInstanceRetirementQueueTest, TransfersPendingInstancesExactlyOnce) { - const auto system = CreateRef(); - const auto first = CreateRef(system); - const auto second = CreateRef(system); + TestInstanceRegistry runtime; + const auto system = CreateRef("Retirement queue system"); + const auto first = runtime.Registry.CreateInstance(system); + const auto second = runtime.Registry.CreateInstance(system); + + ASSERT_TRUE(first); + ASSERT_TRUE(second); SystemInstanceRetirementQueue queue; queue.Enqueue(first); @@ -32,12 +38,13 @@ TEST(SystemInstanceRetirementQueueTest, TransfersPendingInstancesExactlyOnce) TEST(SystemInstanceRetirementQueueTest, DrainsDestroyRequestsEnqueuedDuringUpdate) { constexpr uint32_t destroyRequestCount = 256; - const auto system = CreateRef(); + 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(CreateRef(system)); + instances.push_back(runtime.Registry.CreateInstance(system)); SystemInstanceRetirementQueue queue; std::barrier beginConcurrentAccess{ 2 }; diff --git a/Elixir/Tests/Engine/Aether/Runtime/InstanceRegistryTest.cpp b/Elixir/Tests/Engine/Aether/Runtime/InstanceRegistryTest.cpp new file mode 100644 index 00000000..03ccde0a --- /dev/null +++ b/Elixir/Tests/Engine/Aether/Runtime/InstanceRegistryTest.cpp @@ -0,0 +1,126 @@ +#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( + InstanceRegistry& registry, + const Ref& instance + ) + { + FrameSubmission submission; + EXPECT_TRUE(registry.Submit(submission, instance)); + + if (submission.IsEmpty()) + return nullptr; + + return submission.GetRenderProxies().front(); + } +} + +TEST(InstanceRegistryTest, CreatesAnInstanceWithoutRetainingItsSystem) +{ + TestInstanceRegistry runtime; + std::weak_ptr authoredSystem; + Ref instance; + + { + const auto system = CreateRef("Transient authored system"); + system->GetParameters().SetFloat4("Tint", { 1.0f, 0.5f, 0.25f, 1.0f }); + authoredSystem = system; + instance = runtime.Registry.CreateInstance(system); + + ASSERT_TRUE(instance); + EXPECT_EQ(instance->GetSourceSystemId(), system->GetId()); + } + + 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 = runtime.Registry.CreateInstance(system); + const auto second = runtime.Registry.CreateInstance(system); + + ASSERT_TRUE(first); + ASSERT_TRUE(second); + + const auto firstProxy = Capture(runtime.Registry, first); + const auto secondProxy = Capture(runtime.Registry, 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 = runtime.Registry.CreateInstance(system); + ASSERT_TRUE(instance); + ASSERT_TRUE(instance->SetParameterOverride( + "Tint", + { 0.25f, 0.5f, 0.75f, 1.0f } + )); + + const auto before = Capture(runtime.Registry, 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(runtime.Registry, 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.CreateInstance("Detached system"); + const auto submission = CreateRef(); + + ASSERT_TRUE(instance); + ASSERT_TRUE(runtime.Registry.Submit(*submission, instance)); + runtime.Registry.Publish(submission); + + ASSERT_EQ(runtime.Registry.DetachInstance(instance), instance); + + const auto published = runtime.Registry.AcquireSubmission(); + ASSERT_TRUE(published); + EXPECT_TRUE(published->IsEmpty()); + + FrameSubmission replacement; + EXPECT_FALSE(runtime.Registry.Submit(replacement, instance)); + EXPECT_FALSE(runtime.Registry.DetachInstance(instance)); +} diff --git a/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp b/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp index 60284d71..e517c306 100644 --- a/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp @@ -1,11 +1,14 @@ #include #include +#include #include #include #include +#include "TestInstanceRegistry.h" + using namespace Elixir; using namespace Elixir::Aether; using namespace Elixir::Aether::Rendering; @@ -18,63 +21,59 @@ concept HasPublicSnapshotCapture = requires(const T& instance) static_assert(!HasPublicSnapshotCapture); +static_assert( + !std::constructible_from< + SystemInstance, + Ref + > +); + namespace { - Ref CaptureForTest(const SystemInstance& instance) + Ref CaptureForTest( + const Ref& instance + ) { FrameSubmission submission; - EXPECT_TRUE(submission.Submit(instance)); + EXPECT_TRUE(submission.Submit(*instance)); return submission.GetRenderProxies().front(); } - Ref MakeCompiledSystem() + Ref MakeSystem() { - 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 } }, - }; - - system->ExposedParameters = { - { "Tint", 0u }, - }; + 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(SystemInstanceTest, 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.Registry.CreateInstance(system); + ASSERT_TRUE(instance); 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)); const auto snapshot = CaptureForTest(instance); EXPECT_EQ(snapshot->GetRevision(), initialRevision + 1); - EXPECT_EQ(&snapshot->GetCompiledSystem(), replacementSystem.get()); -} - -TEST(SystemInstanceTest, DoesNotIncrementRevisionForSameCompiledSystem) -{ - const auto compiledSystem = CreateRef(); - SystemInstance instance{ compiledSystem }; - instance.SetCompiledSystem(compiledSystem); - - EXPECT_EQ(CaptureForTest(instance)->GetRevision(), 1); + EXPECT_EQ(snapshot->GetCompiledSystem().CompilationRevision, 2u); } TEST(SystemInstanceTest, ReturnsOverrideOrCompiledDefaultForExposedParameter) { - const auto compiledSystem = MakeCompiledSystem(); - SystemInstance instance{ compiledSystem }; + TestInstanceRegistry runtime; + const auto instance = runtime.Registry.CreateInstance(MakeSystem()); + ASSERT_TRUE(instance); - const auto defaultValue = instance.GetParameterValue("Tint"); + const auto defaultValue = instance->GetParameterValue("Tint"); ASSERT_TRUE(defaultValue.has_value()); EXPECT_FLOAT_EQ(defaultValue->x, 1.0f); @@ -82,31 +81,32 @@ TEST(SystemInstanceTest, ReturnsOverrideOrCompiledDefaultForExposedParameter) EXPECT_FLOAT_EQ(defaultValue->z, 1.0f); EXPECT_FLOAT_EQ(defaultValue->w, 1.0f); - ASSERT_TRUE(instance.SetParameterOverride( + ASSERT_TRUE(instance->SetParameterOverride( "Tint", { 0.25f, 0.5f, 0.75f, 1.0f } )); - const auto overrideValue = instance.GetParameterValue("Tint"); + 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()); + EXPECT_FALSE(instance->GetParameterValue("SizeOverLife:0").has_value()); + EXPECT_FALSE(instance->GetParameterValue("Missing").has_value()); } TEST(SystemInstanceTest, AppliesOverridesOnlyToExposedParameters) { - const auto compiledSystem = MakeCompiledSystem(); - SystemInstance instance{ compiledSystem }; + TestInstanceRegistry runtime; + const auto instance = runtime.Registry.CreateInstance(MakeSystem()); + ASSERT_TRUE(instance); const auto initialParameterRevision = CaptureForTest(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 })); const auto snapshot = CaptureForTest(instance); EXPECT_EQ(snapshot->GetParameterRevision(), initialParameterRevision + 1); @@ -126,12 +126,13 @@ TEST(SystemInstanceTest, AppliesOverridesOnlyToExposedParameters) TEST(SystemInstanceTest, ClearsOverridesAndRestoresCompiledDefaults) { - const auto compiledSystem = MakeCompiledSystem(); - SystemInstance instance{ compiledSystem }; + TestInstanceRegistry runtime; + const auto instance = runtime.Registry.CreateInstance(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 = CaptureForTest(instance)->GetParameterValue(0); EXPECT_FLOAT_EQ(tint.x, 1.0f); @@ -140,22 +141,17 @@ TEST(SystemInstanceTest, ClearsOverridesAndRestoresCompiledDefaults) EXPECT_FLOAT_EQ(tint.w, 1.0f); } -TEST(SystemInstanceTest, 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.Registry.CreateInstance(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 = CaptureForTest(instance)->GetParameterValue(0); EXPECT_FLOAT_EQ(tint.x, 0.25f); @@ -166,26 +162,29 @@ TEST(SystemInstanceTest, RetainsOnlyOverridesExposedByReplacementSystem) TEST(SystemInstanceTest, StoresWorldTransformWithoutChangingCompiledSystem) { - const auto compiledSystem = MakeCompiledSystem(); - SystemInstance instance{ compiledSystem }; + TestInstanceRegistry runtime; + const auto instance = runtime.Registry.CreateInstance(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); 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.get()); + EXPECT_EQ(&snapshot->GetCompiledSystem(), compiledSystem); } TEST(SystemInstanceTest, KeepsCapturedProxyImmutableDuringConcurrentOverrides) { - const auto system = MakeCompiledSystem(); - SystemInstance instance{ system }; + TestInstanceRegistry runtime; + const auto instance = runtime.Registry.CreateInstance(MakeSystem()); + ASSERT_TRUE(instance); const auto capturedBeforeOverrides = CaptureForTest(instance); std::barrier beginUpdates{ 2 }; @@ -194,7 +193,7 @@ TEST(SystemInstanceTest, KeepsCapturedProxyImmutableDuringConcurrentOverrides) beginUpdates.arrive_and_wait(); for (uint32_t value = 2; value <= 64; ++value) - instance.SetParameterOverride("Tint", { (float)value, 0.0f, 0.0f, 1.0f }); + instance->SetParameterOverride("Tint", { (float)value, 0.0f, 0.0f, 1.0f }); }); beginUpdates.arrive_and_wait(); diff --git a/Elixir/Tests/Engine/Aether/SystemTest.cpp b/Elixir/Tests/Engine/Aether/SystemTest.cpp index cedbfc85..183f8ae5 100644 --- a/Elixir/Tests/Engine/Aether/SystemTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemTest.cpp @@ -3,29 +3,48 @@ #include #include #include -#include #include -#include "TestMaterialResolver.h" +#include "TestInstanceRegistry.h" using namespace Elixir; using namespace Elixir::Aether; using namespace Elixir::Aether::Core; +template +concept HasPublicCompile = requires(const T& system, MaterialResolver& resolver) +{ + system.Compile(resolver); +}; + +static_assert(!HasPublicCompile); + namespace { - SCompiledSystem Compile(const System& system) + SCompiledSystem Compile(const Ref& system) { - TestMaterialResolver resolver; - return system.Compile(resolver); + TestInstanceRegistry runtime; + const auto instance = runtime.Registry.CreateInstance(system); + EXPECT_TRUE(instance); + + if (!instance) + return {}; + + Rendering::FrameSubmission submission; + EXPECT_TRUE(runtime.Registry.Submit(submission, instance)); + + if (submission.IsEmpty()) + return {}; + + return submission.GetRenderProxies().front()->GetCompiledSystem(); } } TEST(SystemTest, CompilePreservesEmitterSimulationSpace) { - System system{ "Simulation space contract" }; - system.AddEmitter("World", 8, 0.0f); // world emitter - auto& localEmitter = system.AddEmitter("Local", 8, 0.0f); + 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 = Compile(system); @@ -37,10 +56,10 @@ TEST(SystemTest, CompilePreservesEmitterSimulationSpace) 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 = Compile(system); @@ -54,10 +73,10 @@ TEST(SystemTest, CompileAssignsContiguousLocalEmitterParticleOffsets) 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); @@ -77,11 +96,11 @@ TEST(SystemTest, CompileResolvesTriggerEmitterByCompiledIndex) 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 = Compile(system); @@ -121,8 +140,8 @@ TEST(SystemTest, CompileSnapshotsParticleSpriteMaterialForRenderData) const auto instance = material->CreateInstance(); ASSERT_TRUE(instance->SetVector("Tint", { 0.25f, 0.5f, 0.75f, 1.0f })); - System system{ "Material snapshot contract" }; - auto& emitter = system.AddEmitter("Smoke", 8, 0.0f); + const auto system = CreateRef("Material snapshot contract"); + auto& emitter = system->AddEmitter("Smoke", 8, 0.0f); emitter.SetMaterial(instance); const auto first = Compile(system); @@ -150,8 +169,8 @@ TEST(SystemTest, CompileSnapshotsParticleRibbonMaterialForRenderData) const auto material = CreateRef("Particle ribbon"); ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleRibbon, true)); - System system{ "Ribbon material snapshot contract" }; - auto& emitter = system.AddEmitter("Ribbon", 8, 0.0f); + 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(); @@ -171,8 +190,8 @@ TEST(SystemTest, CompileSnapshotsParticleMeshMaterialForRenderData) const auto material = CreateRef("Particle mesh"); material->SetUsage(EMaterialUsage::ParticleMesh, true); - System system{ "Mesh material" }; - auto& emitter = system.AddEmitter("Mesh", 8, 0.0f); + const auto system = CreateRef("Mesh material"); + auto& emitter = system->AddEmitter("Mesh", 8, 0.0f); emitter.SetRenderMode(EParticleRenderMode::Mesh); const auto instance = material->CreateInstance(); @@ -187,13 +206,16 @@ TEST(SystemTest, CompileSnapshotsParticleMeshMaterialForRenderData) )); } -TEST(SystemTest, KeepsAnEmitterWithoutAnExplicitMaterialUnbound) +TEST(SystemTest, CompileAssignsTheDefaultMaterialWhenNoneIsExplicit) { - System system{ "Explicit material contract" }; - system.AddEmitter("Smoke", 8, 0.0f); + const auto system = CreateRef("Default material contract"); + system->AddEmitter("Smoke", 8, 0.0f); const auto compiled = Compile(system); ASSERT_EQ(compiled.Emitters.size(), 1); - EXPECT_FALSE(compiled.Emitters[0].Material); + ASSERT_TRUE(compiled.Emitters[0].Material); + EXPECT_TRUE(compiled.Emitters[0].Material->GetCompiledMaterial()->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..1f9b8fb4 --- /dev/null +++ b/Elixir/Tests/Engine/Aether/TestInstanceRegistry.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +#include "TestMaterialResolver.h" + +using namespace Elixir; +using namespace Elixir::Aether; + +class TestInstanceRegistry final +{ +private: + MaterialRegistry m_MaterialRegistry; + TestMaterialResolver m_MaterialResolver; + +public: + TestInstanceRegistry() + : Registry(m_MaterialRegistry, m_MaterialResolver) {} + + Ref CreateInstance(std::string name = "Test system") + { + return Registry.CreateInstance(CreateRef(std::move(name))); + } + + Runtime::InstanceRegistry Registry; +}; From 3040daac7597d8c55204469ff63be2cb33cd24bd Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Tue, 11 Aug 2026 20:30:40 -0300 Subject: [PATCH 62/89] fix(vulkan): stabilize gpu test lifecycle Share one graphics context across Vulkan test suites, use upload command buffers for transfer operations, and make descriptor pool destruction idempotent. --- .../Graphics/Vulkan/VulkanDescriptorPool.cpp | 9 ++++- .../Graphics/Vulkan/VulkanDescriptorPool.h | 4 +- .../Graphics/Vulkan/VulkanBufferTest.cpp | 39 ++++++++----------- .../Tests/Graphics/Vulkan/VulkanImageTest.cpp | 37 +++++++----------- .../Tests/Graphics/Vulkan/VulkanTestContext.h | 35 +++++++++++++++++ 5 files changed, 75 insertions(+), 49 deletions(-) create mode 100644 Elixir/Tests/Graphics/Vulkan/VulkanTestContext.h 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/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; +}; From 6fb2aa4bc4678ab2d52d9fc9b396a13873eec1ee Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Wed, 12 Aug 2026 01:31:14 -0300 Subject: [PATCH 63/89] refactor(aether): register persistent system instances Move instance creation to System and let Manager add each instance once for persistent simulation and rendering. Compile on first registration, preserve immutable frame snapshots, and update lifecycle and concurrency coverage. --- Dissolve/Source/Dissolve.cpp | 23 +-- Elixir/Source/Engine/Aether/Manager.cpp | 33 +--- Elixir/Source/Engine/Aether/Manager.h | 92 +++------ .../Engine/Aether/Rendering/FrameSubmission.h | 9 +- .../Aether/Runtime/InstanceRegistry.cpp | 109 +++++++---- .../Engine/Aether/Runtime/InstanceRegistry.h | 54 +++--- Elixir/Source/Engine/Aether/System.cpp | 40 +++- Elixir/Source/Engine/Aether/System.h | 24 ++- .../Source/Engine/Aether/SystemInstance.cpp | 177 ++++++++++++++---- Elixir/Source/Engine/Aether/SystemInstance.h | 31 ++- .../FrameSubmissionPublisherTest.cpp | 8 +- .../Aether/Rendering/FrameSubmissionTest.cpp | 12 +- .../SystemInstanceRetirementQueueTest.cpp | 6 +- .../Aether/Runtime/InstanceRegistryTest.cpp | 111 +++++++++-- .../Engine/Aether/SystemInstanceTest.cpp | 14 +- Elixir/Tests/Engine/Aether/SystemTest.cpp | 6 +- .../Engine/Aether/TestInstanceRegistry.h | 10 +- 17 files changed, 491 insertions(+), 268 deletions(-) diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index d77318c6..a0ebcbf9 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -218,11 +218,19 @@ Dissolve::Dissolve() } } - m_ParticleSystemInstances[0] = GetAetherManager().CreateInstance(m_ParticleSystems[0]); - m_ParticleSystemInstances[1] = GetAetherManager().CreateInstance(m_ParticleSystems[1]); + 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 }); } @@ -241,17 +249,6 @@ void Dissolve::Prepare(const Timestep frameTime) { EE_PROFILE_ZONE_SCOPED() Application::Prepare(frameTime); - - auto& aether = GetAetherManager(); - const auto submission = aether.CreateFrameSubmission(); - - bool submitted = aether.Submit(*submission, m_ParticleSystemInstances[0]); - EE_CORE_ASSERT(submitted, "The particle system instance was submitted more than once.") - - submitted = aether.Submit(*submission, m_ParticleSystemInstances[1]); - EE_CORE_ASSERT(submitted, "The particle system instance was submitted more than once.") - - aether.PublishFrameSubmission(submission); } void Dissolve::Render(const Timestep frameTime) diff --git a/Elixir/Source/Engine/Aether/Manager.cpp b/Elixir/Source/Engine/Aether/Manager.cpp index 4fca4035..de7a3eb5 100644 --- a/Elixir/Source/Engine/Aether/Manager.cpp +++ b/Elixir/Source/Engine/Aether/Manager.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -30,19 +31,19 @@ namespace Elixir::Aether return LoadEffectFile(filepath); } - Ref Manager::CreateInstance(const Ref& system) + bool Manager::Recompile(const Ref& system) { - return GetRuntime().CreateInstance(system); + return GetRuntime().Recompile(system); } - bool Manager::Recompile(const Ref& system) + bool Manager::Add(const Ref& instance) { - return GetRuntime().Recompile(system); + return GetRuntime().Register(instance); } - bool Manager::DestroyInstance(const Ref& instance) + bool Manager::Remove(const Ref& instance) { - const auto detached = GetRuntime().DetachInstance(instance); + const auto detached = GetRuntime().Unregister(instance); if (!detached) return false; m_PendingRetirements.Enqueue(detached); @@ -52,29 +53,11 @@ namespace Elixir::Aether void Manager::BeginFrame(const Timestep& timestep) { + GetRuntime().PublishActiveInstances(); GetSimulator().BeginFrame(timestep); RetireDestroyedInstances(); } - Ref Manager::CreateFrameSubmission() - { - return CreateRef(); - } - - bool Manager::Submit( - FrameSubmission& submission, - const Ref& instance - ) const - { - return GetRuntime().Submit(submission, instance); - } - - void Manager::PublishFrameSubmission(Ref submission) - { - EE_CORE_ASSERT(submission, "Aether frame submission cannot be null.") - GetRuntime().Publish(std::move(submission)); - } - void Manager::Render(const Camera& camera) { const auto submission = GetRuntime().AcquireSubmission(); diff --git a/Elixir/Source/Engine/Aether/Manager.h b/Elixir/Source/Engine/Aether/Manager.h index a9d34f3e..90714f09 100644 --- a/Elixir/Source/Engine/Aether/Manager.h +++ b/Elixir/Source/Engine/Aether/Manager.h @@ -1,9 +1,6 @@ #pragma once #include -#include -#include -#include #include namespace Elixir @@ -19,6 +16,8 @@ namespace Elixir namespace Aether { + namespace Runtime { class InstanceRegistry; } + namespace Simulation { class Simulator; @@ -50,9 +49,9 @@ namespace Elixir::Aether * simulation resources, compute pipelines, and deferred resource retirement. * Renderer consumes immutable render frames and records particle draw commands. * - * A frame producer creates and fills a FrameSubmission. Publishing the - * submission makes its immutable instance data available to Simulator. - * Simulator produces a RenderFrame for Renderer. + * 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. @@ -104,17 +103,6 @@ namespace Elixir::Aether */ static Ref LoadEffect(const std::filesystem::path& filepath); - /** - * @brief Creates and registers an instance of a System asset. - * - * Runtime compiles and caches the asset without retaining the authored - * System. - * - * @param system System asset to instantiate. - * @return Registered instance, or null when compilation fails. - */ - Ref CreateInstance(const Ref& system); - /** * @brief Recompiles a System and updates its registered instances. * @@ -123,6 +111,22 @@ namespace Elixir::Aether */ 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. * @@ -134,64 +138,24 @@ namespace Elixir::Aether * @return True when the manager owned and detached the instance. * @return False when instance is null or belongs to another manager. * - * @note Submit() rejects the instance after this method returns true. + * @note RegisterInstance() rejects this instance after the method returns true. */ - bool DestroyInstance(const Ref& instance); + bool Remove(const Ref& instance); /** * @brief Prepares Aether for a new frame. * - * The method updates simulation time, releases allocations whose GPU work - * has completed, and forwards destroyed instances to Simulator. + * 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 and before Render(). + * the current frame slot. */ void BeginFrame(const Timestep& timestep); - /** - * @brief Creates an empty mutable frame submission. - * - * A single producer fills the submission with managed system instances, - * then publishes it for rendering. - * - * @return A new unsealed frame submission. - */ - static Ref CreateFrameSubmission(); - - /** - * @brief Captures an instance for a frame submission. - * - * The method captures the instance's immutable render proxy. It does not - * publish the submission. - * - * @param submission Submission to update. - * @param instance Registered runtime instance to capture. - * @return True when the instance was captured. - * @return False when the instance is unmanaged, null, duplicated, or the - * submission is sealed. - */ - bool Submit( - FrameSubmission& submission, - const Ref& instance - ) const; - - /** - * @brief Publishes a completed frame submission for rendering. - * - * The method seals the submission and removes instances that Manager no - * longer owns. Simulator consumes the latest published submission when - * Render() runs. - * - * @param submission Submission to publish. - * - * @pre submission is not null. - * @warning Do not modify the submission after publishing it. - */ - void PublishFrameSubmission(Ref submission); - /** * @brief Simulates and renders the latest published submission. * @@ -227,9 +191,9 @@ namespace Elixir::Aether Scope m_Runtime; Scope m_Simulator; + Scope m_Renderer; SystemInstanceRetirementQueue m_PendingRetirements; - Scope m_Renderer; const GraphicsContext* m_GraphicsContext = nullptr; }; diff --git a/Elixir/Source/Engine/Aether/Rendering/FrameSubmission.h b/Elixir/Source/Engine/Aether/Rendering/FrameSubmission.h index 6b4d7d53..66610ecc 100644 --- a/Elixir/Source/Engine/Aether/Rendering/FrameSubmission.h +++ b/Elixir/Source/Engine/Aether/Rendering/FrameSubmission.h @@ -31,7 +31,8 @@ namespace Elixir::Aether::Rendering * * @param instance Runtime instance to capture. * @return True when the instance was added. - * @return False when the submission is sealed or already contains instance. + * @return False when the instance is unregistered, duplicated, or the + * submission is sealed. */ bool Submit(const SystemInstance& instance) { @@ -41,6 +42,12 @@ namespace Elixir::Aether::Rendering 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; } diff --git a/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp index 4dc1fffe..d69549a5 100644 --- a/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp +++ b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp @@ -12,30 +12,6 @@ namespace Elixir::Aether::Runtime ) : m_EffectMaterials(materialRegistry), m_MaterialResolver(materialResolver) {} - Ref InstanceRegistry::CreateInstance(const Ref& system) - { - EE_CORE_ASSERT(system, "Aether requires a System asset.") - if (!system) return nullptr; - - const std::scoped_lock lock(m_Mutex); - - auto compiled = m_CompiledSystems.find(system->GetId()); - if (compiled == m_CompiledSystems.end()) - { - const auto result = CompileSystem(*system); - if (!result) return nullptr; - - compiled = m_CompiledSystems.emplace(system->GetId(), result).first; - } - - auto instance = Ref(new SystemInstance(compiled->second)); - const auto [_, inserted] = m_Instances.emplace(instance->GetKey(), instance); - - EE_CORE_ASSERT(inserted, "Aether system instance UUID must be unique.") - - return instance; - } - bool InstanceRegistry::Recompile(const Ref& system) { EE_CORE_ASSERT(system, "Aether requires a System asset.") @@ -57,7 +33,55 @@ namespace Elixir::Aether::Runtime return true; } - Ref InstanceRegistry::DetachInstance( + 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); + m_InstanceOrder.push_back(instance->GetKey()); + } + + return true; + } + + Ref InstanceRegistry::Unregister( const Ref& instance ) { @@ -74,31 +98,36 @@ namespace Elixir::Aether::Runtime auto detached = found->second; m_Instances.erase(found); + std::erase(m_InstanceOrder, instance->GetKey()); return detached; } - bool InstanceRegistry::Submit( - FrameSubmission& submission, - const Ref& instance - ) + void InstanceRegistry::PublishActiveInstances() { const std::scoped_lock lock(m_Mutex); - return IsManagedInstance(instance) && submission.Submit(*instance); - } - void InstanceRegistry::Publish(Ref submission) - { - EE_CORE_ASSERT(submission, "Aether frame submission cannot be null.") + auto submission = CreateRef(); - const std::scoped_lock lock(m_Mutex); - m_Publisher.Publish( - std::move(submission), - [this](const SSystemInstanceKey& key) + for (const auto& key : m_InstanceOrder) + { + const auto found = m_Instances.find(key); + EE_CORE_ASSERT( + found != m_Instances.end(), + "Aether active instance is not registered." + ) + + if (found != m_Instances.end()) { - return m_Instances.contains(key); + const bool submitted = submission->Submit(*found->second); + EE_CORE_ASSERT( + submitted, + "Aether could not capture an active system instance." + ) } - ); + } + + m_Publisher.Publish(std::move(submission)); } Ref InstanceRegistry::AcquireSubmission() diff --git a/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.h b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.h index 2e341d4f..daf91389 100644 --- a/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.h +++ b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.h @@ -18,9 +18,9 @@ namespace Elixir::Aether::Runtime /** * @brief Manages compiled Aether systems and their runtime instances. * - * InstanceRegistry resolves effect materials, compiles System assets, caches - * immutable runtime data, and owns registered SystemInstance objects. It also - * publishes immutable frame submissions without depending on GPU services. + * 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 @@ -45,17 +45,6 @@ namespace Elixir::Aether::Runtime MaterialResolver& materialResolver ); - /** - * @brief Creates and registers an instance of a System asset. - * - * The method reuses an existing compiled representation when available. - * It does not retain the authored System. - * - * @param system System asset to instantiate. - * @return Registered instance, or null when compilation fails. - */ - Ref CreateInstance(const Ref& system); - /** * @brief Recompiles a System and updates all of its registered instances. * @@ -68,6 +57,21 @@ namespace Elixir::Aether::Runtime */ 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. * @@ -77,26 +81,15 @@ namespace Elixir::Aether::Runtime * @param instance Instance to detach. * @return Detached instance, or null when it is not registered. */ - Ref DetachInstance(const Ref& instance); + Ref Unregister(const Ref& instance); /** - * @brief Adds a registered instance to a frame submission. - * - * @param submission Submission to update. - * @param instance Instance to capture. - * @return True when the instance was captured. - */ - bool Submit(FrameSubmission& submission, const Ref& instance); - - /** - * @brief Publishes a frame submission containing managed instances. - * - * Instances detached before publication are removed from the published - * submission. + * @brief Publishes the current state of every active instance. * - * @param submission Submission to publish. + * A submission racing this method is included in either this frame or the + * next frame, according to the registry lock acquisition order. */ - void Publish(Ref submission); + void PublishActiveInstances(); /** * @brief Returns the latest immutable frame submission. @@ -116,6 +109,7 @@ namespace Elixir::Aether::Runtime std::unordered_map> m_CompiledSystems; std::unordered_map> m_Instances; + std::vector m_InstanceOrder; mutable std::mutex m_Mutex; FrameSubmissionPublisher m_Publisher; diff --git a/Elixir/Source/Engine/Aether/System.cpp b/Elixir/Source/Engine/Aether/System.cpp index 66646159..f3856372 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, @@ -26,13 +33,41 @@ namespace Elixir::Aether 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(MaterialResolver& materialResolver) const { SCompiledSystem system; system.SourceId = m_UUID; system.CompilationRevision = ++m_CompilationRevision; - system.Parameters = m_Parameters.Compile(); + system.Parameters = BuildExposedParameters(); const auto systemCurves = m_Curves.Compile(); system.Curves.insert(system.Curves.end(), systemCurves.begin(), systemCurves.end()); @@ -44,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()); diff --git a/Elixir/Source/Engine/Aether/System.h b/Elixir/Source/Engine/Aether/System.h index fa504599..cfe4d8a5 100644 --- a/Elixir/Source/Engine/Aether/System.h +++ b/Elixir/Source/Engine/Aether/System.h @@ -17,6 +17,8 @@ namespace Elixir::Aether using namespace Core; using namespace Modules; + class SystemInstance; + /** * @brief Maps an exposed runtime parameter to the compiled parameter table. * @@ -85,9 +87,10 @@ namespace Elixir::Aether * @note A system is movable but not copyable. Its UUID identifies the authored * source across compiled revisions. */ - class ELIXIR_API System final + class ELIXIR_API System final : public std::enable_shared_from_this { friend class Runtime::InstanceRegistry; + friend class SystemInstance; public: /** @@ -103,6 +106,19 @@ 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. * @@ -166,6 +182,12 @@ namespace Elixir::Aether 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(MaterialResolver& materialResolver) const; diff --git a/Elixir/Source/Engine/Aether/SystemInstance.cpp b/Elixir/Source/Engine/Aether/SystemInstance.cpp index dd192ae1..7ef22c60 100644 --- a/Elixir/Source/Engine/Aether/SystemInstance.cpp +++ b/Elixir/Source/Engine/Aether/SystemInstance.cpp @@ -31,42 +31,11 @@ namespace Elixir::Aether /* SystemInstance */ - SystemInstance::SystemInstance(Ref system) - : m_SourceSystemId(system->SourceId), - m_CompiledSystem(std::move(system)) + SystemInstance::SystemInstance(Ref system) + : m_SourceSystemId(system->GetId()), + m_SourceSystem(std::move(system)) { - EE_CORE_ASSERT(m_CompiledSystem, "SystemInstance requires a compiled system.") - PublishSnapshot(); - } - - void SystemInstance::ApplyCompilation(Ref system) - { - 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; - - 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(); + EE_CORE_ASSERT(m_SourceSystem, "SystemInstance requires an authored system.") } bool SystemInstance::SetParameterOverride( @@ -76,12 +45,18 @@ namespace Elixir::Aether { const std::scoped_lock lock(m_SnapshotMutex); - if (!FindExposedParameter(*m_CompiledSystem, name)) - return false; + 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; - PublishSnapshot(); + + if (m_CompiledSystem) + PublishSnapshot(); return true; } @@ -94,7 +69,9 @@ namespace Elixir::Aether return false; ++m_ParameterRevision; - PublishSnapshot(); + + if (m_CompiledSystem) + PublishSnapshot(); return true; } @@ -108,13 +85,30 @@ namespace Elixir::Aether m_ParameterOverrides.clear(); ++m_ParameterRevision; - PublishSnapshot(); + + if (m_CompiledSystem) + PublishSnapshot(); } std::optional SystemInstance::GetParameterValue(const std::string& name) const { const std::scoped_lock lock(m_SnapshotMutex); + if (!m_CompiledSystem) + { + 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; + } + const auto* exposedParameter = FindExposedParameter(*m_CompiledSystem, name); if (!exposedParameter) return std::nullopt; @@ -136,6 +130,104 @@ namespace Elixir::Aether { const std::scoped_lock lock(m_SnapshotMutex); m_WorldTransform = worldTransform; + + if (m_CompiledSystem) + PublishSnapshot(); + } + + Ref SystemInstance::GetSourceSystem() const + { + const std::scoped_lock lock(m_SnapshotMutex); + return m_SourceSystem; + } + + bool SystemInstance::TryBeginSubmission() + { + const std::scoped_lock lock(m_SnapshotMutex); + + if (m_SubmissionStarted) + return false; + + m_SubmissionStarted = true; + return true; + } + + void SystemInstance::CancelSubmission() + { + 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; + + 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::ApplyCompilation(Ref system) + { + 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; + + 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(); } @@ -147,6 +239,11 @@ namespace Elixir::Aether void SystemInstance::PublishSnapshot() { + EE_CORE_ASSERT( + m_CompiledSystem, + "SystemInstance requires compiled data before publishing a snapshot." + ) + m_Snapshot = CreateRef( m_Key, m_Revision, diff --git a/Elixir/Source/Engine/Aether/SystemInstance.h b/Elixir/Source/Engine/Aether/SystemInstance.h index f83d42fa..1bb53d3c 100644 --- a/Elixir/Source/Engine/Aether/SystemInstance.h +++ b/Elixir/Source/Engine/Aether/SystemInstance.h @@ -135,10 +135,13 @@ namespace Elixir::Aether }; /** - * @brief Represents one runtime use of a compiled Aether system. + * @brief Represents one runtime use of an Aether system. * - * SystemInstance owns mutable runtime choices: the selected compiled system, - * world transform, and exposed parameter overrides. It publishes each change + * 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 @@ -151,12 +154,19 @@ namespace Elixir::Aether 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; @@ -211,8 +221,17 @@ namespace Elixir::Aether void SetWorldTransform(const glm::mat4& worldTransform); private: - // Creates an instance from data compiled internally by Aether. - explicit SystemInstance(Ref system); + // Returns the authored System retained before the first submission. + Ref GetSourceSystem() const; + + // 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); @@ -248,10 +267,12 @@ namespace Elixir::Aether 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 }; ParameterOverridesMap m_ParameterOverrides; + bool m_SubmissionStarted = false; Ref m_Snapshot; mutable std::mutex m_SnapshotMutex; }; diff --git a/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionPublisherTest.cpp b/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionPublisherTest.cpp index 1b4da3e7..6e148079 100644 --- a/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionPublisherTest.cpp +++ b/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionPublisherTest.cpp @@ -14,7 +14,7 @@ using namespace Elixir::Aether::Rendering; TEST(FrameSubmissionPublisherTest, PublishesOnlySealedSubmissions) { TestInstanceRegistry runtime; - const auto instance = runtime.CreateInstance("Sealed submission system"); + const auto instance = runtime.CreateRegisteredInstance("Sealed submission system"); ASSERT_TRUE(instance); const auto submission = CreateRef(); @@ -32,7 +32,7 @@ TEST(FrameSubmissionPublisherTest, PublishesOnlySealedSubmissions) TEST(FrameSubmissionPublisherTest, RemovesDestroyedInstanceFromPublishedFrame) { TestInstanceRegistry runtime; - const auto instance = runtime.CreateInstance("Removed published system"); + const auto instance = runtime.CreateRegisteredInstance("Removed published system"); ASSERT_TRUE(instance); const auto submission = CreateRef(); @@ -48,7 +48,7 @@ TEST(FrameSubmissionPublisherTest, RemovesDestroyedInstanceFromPublishedFrame) TEST(FrameSubmissionPublisherTest, FiltersInstanceRejectedAtPublication) { TestInstanceRegistry runtime; - const auto instance = runtime.CreateInstance("Filtered published system"); + const auto instance = runtime.CreateRegisteredInstance("Filtered published system"); ASSERT_TRUE(instance); const auto submission = CreateRef(); @@ -66,7 +66,7 @@ TEST(FrameSubmissionPublisherTest, FiltersInstanceRejectedAtPublication) TEST(FrameSubmissionPublisherTest, PublishesAndAcquiresSealedFramesConcurrently) { TestInstanceRegistry runtime; - const auto instance = runtime.CreateInstance("Concurrent published system"); + const auto instance = runtime.CreateRegisteredInstance("Concurrent published system"); ASSERT_TRUE(instance); const auto firstSubmission = CreateRef(); diff --git a/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionTest.cpp b/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionTest.cpp index d27ab3ca..17de5fb8 100644 --- a/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionTest.cpp +++ b/Elixir/Tests/Engine/Aether/Rendering/FrameSubmissionTest.cpp @@ -38,8 +38,8 @@ TEST(FrameSubmissionTest, RetainsEachSystemInstanceAtMostOnce) { TestInstanceRegistry runtime; const auto system = CreateRef("Frame submission system"); - const auto firstInstance = runtime.Registry.CreateInstance(system); - const auto secondInstance = runtime.Registry.CreateInstance(system); + const auto firstInstance = runtime.CreateRegisteredInstance(system); + const auto secondInstance = runtime.CreateRegisteredInstance(system); ASSERT_TRUE(firstInstance); ASSERT_TRUE(secondInstance); @@ -58,8 +58,8 @@ TEST(FrameSubmissionTest, ResetKeepsTheSubmissionReusable) { TestInstanceRegistry runtime; const auto system = CreateRef("Reusable submission system"); - const auto firstInstance = runtime.Registry.CreateInstance(system); - const auto secondInstance = runtime.Registry.CreateInstance(system); + const auto firstInstance = runtime.CreateRegisteredInstance(system); + const auto secondInstance = runtime.CreateRegisteredInstance(system); ASSERT_TRUE(firstInstance); ASSERT_TRUE(secondInstance); @@ -79,7 +79,7 @@ TEST(FrameSubmissionTest, ResetKeepsTheSubmissionReusable) TEST(FrameSubmissionTest, RemovesAnInstanceBeforeItIsRetired) { TestInstanceRegistry runtime; - const auto instance = runtime.CreateInstance("Removed submission system"); + const auto instance = runtime.CreateRegisteredInstance("Removed submission system"); ASSERT_TRUE(instance); FrameSubmission submission; @@ -94,7 +94,7 @@ TEST(FrameSubmissionTest, RemovesAnInstanceBeforeItIsRetired) TEST(FrameSubmissionTest, RetainsTheStateCapturedAtSubmission) { TestInstanceRegistry runtime; - const auto instance = runtime.CreateInstance("Captured submission system"); + const auto instance = runtime.CreateRegisteredInstance("Captured submission system"); ASSERT_TRUE(instance); FrameSubmission submission; diff --git a/Elixir/Tests/Engine/Aether/Rendering/SystemInstanceRetirementQueueTest.cpp b/Elixir/Tests/Engine/Aether/Rendering/SystemInstanceRetirementQueueTest.cpp index 4686c549..6d0fbc14 100644 --- a/Elixir/Tests/Engine/Aether/Rendering/SystemInstanceRetirementQueueTest.cpp +++ b/Elixir/Tests/Engine/Aether/Rendering/SystemInstanceRetirementQueueTest.cpp @@ -17,8 +17,8 @@ TEST(SystemInstanceRetirementQueueTest, TransfersPendingInstancesExactlyOnce) { TestInstanceRegistry runtime; const auto system = CreateRef("Retirement queue system"); - const auto first = runtime.Registry.CreateInstance(system); - const auto second = runtime.Registry.CreateInstance(system); + const auto first = runtime.CreateRegisteredInstance(system); + const auto second = runtime.CreateRegisteredInstance(system); ASSERT_TRUE(first); ASSERT_TRUE(second); @@ -44,7 +44,7 @@ TEST(SystemInstanceRetirementQueueTest, DrainsDestroyRequestsEnqueuedDuringUpdat instances.reserve(destroyRequestCount); for (uint32_t instance = 0; instance < destroyRequestCount; ++instance) - instances.push_back(runtime.Registry.CreateInstance(system)); + instances.push_back(runtime.CreateRegisteredInstance(system)); SystemInstanceRetirementQueue queue; std::barrier beginConcurrentAccess{ 2 }; diff --git a/Elixir/Tests/Engine/Aether/Runtime/InstanceRegistryTest.cpp b/Elixir/Tests/Engine/Aether/Runtime/InstanceRegistryTest.cpp index 03ccde0a..24b577ce 100644 --- a/Elixir/Tests/Engine/Aether/Runtime/InstanceRegistryTest.cpp +++ b/Elixir/Tests/Engine/Aether/Runtime/InstanceRegistryTest.cpp @@ -1,5 +1,8 @@ #include +#include +#include + #include #include "../TestInstanceRegistry.h" @@ -12,12 +15,11 @@ using namespace Elixir::Aether::Runtime; namespace { Ref Capture( - InstanceRegistry& registry, const Ref& instance ) { FrameSubmission submission; - EXPECT_TRUE(registry.Submit(submission, instance)); + EXPECT_TRUE(submission.Submit(*instance)); if (submission.IsEmpty()) return nullptr; @@ -26,22 +28,26 @@ namespace } } -TEST(InstanceRegistryTest, CreatesAnInstanceWithoutRetainingItsSystem) +TEST(InstanceRegistryTest, RetainsAuthoredSystemUntilFirstRegistration) { TestInstanceRegistry runtime; std::weak_ptr authoredSystem; Ref instance; { - const auto system = CreateRef("Transient authored system"); + auto system = CreateRef("Transient authored system"); system->GetParameters().SetFloat4("Tint", { 1.0f, 0.5f, 0.25f, 1.0f }); authoredSystem = system; - instance = runtime.Registry.CreateInstance(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"); @@ -53,14 +59,16 @@ TEST(InstanceRegistryTest, ReusesCompiledDataForTheSameSystem) { TestInstanceRegistry runtime; const auto system = CreateRef("Shared compiled system"); - const auto first = runtime.Registry.CreateInstance(system); - const auto second = runtime.Registry.CreateInstance(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(runtime.Registry, first); - const auto secondProxy = Capture(runtime.Registry, second); + const auto firstProxy = Capture(first); + const auto secondProxy = Capture(second); ASSERT_TRUE(firstProxy); ASSERT_TRUE(secondProxy); @@ -77,20 +85,21 @@ TEST(InstanceRegistryTest, RecompilesExistingInstancesAndRetainsOverrides) const auto system = CreateRef("Recompiled system"); system->GetParameters().SetFloat4("Tint", { 1.0f, 1.0f, 1.0f, 1.0f }); - const auto instance = runtime.Registry.CreateInstance(system); + 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(runtime.Registry, 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(runtime.Registry, instance); + const auto after = Capture(instance); ASSERT_TRUE(after); EXPECT_EQ(after->GetRevision(), before->GetRevision() + 1); EXPECT_EQ( @@ -107,20 +116,82 @@ TEST(InstanceRegistryTest, RecompilesExistingInstancesAndRetainsOverrides) TEST(InstanceRegistryTest, DetachesAnInstanceFromPublishedFrames) { TestInstanceRegistry runtime; - const auto instance = runtime.CreateInstance("Detached system"); - const auto submission = CreateRef(); + const auto instance = runtime.CreateRegisteredInstance("Detached system"); ASSERT_TRUE(instance); - ASSERT_TRUE(runtime.Registry.Submit(*submission, instance)); - runtime.Registry.Publish(submission); + runtime.Registry.PublishActiveInstances(); - ASSERT_EQ(runtime.Registry.DetachInstance(instance), instance); + ASSERT_EQ(runtime.Registry.Unregister(instance), instance); const auto published = runtime.Registry.AcquireSubmission(); ASSERT_TRUE(published); EXPECT_TRUE(published->IsEmpty()); - FrameSubmission replacement; - EXPECT_FALSE(runtime.Registry.Submit(replacement, instance)); - EXPECT_FALSE(runtime.Registry.DetachInstance(instance)); + 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 submission"); + 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/SystemInstanceTest.cpp b/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp index e517c306..f6684f42 100644 --- a/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemInstanceTest.cpp @@ -53,7 +53,7 @@ TEST(SystemInstanceTest, RecompilesSystemAndIncrementsRevision) { TestInstanceRegistry runtime; const auto system = MakeSystem(); - const auto instance = runtime.Registry.CreateInstance(system); + const auto instance = runtime.CreateRegisteredInstance(system); ASSERT_TRUE(instance); const auto initialRevision = CaptureForTest(instance)->GetRevision(); @@ -70,7 +70,7 @@ TEST(SystemInstanceTest, RecompilesSystemAndIncrementsRevision) TEST(SystemInstanceTest, ReturnsOverrideOrCompiledDefaultForExposedParameter) { TestInstanceRegistry runtime; - const auto instance = runtime.Registry.CreateInstance(MakeSystem()); + const auto instance = runtime.CreateRegisteredInstance(MakeSystem()); ASSERT_TRUE(instance); const auto defaultValue = instance->GetParameterValue("Tint"); @@ -100,7 +100,7 @@ TEST(SystemInstanceTest, ReturnsOverrideOrCompiledDefaultForExposedParameter) TEST(SystemInstanceTest, AppliesOverridesOnlyToExposedParameters) { TestInstanceRegistry runtime; - const auto instance = runtime.Registry.CreateInstance(MakeSystem()); + const auto instance = runtime.CreateRegisteredInstance(MakeSystem()); ASSERT_TRUE(instance); const auto initialParameterRevision = CaptureForTest(instance)->GetParameterRevision(); @@ -127,7 +127,7 @@ TEST(SystemInstanceTest, AppliesOverridesOnlyToExposedParameters) TEST(SystemInstanceTest, ClearsOverridesAndRestoresCompiledDefaults) { TestInstanceRegistry runtime; - const auto instance = runtime.Registry.CreateInstance(MakeSystem()); + const auto instance = runtime.CreateRegisteredInstance(MakeSystem()); ASSERT_TRUE(instance); ASSERT_TRUE(instance->SetParameterOverride("Tint", { 0.25f, 0.5f, 0.75f, 1.0f })); @@ -145,7 +145,7 @@ TEST(SystemInstanceTest, RetainsCompatibleOverridesAfterRecompilation) { TestInstanceRegistry runtime; const auto system = MakeSystem(); - const auto instance = runtime.Registry.CreateInstance(system); + const auto instance = runtime.CreateRegisteredInstance(system); ASSERT_TRUE(instance); ASSERT_TRUE(instance->SetParameterOverride("Tint", { 0.25f, 0.5f, 0.75f, 1.0f })); @@ -163,7 +163,7 @@ TEST(SystemInstanceTest, RetainsCompatibleOverridesAfterRecompilation) TEST(SystemInstanceTest, StoresWorldTransformWithoutChangingCompiledSystem) { TestInstanceRegistry runtime; - const auto instance = runtime.Registry.CreateInstance(MakeSystem()); + const auto instance = runtime.CreateRegisteredInstance(MakeSystem()); ASSERT_TRUE(instance); const auto* compiledSystem = &CaptureForTest(instance)->GetCompiledSystem(); @@ -183,7 +183,7 @@ TEST(SystemInstanceTest, StoresWorldTransformWithoutChangingCompiledSystem) TEST(SystemInstanceTest, KeepsCapturedProxyImmutableDuringConcurrentOverrides) { TestInstanceRegistry runtime; - const auto instance = runtime.Registry.CreateInstance(MakeSystem()); + const auto instance = runtime.CreateRegisteredInstance(MakeSystem()); ASSERT_TRUE(instance); const auto capturedBeforeOverrides = CaptureForTest(instance); std::barrier beginUpdates{ 2 }; diff --git a/Elixir/Tests/Engine/Aether/SystemTest.cpp b/Elixir/Tests/Engine/Aether/SystemTest.cpp index 183f8ae5..c0c0724d 100644 --- a/Elixir/Tests/Engine/Aether/SystemTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemTest.cpp @@ -24,14 +24,14 @@ namespace SCompiledSystem Compile(const Ref& system) { TestInstanceRegistry runtime; - const auto instance = runtime.Registry.CreateInstance(system); + const auto instance = system->CreateInstance(); EXPECT_TRUE(instance); - if (!instance) + if (!instance || !runtime.Registry.Register(instance)) return {}; Rendering::FrameSubmission submission; - EXPECT_TRUE(runtime.Registry.Submit(submission, instance)); + EXPECT_TRUE(submission.Submit(*instance)); if (submission.IsEmpty()) return {}; diff --git a/Elixir/Tests/Engine/Aether/TestInstanceRegistry.h b/Elixir/Tests/Engine/Aether/TestInstanceRegistry.h index 1f9b8fb4..0bca0374 100644 --- a/Elixir/Tests/Engine/Aether/TestInstanceRegistry.h +++ b/Elixir/Tests/Engine/Aether/TestInstanceRegistry.h @@ -18,9 +18,15 @@ class TestInstanceRegistry final TestInstanceRegistry() : Registry(m_MaterialRegistry, m_MaterialResolver) {} - Ref CreateInstance(std::string name = "Test system") + Ref CreateRegisteredInstance(const Ref& system) { - return Registry.CreateInstance(CreateRef(std::move(name))); + 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; From 17589ccfac2b99767095b8307c59da6958342b0b Mon Sep 17 00:00:00 2001 From: Felipe Vieira Date: Wed, 12 Aug 2026 02:15:10 -0300 Subject: [PATCH 64/89] refactor(aether): simplify instance registry storage Use the instance map as the registry's single source of truth and remove the redundant registration-order list. Keep frame ordering unspecified so later culling and rendering stages can establish task-specific order. --- .../Engine/Aether/Rendering/FrameSubmission.h | 2 +- .../Aether/Runtime/InstanceRegistry.cpp | 19 ++++--------------- .../Engine/Aether/Runtime/InstanceRegistry.h | 1 - .../Aether/Runtime/InstanceRegistryTest.cpp | 2 +- 4 files changed, 6 insertions(+), 18 deletions(-) diff --git a/Elixir/Source/Engine/Aether/Rendering/FrameSubmission.h b/Elixir/Source/Engine/Aether/Rendering/FrameSubmission.h index 66610ecc..c80881c5 100644 --- a/Elixir/Source/Engine/Aether/Rendering/FrameSubmission.h +++ b/Elixir/Source/Engine/Aether/Rendering/FrameSubmission.h @@ -129,7 +129,7 @@ namespace Elixir::Aether::Rendering /** * @brief Returns the renderer-facing proxies captured for this frame. - * @return Immutable render proxies in submission order. + * @return Immutable render proxies captured for this frame. */ const std::vector>& GetRenderProxies() const { diff --git a/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp index d69549a5..b2d14715 100644 --- a/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp +++ b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp @@ -75,7 +75,6 @@ namespace Elixir::Aether::Runtime } m_Instances.emplace(instance->GetKey(), instance); - m_InstanceOrder.push_back(instance->GetKey()); } return true; @@ -98,7 +97,6 @@ namespace Elixir::Aether::Runtime auto detached = found->second; m_Instances.erase(found); - std::erase(m_InstanceOrder, instance->GetKey()); return detached; } @@ -109,22 +107,13 @@ namespace Elixir::Aether::Runtime auto submission = CreateRef(); - for (const auto& key : m_InstanceOrder) + for (const auto& instance : m_Instances | std::views::values) { - const auto found = m_Instances.find(key); + const bool submitted = submission->Submit(*instance); EE_CORE_ASSERT( - found != m_Instances.end(), - "Aether active instance is not registered." + submitted, + "Aether could not capture an active system instance." ) - - if (found != m_Instances.end()) - { - const bool submitted = submission->Submit(*found->second); - EE_CORE_ASSERT( - submitted, - "Aether could not capture an active system instance." - ) - } } m_Publisher.Publish(std::move(submission)); diff --git a/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.h b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.h index daf91389..dc1e86ef 100644 --- a/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.h +++ b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.h @@ -109,7 +109,6 @@ namespace Elixir::Aether::Runtime std::unordered_map> m_CompiledSystems; std::unordered_map> m_Instances; - std::vector m_InstanceOrder; mutable std::mutex m_Mutex; FrameSubmissionPublisher m_Publisher; diff --git a/Elixir/Tests/Engine/Aether/Runtime/InstanceRegistryTest.cpp b/Elixir/Tests/Engine/Aether/Runtime/InstanceRegistryTest.cpp index 24b577ce..bff51083 100644 --- a/Elixir/Tests/Engine/Aether/Runtime/InstanceRegistryTest.cpp +++ b/Elixir/Tests/Engine/Aether/Runtime/InstanceRegistryTest.cpp @@ -165,7 +165,7 @@ TEST(InstanceRegistryTest, AcceptsConcurrentRegistrations) constexpr size_t instanceCount = 64; TestInstanceRegistry runtime; - const auto system = CreateRef("Concurrent submission"); + const auto system = CreateRef("Concurrent registration"); std::vector> instances; std::vector threads; std::atomic_size_t accepted = 0; From 0379a125081708a0f85d47ee3910e55d33750024 Mon Sep 17 00:00:00 2001 From: MrChampz Date: Thu, 27 Aug 2026 01:01:49 -0300 Subject: [PATCH 65/89] fix(aether): offset vortex parameter indices --- .../Engine/Aether/Simulation/Simulator.cpp | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Elixir/Source/Engine/Aether/Simulation/Simulator.cpp b/Elixir/Source/Engine/Aether/Simulation/Simulator.cpp index b189cc7c..5a02ee78 100644 --- a/Elixir/Source/Engine/Aether/Simulation/Simulator.cpp +++ b/Elixir/Source/Engine/Aether/Simulation/Simulator.cpp @@ -201,6 +201,27 @@ namespace Elixir::Aether::Simulation 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; } From 59d4c913a665b64c7d67a6adcb70d66029225378 Mon Sep 17 00:00:00 2001 From: MrChampz Date: Sat, 29 Aug 2026 21:32:54 -0300 Subject: [PATCH 66/89] docs(material): document public material API --- AGENTS.md | 8 + Elixir/Source/Engine/Material/Material.h | 163 +++++++++++++++++- .../Engine/Material/MaterialRenderScene.h | 108 +++++++++++- 3 files changed, 263 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cd1b5e55..c80bd00d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,3 +3,11 @@ ## 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. diff --git a/Elixir/Source/Engine/Material/Material.h b/Elixir/Source/Engine/Material/Material.h index e3e982a7..1de3753e 100644 --- a/Elixir/Source/Engine/Material/Material.h +++ b/Elixir/Source/Engine/Material/Material.h @@ -7,35 +7,78 @@ namespace Elixir { class MaterialInstance; - // A renderer-specific shader permutation supported by a Surface material. - // It does not change the material domain or graph outputs. + /** + * @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 Identifies the category of a material parameter. + */ enum class EMaterialParameterKind : uint8_t { - Value, Texture + /** A scalar or vector parameter. */ + Value, + + /** A texture parameter. */ + Texture }; + /** + * @brief Identifies the value stored by a material parameter. + */ enum class EMaterialParameterType : uint8_t { - Scalar, Vector, Texture + /** A single floating-point value. */ + Scalar, + + /** A four-component floating-point value. */ + Vector, + + /** A texture reference. */ + Texture }; - // A single named material parameter value. A parameter is one of a scalar, a - // vector, or a texture; the active kind is given by Type. + /** + * @brief Stores one material parameter value. + * + * @ref Type identifies which value member is active. + */ struct SMaterialParam { + /** 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 SMaterialParam MakeScalar(const float value) { SMaterialParam param; @@ -44,6 +87,11 @@ namespace Elixir return param; } + /** + * @brief Creates a vector material parameter. + * @param value Vector value to store. + * @return A parameter whose type is `Vector`. + */ static SMaterialParam MakeVector(const glm::vec4& value) { SMaterialParam param; @@ -52,6 +100,11 @@ namespace Elixir return param; } + /** + * @brief Creates a texture material parameter. + * @param texture Texture to store. + * @return A parameter whose type is `Texture`. + */ static SMaterialParam MakeTexture(const Ref& texture) { SMaterialParam param; @@ -61,51 +114,138 @@ namespace Elixir } }; + /** + * @brief Defines one parameter in a material schema. + */ struct SMaterialParameterDefinition { + /** Parameter category. */ EMaterialParameterKind Kind = EMaterialParameterKind::Value; + + /** Expected type for value parameters. */ EMaterialGraphValueType ValueType = EMaterialGraphValueType::Float4; + + /** Value used when an instance does not provide an override. */ SMaterialParam DefaultValue; }; - // A material template: a named set of parameters with default values (the schema - // shared by all of its instances). The shading itself is provided by the renderer's shader; - // a Material describes the parameters that feed it. + /** + * @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 SetDefaultParam(const std::string& name, const SMaterialParam& value); + + /** + * @brief Finds a parameter default value. + * @param name Parameter name. + * @return Default value, or null if the parameter does not exist. + */ const SMaterialParam* GetDefaultParam(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 SMaterialParam& 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 SMaterialParam& value @@ -118,6 +258,11 @@ namespace Elixir 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/Material/MaterialRenderScene.h b/Elixir/Source/Engine/Material/MaterialRenderScene.h index f822f2d7..2dcb3305 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderScene.h +++ b/Elixir/Source/Engine/Material/MaterialRenderScene.h @@ -4,15 +4,39 @@ namespace Elixir { + /** + * @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 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, @@ -32,58 +56,128 @@ namespace Elixir 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 SMaterialVertexBufferBinding { + /** 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 SMaterialDrawCommand { + /** 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; }; - // Geometry resources are shared by all items that use a particle-state - // layout and render primitive in the current frame. + /** + * @brief Stores shared resources for render items with compatible geometry. + */ struct SMaterialRenderGeometry { + /** Pipeline configuration for the geometry. */ SMaterialPipelineRequest 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; }; - // A frame-local material item. Geometry producers may temporarily expose - // an additional texture while their legacy data is migrated into MaterialRenderProxy. + /** + * @brief Describes one material draw recorded for the current frame. + */ struct SMaterialRenderItem { + /** Material pass used to render the item. */ EMaterialPass Pass = EMaterialPass::ParticleSprite; + + /** Resolved material data 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. */ SMaterialDrawCommand Draw; }; - // Immutable after frame publication. MaterialSystem consumes this object - // synchronously while recording the frame command buffer. + /** + * @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(SMaterialRenderGeometry 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(SMaterialRenderItem item); + /** + * @brief Finds geometry by index. + * + * @param index Geometry index. + * @return The geometry, or null when @p index is invalid. + */ const SMaterialRenderGeometry* 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; }; -} \ No newline at end of file +} From b73a36689aa6347a8e1980d4958b8c460ba4037b Mon Sep 17 00:00:00 2001 From: MrChampz Date: Sat, 29 Aug 2026 23:54:52 -0300 Subject: [PATCH 67/89] docs(material): document render material types --- Elixir/Source/Engine/Core/Core.h | 2 +- .../Source/Engine/Material/MaterialInstance.h | 64 ++++++++- .../Engine/Material/MaterialRenderProxy.h | 43 +++++- .../Source/Engine/Material/MaterialRenderer.h | 123 +++++++++++++++++- 4 files changed, 222 insertions(+), 10 deletions(-) diff --git a/Elixir/Source/Engine/Core/Core.h b/Elixir/Source/Engine/Core/Core.h index 8b234350..df4a7541 100644 --- a/Elixir/Source/Engine/Core/Core.h +++ b/Elixir/Source/Engine/Core/Core.h @@ -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/Material/MaterialInstance.h b/Elixir/Source/Engine/Material/MaterialInstance.h index a6a58ab8..fad5193b 100644 --- a/Elixir/Source/Engine/Material/MaterialInstance.h +++ b/Elixir/Source/Engine/Material/MaterialInstance.h @@ -4,29 +4,87 @@ namespace Elixir { + /** + * @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 SMaterialParam* GetResolvedParameter(const std::string& name) const; + + /** @brief Returns the parent material. */ const Ref& GetParent() const { return m_Parent; } - uint32_t GetRevision() const { return m_Revision; } - const SMaterialParam* GetResolvedParameter(const std::string& name) const; + /** @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 SMaterialParam& value); - // Override if present, else the parent's default (or null). + // Finds an override or the parent material's default value. const SMaterialParam* Resolve(const std::string& name) const; Ref m_Parent; diff --git a/Elixir/Source/Engine/Material/MaterialRenderProxy.h b/Elixir/Source/Engine/Material/MaterialRenderProxy.h index 184b9ef0..5c0e157b 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderProxy.h +++ b/Elixir/Source/Engine/Material/MaterialRenderProxy.h @@ -5,17 +5,58 @@ namespace Elixir { + /** + * @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 ); - const Ref GetCompiledMaterial() const { return m_CompiledMaterial; } + /** + * @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: diff --git a/Elixir/Source/Engine/Material/MaterialRenderer.h b/Elixir/Source/Engine/Material/MaterialRenderer.h index d723bc91..cca72e8d 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderer.h +++ b/Elixir/Source/Engine/Material/MaterialRenderer.h @@ -12,72 +12,148 @@ namespace Elixir { class ShaderLoader; + /** + * @brief Identifies a material render pass. + */ enum class EMaterialPass : uint8_t { + /** Pass for particle sprites. */ ParticleSprite, + + /** Pass for particle ribbons. */ ParticleRibbon, + + /** Pass for particle meshes. */ ParticleMesh, }; + /** + * @brief Identifies a compiled material program. + * + * The identity is suitable for grouping render items that use the same shader. + */ struct SMaterialProgramKey { + /** 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 SMaterialProgramKey&) const = default; }; + /** + * @brief Describes the vertex input layout for a material pipeline. + */ struct SMaterialPipelineRequest { + /** 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 SMaterialConstantBufferBinding { + /** 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 SMaterialStorageBufferBinding { + /** Shader binding name. */ std::string_view Name; + + /** Storage buffer to bind. */ MaterialStorageBuffer Buffer; }; + /** + * @brief Groups external buffers required by a material pass. + */ struct SMaterialExternalResources { 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 SMaterialPassRequest { + /** Material pass to prepare. */ EMaterialPass Pass = EMaterialPass::ParticleSprite; + + /** Resolved material data for the pass. */ const MaterialRenderProxy* Material = nullptr; + + /** Pipeline requirements for the pass. */ SMaterialPipelineRequest Pipeline; + + /** External buffers required by the pass. */ SMaterialExternalResources ExternalResources; + + /** Push constants applied before the first draw. */ std::span InitialPushConstants; }; + /** + * @brief Stores a prepared shader and graphics pipeline. + */ struct SPreparedMaterialPass { + /** 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 Compiles material instances and prepares their render passes. + * + * The renderer caches compiled materials, descriptor bindings, and graphics + * pipelines for reuse across render items. + */ class ELIXIR_API MaterialRenderer final { public: + /** + * @brief Creates a material renderer. + * @param context Graphics context used to create pipelines. + * @param frameBuffer Buffer that stores per-frame material data. + * @param textures Registry that provides material textures and a sampler. + * @param shaderLoader Loader used to compile material shaders. + * @pre All arguments are valid for the renderer lifetime. + */ MaterialRenderer( const GraphicsContext* context, Ref frameBuffer, @@ -85,21 +161,54 @@ namespace Elixir const ShaderLoader* shaderLoader ); + /** + * @brief Prepares a shader and graphics pipeline for a material pass. + * @param request Pass requirements and external resources. + * @return Prepared pass, or no value if the request is invalid or unsupported. + * @pre `request.Material` is not null. + * @pre `request.Pipeline.VertexLayout` is not null. + */ std::optional Prepare( const SMaterialPassRequest& request ); + /** + * @brief Resolves an instance into render-ready material data. + * @param instance Material instance to resolve. + * @return Render proxy, or null if the instance cannot be compiled. + */ Ref Resolve(const Ref& instance); + /** + * @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, @@ -107,6 +216,7 @@ namespace Elixir DynamicStorageBuffer, }; + /** Describes one descriptor binding used by a shader. */ struct SDescriptorBinding { std::string Name; @@ -116,6 +226,7 @@ namespace Elixir bool operator==(const SDescriptorBinding&) const = default; }; + /** Stores the descriptor bindings established for a shader. */ struct SDescriptorBindingState { EMaterialPass Pass = EMaterialPass::ParticleSprite; @@ -124,6 +235,7 @@ namespace Elixir bool operator==(const SDescriptorBindingState&) const = default; }; + /** Identifies a cached graphics pipeline. */ struct SPipelineKey { EMaterialPass Pass = EMaterialPass::ParticleSprite; @@ -133,25 +245,26 @@ namespace Elixir bool operator==(const SPipelineKey&) const = default; }; + /** Hashes a graphics pipeline key. */ struct SPipelineKeyHasher { size_t operator()(const SPipelineKey& key) const { - size_t hash = std::hash{}(uint32_t(key.Pass)); - hash ^= std::hash{}(key.Shader) + - 0x9e3779b9 + (hash << 6) + (hash >> 2); - hash ^= std::hash{}(key.VertexLayoutKey) + - 0x9e3779b9 + (hash << 6) + (hash >> 2); + 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; } }; + /** Returns a cached pipeline or creates one for the request. */ Ref GetPipeline( EMaterialPass pass, const Ref& shader, const SMaterialPipelineRequest& request ); + /** Binds and validates the descriptor resources for a shader. */ bool BindDescriptorResources( const Ref& shader, const SMaterialPassRequest& request From 6c014535245623218eedee87ee25c11297bee182 Mon Sep 17 00:00:00 2001 From: MrChampz Date: Sun, 30 Aug 2026 02:58:31 -0300 Subject: [PATCH 68/89] docs(material): document material registries --- .../Source/Engine/Material/MaterialRegistry.h | 25 ++++++- .../Source/Engine/Material/MaterialResolver.h | 14 +++- .../Source/Engine/Material/MaterialSystem.h | 66 +++++++++++++++++++ .../Engine/Material/MaterialTextureRegistry.h | 47 +++++++++++++ 4 files changed, 148 insertions(+), 4 deletions(-) diff --git a/Elixir/Source/Engine/Material/MaterialRegistry.h b/Elixir/Source/Engine/Material/MaterialRegistry.h index b710ce3f..15f35045 100644 --- a/Elixir/Source/Engine/Material/MaterialRegistry.h +++ b/Elixir/Source/Engine/Material/MaterialRegistry.h @@ -5,20 +5,41 @@ namespace Elixir { - // Application-owned registry for raw material assets and defaults. + /** + * @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; }; -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/Material/MaterialResolver.h b/Elixir/Source/Engine/Material/MaterialResolver.h index 2d0e245e..89cfce30 100644 --- a/Elixir/Source/Engine/Material/MaterialResolver.h +++ b/Elixir/Source/Engine/Material/MaterialResolver.h @@ -5,14 +5,24 @@ namespace Elixir { - // Boundary used by scene compilers to publish immutable GPU material state. + /** + * @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; }; -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/Material/MaterialSystem.h b/Elixir/Source/Engine/Material/MaterialSystem.h index 8a625987..a9c63ea3 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.h +++ b/Elixir/Source/Engine/Material/MaterialSystem.h @@ -11,59 +11,125 @@ namespace Elixir { class ShaderLoader; + /** + * @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 Stores material data prepared for one submitted frame. + */ struct SMaterialFrameSnapshot { + /** @brief Maps resolved material proxies to their frame data. */ Ref Table; + + /** @brief Number of materials stored in Table. */ uint32_t MaterialCount = 0; + + /** @brief Serial that identifies the submission that owns this snapshot. */ uint64_t SubmissionSerial = 0; }; + /** + * @brief Reports the work recorded by a material render pass. + */ struct SMaterialRenderResult { + /** @brief Number of material batches rendered. */ uint32_t BatchCount = 0; + + /** @brief Number of draw commands recorded. */ uint32_t DrawCount = 0; }; + /** + * @brief Prepares frame material data and records material draw commands. + * + * The system owns shared material buffers, texture bindings, and render-state + * preparation for a graphics context. + */ class ELIXIR_API MaterialSystem final : public MaterialResolver { public: + /** + * @brief Creates a material system. + * @param context Graphics context that owns material resources. + * @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 Builds and uploads material data for a scene submission. + * @param scene Scene that provides material render items. + * @param submissionSerial Serial that identifies the submission. + * @return A snapshot that contains the resolved frame material data. + */ SMaterialFrameSnapshot BuildFrameSnapshot( const MaterialRenderScene& scene, uint64_t submissionSerial ); + /** + * @brief Gets the shader program key required for a material pass. + * @param pass Material pass to prepare. + * @param material Resolved material data. + * @return The program key, or no value when the pass is unsupported. + */ std::optional GetProgramKey( EMaterialPass pass, const MaterialRenderProxy& material ) const; + /** + * @brief Prepares GPU state for one material pass request. + * @param request Material pass and geometry requirements. + * @return Prepared pass state, or no value when preparation fails. + */ std::optional PrepareMaterialPass( const SMaterialPassRequest& request ) const; + /** + * @brief Records draw commands for the scene materials. + * @param cmd Command buffer that receives the draw commands. + * @param scene Scene that provides render items. + * @param snapshot Material data built for this scene submission. + * @return Counts of rendered batches and recorded draw commands. + */ SMaterialRenderResult Render( const Ref& cmd, const MaterialRenderScene& scene, const SMaterialFrameSnapshot& snapshot ) const; + /** + * @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 Resolve( const Ref& instance ) override; + /** @brief Gets the buffer that stores frame material data. */ const Ref& GetFrameBuffer() const { return m_FrameBuffer; } + + /** @brief Gets the texture set used by material rendering. */ const Ref& GetTextureSet() const { return m_Textures.GetTextureSet(); } + + /** @brief Gets the sampler used by material textures. */ const Ref& GetSampler() const { return m_Textures.GetSampler(); } private: diff --git a/Elixir/Source/Engine/Material/MaterialTextureRegistry.h b/Elixir/Source/Engine/Material/MaterialTextureRegistry.h index 26341055..2f96db1b 100644 --- a/Elixir/Source/Engine/Material/MaterialTextureRegistry.h +++ b/Elixir/Source/Engine/Material/MaterialTextureRegistry.h @@ -5,11 +5,23 @@ namespace Elixir { + /** + * @brief Stores a texture binding and submission where it becomes available. + */ struct SMaterialTextureBinding { + /** @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 @@ -21,17 +33,52 @@ namespace Elixir } }; + /** + * @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 MaterialTextureRegistry 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 MaterialTextureRegistry(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: From 902873a208ab203ceb1777c5e4d1ffacdfccfc7d Mon Sep 17 00:00:00 2001 From: MrChampz Date: Sun, 30 Aug 2026 10:40:04 -0300 Subject: [PATCH 69/89] docs(material): document compilation types --- .../Source/Engine/Material/DefaultMaterials.h | 14 ++++ .../Material/MaterialCompilationCache.h | 27 ++++++- .../Source/Engine/Material/MaterialCompiler.h | 81 +++++++++++++++++-- .../Engine/Material/MaterialFrameTable.h | 45 ++++++++++- 4 files changed, 155 insertions(+), 12 deletions(-) diff --git a/Elixir/Source/Engine/Material/DefaultMaterials.h b/Elixir/Source/Engine/Material/DefaultMaterials.h index 1788b28d..d0038d87 100644 --- a/Elixir/Source/Engine/Material/DefaultMaterials.h +++ b/Elixir/Source/Engine/Material/DefaultMaterials.h @@ -4,9 +4,23 @@ namespace Elixir { + /** + * @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. + */ DefaultMaterialArray CreateDefaultMaterials(); } \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/MaterialCompilationCache.h b/Elixir/Source/Engine/Material/MaterialCompilationCache.h index b7c76e8f..368d7fcd 100644 --- a/Elixir/Source/Engine/Material/MaterialCompilationCache.h +++ b/Elixir/Source/Engine/Material/MaterialCompilationCache.h @@ -7,15 +7,38 @@ namespace Elixir { class ShaderLoader; - // Renderer-owned, GraphicsContext-scoped cache for compiled material programs. + /** + * @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 MaterialCompilationCache 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 MaterialCompilationCache(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; @@ -27,4 +50,4 @@ namespace Elixir std::unordered_map m_Entries; std::mutex m_Mutex; }; -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/Material/MaterialCompiler.h b/Elixir/Source/Engine/Material/MaterialCompiler.h index e33d55a9..4ee3e6a8 100644 --- a/Elixir/Source/Engine/Material/MaterialCompiler.h +++ b/Elixir/Source/Engine/Material/MaterialCompiler.h @@ -5,30 +5,68 @@ namespace Elixir { + /** + * @brief Describes one material parameter in compiled GPU data. + */ struct SCompiledMaterialParameter { + /** @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. */ EMaterialGraphValueType ValueType = EMaterialGraphValueType::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; } - // A Surface shader is never a fallback particle permutation. + /** + * @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) @@ -46,47 +84,76 @@ namespace Elixir } }; + /** + * @brief Reports the outcome of material compilation. + */ struct SMaterialCompileResult { + /** @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; } }; - // Turns a MaterialGraph into a usable shader: injects the graph's generated - // body into the Material template, compiles it to SPIR-V with DXC at - // runtime, and loads it (with the shared model vertex shader) into a Shader. + /** + * @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 MaterialCompiler { public: - // Pure phase: validates schema, assigns stable slots and lowers HLSL. + /** + * @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 SMaterialCompileResult Build(const Material& material); - // Toolchain phase: compiles the prepared material to a ready-to-bind shader. - static SMaterialCompileResult Compile(const ShaderLoader* loader, 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 SMaterialCompileResult 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 SMaterialCompileResult CompileSurface( const ShaderLoader* loader, const Material& material, SMaterialCompileResult result ); + /** @brief Compiles the particle sprite shader program. */ static SMaterialCompileResult CompileParticleSprite( const ShaderLoader* loader, const Material& material, SMaterialCompileResult result ); + /** @brief Compiles the particle ribbon shader program. */ static SMaterialCompileResult CompileParticleRibbon( const ShaderLoader* loader, const Material& material, SMaterialCompileResult result ); + /** @brief Compiles the particle mesh shader program. */ static SMaterialCompileResult CompileParticleMesh( const ShaderLoader* loader, const Material& material, diff --git a/Elixir/Source/Engine/Material/MaterialFrameTable.h b/Elixir/Source/Engine/Material/MaterialFrameTable.h index 1f67ec48..116ef185 100644 --- a/Elixir/Source/Engine/Material/MaterialFrameTable.h +++ b/Elixir/Source/Engine/Material/MaterialFrameTable.h @@ -4,31 +4,70 @@ namespace Elixir { - // GPU ABI shared by material templates. + /** + * @brief Defines the GPU data layout for one resolved material. + * + * Material templates share this layout with the shader interface. + */ struct alignas(16) SMaterialFrameData { - std::array Values{}; - std::array TextureIndices{}; + /** @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 MaterialFrameTable 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. + */ MaterialFrameTable( 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; From 067d2652fe5bd615fb37df4d4f456316a3275218 Mon Sep 17 00:00:00 2001 From: MrChampz Date: Sun, 30 Aug 2026 16:31:42 -0300 Subject: [PATCH 70/89] docs(material): document material graph API --- .../Source/Engine/Material/MaterialGraph.cpp | 7 +- Elixir/Source/Engine/Material/MaterialGraph.h | 217 ++++++++++++++---- 2 files changed, 176 insertions(+), 48 deletions(-) diff --git a/Elixir/Source/Engine/Material/MaterialGraph.cpp b/Elixir/Source/Engine/Material/MaterialGraph.cpp index fee4e66a..55729959 100644 --- a/Elixir/Source/Engine/Material/MaterialGraph.cpp +++ b/Elixir/Source/Engine/Material/MaterialGraph.cpp @@ -169,9 +169,9 @@ namespace Elixir it->second.Inputs[toSlot] = (int32_t)fromNode; } - void MaterialGraph::SetChannel(EMaterialChannel channel, uint32_t nodeId) + void MaterialGraph::SetChannel(const EMaterialChannel channel, const uint32_t nodeId) { - m_Channels[(uint8_t)channel] = nodeId; + m_Channels[channel] = nodeId; } std::string MaterialGraph::GenerateHLSL() const @@ -185,14 +185,13 @@ namespace Elixir std::unordered_map emitted; std::unordered_map types; - for (const auto& [channelIndex, nodeId] : m_Channels) + for (const auto& [channel, nodeId] : m_Channels) { const std::string var = EmitNode(nodeId, emitted, types, body, &bindings); const EMaterialGraphValueType from = types.contains(nodeId) ? types[nodeId] : EMaterialGraphValueType::Float4; - const auto channel = (EMaterialChannel)channelIndex; const auto channelName = ChannelName(channel); body += " surface." + std::string(channelName) + " = " + Coerce(var, from, channel) + ";\n"; } diff --git a/Elixir/Source/Engine/Material/MaterialGraph.h b/Elixir/Source/Engine/Material/MaterialGraph.h index 8ee81f13..01a79502 100644 --- a/Elixir/Source/Engine/Material/MaterialGraph.h +++ b/Elixir/Source/Engine/Material/MaterialGraph.h @@ -2,96 +2,225 @@ namespace Elixir { - // The HLSL value type a node output carries. + /** + * @brief Defines the HLSL value type produced by a graph node. + */ enum class EMaterialGraphValueType : uint8_t { - Float, Float2, Float3, Float4, + /** @brief One floating-point component. */ + Float, + + /** @brief Two floating-point components. */ + Float2, + + /** @brief Three floating-point components. */ + Float3, + + /** @brief Four floating-point components. */ + Float4, }; - // The kind of computation a node performs. The codegen switches on this. + /** + * @brief Defines the operation performed by a material graph node. + */ enum class EMaterialNodeType : uint8_t { - Constant, // a literal value - Parameter, // a named material-instance parameters (mat.) - TexCoord, // input.TexCoord - TextureSample, // sample a bound texture at a UV (input 0) - ComponentMask, // Select one component from a vector input. - Time, // seconds since start (cbFrame.start) - Sine, // sin(a) - Panner, // uv + Time * speed (speed from ConstantValue.xy) - Checkerboard, // procedural two-color checkerboard from UV + /** @brief Outputs a literal value. */ + Constant, + + /** @brief Reads a named material value parameter. */ + Parameter, // mat. + + /** @brief Outputs the input texture coordinates. */ + TexCoord, // input.TexCoord + + /** @brief Samples a named material texture parameter. */ + TextureSample, // sample a bound texture at a UV (input 0) + + /** @brief Selects one component from an input value. */ + ComponentMask, + + /** @brief Outputs elapsed time in seconds. */ + Time, // cbFrame.Time + + /** @brief Applies the sine function to an input. */ + Sine, // sin(a) + + /** @brief Offsets texture coordinates over time. */ + Panner, // uv + Time * speed (speed from ConstantValue.xy) + + /** @brief Generates a procedural checkerboard. */ + Checkerboard, + + /** @brief Generates an exponential radial gradient. */ RadialGradientExponential, // pow(saturate(1 - distance / radius), exponent) - Multiply, // a * b - Add, // a + b - Subtract, // a - b - Divide, // a / b - Power, // pow(a, b) - Dot, // dot(a, b) -> scalar - Lerp, // lerp(a, b, t) - OneMinus, // 1 - a - Saturate, // saturate(a - Fresnel, // schlick fresnel from N,V + + /** @brief Multiplies two input values. */ + Multiply, // a * b + + /** @brief Adds two input values. */ + Add, // a + b + + /** @brief Subtracts two input values. */ + Subtract, // a - b + + /** @brief Divides two input values. */ + Divide, // a / b + + /** @brief Raises one input value to another. */ + Power, // pow(a, b) + + /** @brief Calculates the dot product of two input values. */ + Dot, // dot(a, b) -> scalar + + /** @brief Linearly interpolates between two input values. */ + Lerp, // lerp(a, b, t) + + /** @brief Subtracts an input value from one. */ + OneMinus, // 1 - a + + /** @brief Clamps an input value to the zero-to-one range. */ + Saturate, // saturate(a) + + /** @brief Calculates a Schlick Fresnel factor. */ + Fresnel, }; - // The surface output a channel drives. + /** + * @brief Defines a surface property driven by a material graph. + */ enum class EMaterialChannel : uint8_t { - BaseColor, Normal, Metallic, Roughness, Opacity, Emissive + /** @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 { + /** @brief Expressions for numeric material parameters. */ std::unordered_map Values; + + /** @brief Expressions for texture material parameters. */ std::unordered_map Textures; }; - // One node in a material graph. Nodes are plain data (no lambdas) so the graph - // can be serialized and edited; the codegen interprets Type. + /** + * @brief Stores the data and input connections for one material graph node. + */ struct SMaterialNode { + /** @brief Unique graph identifier assigned when the node is added. */ uint32_t Id = 0; + + /** @brief Operation performed by the node. */ EMaterialNodeType Type = EMaterialNodeType::Constant; + + /** @brief Value type produced by the node. */ EMaterialGraphValueType OutputType = EMaterialGraphValueType::Float4; - // For each input slot: the id of the source node, or -1 to use the matching - // DefaultInputs literal. + /** + * @brief Source node IDs for each input slot. + * + * A value of -1 selects the matching entry in DefaultInputs. + */ std::vector Inputs; + + /** @brief Literal expressions used by unconnected input slots. */ std::vector DefaultInputs; - // Per-type payload. - glm::vec4 ConstantValue{ 0.0f }; // Constant - std::string ParameterName; // Parameter -> mat. - std::string TextureParameterName; // TextureSample -> material texture parameter - uint32_t ComponentIndex = 0; // ComponentMask: x, y, z or w. + /** @brief Constant value and type-specific numeric settings. */ + glm::vec4 ConstantValue{ 0.0f }; + + /** @brief Material value parameter read by a Parameter node. */ + std::string ParameterName; + + /** @brief Material texture parameter sampled by a TextureSample node. */ + std::string TextureParameterName; + + /** @brief Component selected by a ComponentMask node. */ + uint32_t ComponentIndex = 0; + + /** @brief Center used by a RadialGradientExponential node. */ + glm::vec2 RadialGradientCenter{ 0.5f }; + + /** @brief Radius used by a RadialGradientExponential node. */ + float RadialGradientRadius = 0.5f; - // RadialGradientExponential - glm::vec2 RadialGradientCenter{ 0.5f }; - float RadialGradientRadius = 0.5f; - float RadialGradientExponent = 1.0f; + /** @brief Exponent used by a RadialGradientExponential node. */ + float RadialGradientExponent = 1.0f; }; - // A node graph describing a material's surface. Compiles to an HLSL body that - // fills a surface struct, plugged into a template pixel shader. + /** + * @brief Stores a node graph that defines material surface properties. + * + * The graph generates an HLSL body that writes values to a material surface + * structure in a shader template. + */ class ELIXIR_API MaterialGraph { public: + /** + * @brief Adds a node to the graph. + * @param node Node data to add. + * @return The unique ID assigned to the added node. + * + * The graph assigns Id and does not use the ID supplied in node. + */ uint32_t AddNode(const SMaterialNode& node); - // Wire the output of 'fromNode' into input 'toSlot' of 'toNode'. + /** + * @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 toNode does not exist. + */ void Connect(uint32_t fromNode, uint32_t toNode, uint32_t toSlot); - // Drive a surface channel from a node's output. + /** + * @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); - // Generate the HLSL statements that fill 'surface. = ...;'. + /** + * @brief Generates HLSL statements for the configured surface channels. + * @return Generated HLSL that uses default material parameter expressions. + */ std::string GenerateHLSL() const; - // Generate the HLSL statements that fill 'surface. = ...;'. + /** + * @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; + /** @brief Gets the graph nodes indexed by their assigned IDs. */ const std::unordered_map& GetNodes() const { return m_Nodes; } private: + /** @brief Emits HLSL for a node and its dependencies. */ std::string EmitNode( uint32_t id, std::unordered_map& emitted, @@ -101,7 +230,7 @@ namespace Elixir ) const; std::unordered_map m_Nodes; - std::unordered_map m_Channels; // EMaterialChannel -> node id + std::unordered_map m_Channels; uint32_t m_NextId = 1; }; } From fbf24b8d2124cced737f75f8320b16d23145de27 Mon Sep 17 00:00:00 2001 From: MrChampz Date: Mon, 31 Aug 2026 00:07:19 -0300 Subject: [PATCH 71/89] Refactor material graph nodes --- Elixir/Source/Engine/Material/Material.cpp | 45 +- Elixir/Source/Engine/Material/Material.h | 20 +- .../Engine/Material/MaterialCompiler.cpp | 8 +- .../Source/Engine/Material/MaterialCompiler.h | 2 +- .../Source/Engine/Material/MaterialGraph.cpp | 392 ++++-------------- Elixir/Source/Engine/Material/MaterialGraph.h | 196 ++------- .../Source/Engine/Material/MaterialNode.cpp | 111 +++++ Elixir/Source/Engine/Material/MaterialNode.h | 122 ++++++ .../Source/Engine/Material/Nodes/TimeNode.h | 28 ++ 9 files changed, 435 insertions(+), 489 deletions(-) create mode 100644 Elixir/Source/Engine/Material/MaterialNode.cpp create mode 100644 Elixir/Source/Engine/Material/MaterialNode.h create mode 100644 Elixir/Source/Engine/Material/Nodes/TimeNode.h diff --git a/Elixir/Source/Engine/Material/Material.cpp b/Elixir/Source/Engine/Material/Material.cpp index 12b69d10..7dc846ab 100644 --- a/Elixir/Source/Engine/Material/Material.cpp +++ b/Elixir/Source/Engine/Material/Material.cpp @@ -84,37 +84,32 @@ namespace Elixir bool Material::ValidateGraph(std::string* error) const { - for (const auto& [_, node] : m_Graph.GetNodes()) + class ParameterLookup final : public MaterialNodeValidationContext { - if (node.Type == EMaterialNodeType::Parameter) - { - const auto* parameter = FindParameter(node.ParameterName); - if (parameter && - parameter->Kind == EMaterialParameterKind::Value && - parameter->ValueType == node.OutputType) - continue; - - if (error) - *error = "Invalid value parameter: " + node.ParameterName; + public: + explicit ParameterLookup(const Material& material) : m_Material(material) {} - return false; + 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; } - if (node.Type == EMaterialNodeType::TextureSample) + bool HasTextureParameter(const std::string_view name) const override { - const auto* parameter = FindParameter(node.TextureParameterName); - if (parameter && - parameter->Kind == EMaterialParameterKind::Texture) - continue; - - if (error) - *error = "Invalid texture parameter: " + node.TextureParameterName; - - return false; + const auto* parameter = m_Material.FindParameter(std::string(name)); + return parameter && parameter->Kind == EMaterialParameterKind::Texture; } - } - return true; + private: + const Material& m_Material; + }; + + return m_Graph.Validate(ParameterLookup(*this), error); } bool Material::IsValueCompatible( @@ -125,7 +120,7 @@ namespace Elixir if (definition.Kind == EMaterialParameterKind::Texture) return value.Type == EMaterialParameterType::Texture; - if (definition.ValueType == EMaterialGraphValueType::Float) + if (definition.ValueType == EMaterialValueType::Float) return value.Type == EMaterialParameterType::Scalar; return value.Type == EMaterialParameterType::Vector; diff --git a/Elixir/Source/Engine/Material/Material.h b/Elixir/Source/Engine/Material/Material.h index 1de3753e..149a2013 100644 --- a/Elixir/Source/Engine/Material/Material.h +++ b/Elixir/Source/Engine/Material/Material.h @@ -114,6 +114,24 @@ namespace Elixir } }; + /** + * @brief Defines the HLSL value type carried by a material graph connection. + */ + enum class EMaterialValueType : uint8_t + { + /** @brief One floating-point component. */ + Float, + + /** @brief Two floating-point components. */ + Float2, + + /** @brief Three floating-point components. */ + Float3, + + /** @brief Four floating-point components. */ + Float4, + }; + /** * @brief Defines one parameter in a material schema. */ @@ -123,7 +141,7 @@ namespace Elixir EMaterialParameterKind Kind = EMaterialParameterKind::Value; /** Expected type for value parameters. */ - EMaterialGraphValueType ValueType = EMaterialGraphValueType::Float4; + EMaterialValueType ValueType = EMaterialValueType::Float4; /** Value used when an instance does not provide an override. */ SMaterialParam DefaultValue; diff --git a/Elixir/Source/Engine/Material/MaterialCompiler.cpp b/Elixir/Source/Engine/Material/MaterialCompiler.cpp index d738ede1..44537aa8 100644 --- a/Elixir/Source/Engine/Material/MaterialCompiler.cpp +++ b/Elixir/Source/Engine/Material/MaterialCompiler.cpp @@ -41,10 +41,10 @@ namespace Elixir switch (parameter.ValueType) { - case EMaterialGraphValueType::Float: return value + ".x"; - case EMaterialGraphValueType::Float2: return value + ".xy"; - case EMaterialGraphValueType::Float3: return value + ".xyz"; - case EMaterialGraphValueType::Float4: return value; + 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; diff --git a/Elixir/Source/Engine/Material/MaterialCompiler.h b/Elixir/Source/Engine/Material/MaterialCompiler.h index 4ee3e6a8..7f5cf9a6 100644 --- a/Elixir/Source/Engine/Material/MaterialCompiler.h +++ b/Elixir/Source/Engine/Material/MaterialCompiler.h @@ -17,7 +17,7 @@ namespace Elixir EMaterialParameterKind Kind = EMaterialParameterKind::Value; /** @brief Value type used when Kind is Value. */ - EMaterialGraphValueType ValueType = EMaterialGraphValueType::Float4; + EMaterialValueType ValueType = EMaterialValueType::Float4; /** @brief Slot in the compiled value or texture array. */ uint32_t Slot = 0; diff --git a/Elixir/Source/Engine/Material/MaterialGraph.cpp b/Elixir/Source/Engine/Material/MaterialGraph.cpp index 55729959..125ac394 100644 --- a/Elixir/Source/Engine/Material/MaterialGraph.cpp +++ b/Elixir/Source/Engine/Material/MaterialGraph.cpp @@ -1,23 +1,12 @@ #include "epch.h" #include "MaterialGraph.h" +#include + namespace Elixir { namespace { - const char* TypeName(const EMaterialGraphValueType type) - { - switch (type) - { - case EMaterialGraphValueType::Float: return "float"; - case EMaterialGraphValueType::Float2: return "float2"; - case EMaterialGraphValueType::Float3: return "float3"; - case EMaterialGraphValueType::Float4: return "float4"; - } - - return "float4"; - } - const char* ChannelName(const EMaterialChannel channel) { switch (channel) @@ -33,124 +22,39 @@ namespace Elixir return "BaseColor"; } - std::string Num(const float value) - { - std::string str = std::to_string(value); - return str; - } - - int Components(EMaterialGraphValueType type) - { - switch (type) - { - case EMaterialGraphValueType::Float: return 1; - case EMaterialGraphValueType::Float2: return 2; - case EMaterialGraphValueType::Float3: return 3; - case EMaterialGraphValueType::Float4: return 4; - } - - return 4; - } - - // The wider of two value types (more components wins). Use to pick a common - // type for component-wise ops so mismatched pin widths still compile. - EMaterialGraphValueType Wider( - const EMaterialGraphValueType a, - const EMaterialGraphValueType b - ) - { - return Components(a) >= Components(b) ? a : b; - } - - // Coerce an expression from one value type to a wider (or equal) one: scalars - // splat across all lanes; shorter vectors pad. Keeps generated HLSL well-typed - // regardless of how the user wired the graph. - std::string Widen( - const std::string& expr, - const EMaterialGraphValueType from, - const EMaterialGraphValueType to - ) - { - if (from == to) - return expr; - - if (from == EMaterialGraphValueType::Float) - { - const char* s = to == EMaterialGraphValueType::Float2 - ? ".xx" - : to == EMaterialGraphValueType::Float3 - ? ".xxx" - : ".xxxx"; - return "(" + expr + ")" + s; - } - - if (from == EMaterialGraphValueType::Float2 && to == EMaterialGraphValueType::Float3) - return "float3(" + expr + ", 0.0)"; - if (from == EMaterialGraphValueType::Float2 && to == EMaterialGraphValueType::Float4) - return "float4(" + expr + ", 0.0, 0.0)"; - if (from == EMaterialGraphValueType::Float3 && to == EMaterialGraphValueType::Float4) - return "float4(" + expr + ", 1.0)"; - - // Narrowing (only if a wider value flows into a narrower slot): swizzle down. - if (to == EMaterialGraphValueType::Float) return "(" + expr + ").x"; - if (to == EMaterialGraphValueType::Float2) return "(" + expr + ").xy"; - if (to == EMaterialGraphValueType::Float3) return "(" + expr + ").xyz"; - - return expr; - } - - // Coerce an expression of 'from' type to the channel's expected type. - std::string Coerce( - const std::string& expr, - const EMaterialGraphValueType from, + std::string CoerceForChannel( + const SMaterialExpression& expression, const EMaterialChannel channel ) { - const bool scalarChannel = channel == EMaterialChannel::Metallic || + const bool isScalar = channel == EMaterialChannel::Metallic || channel == EMaterialChannel::Roughness || channel == EMaterialChannel::Opacity; - if (scalarChannel) - return from == EMaterialGraphValueType::Float ? expr : "(" + expr + ").x"; - - // float3 channel (BaseColor/Emissive/Normal). - switch (from) - { - case EMaterialGraphValueType::Float: return expr + ".xxx"; - case EMaterialGraphValueType::Float2: return "float3(" + expr + ", 0.0)"; - case EMaterialGraphValueType::Float3: return expr; - case EMaterialGraphValueType::Float4: return "(" + expr + ").rgb"; - } - - return expr; - } - - std::string ConstantExpr(const SMaterialNode& node) - { - const glm::vec4& v = node.ConstantValue; + if (isScalar) + return expression.ValueType == EMaterialValueType::Float + ? expression.Code + : "(" + expression.Code + ").x"; - switch (node.OutputType) + switch (expression.ValueType) { - case EMaterialGraphValueType::Float: - return Num(v.x); - case EMaterialGraphValueType::Float2: - return "float2(" + Num(v.x) + ", " + Num(v.y) + ")"; - case EMaterialGraphValueType::Float3: - return "float3(" + Num(v.x) + ", " + Num(v.y) + ", " + Num(v.z) + ")"; - case EMaterialGraphValueType::Float4: - return "float4(" + Num(v.x) + ", " + Num(v.y) + ", " + Num(v.z) + ", " + Num(v.w) + ")"; + 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 "0.0"; + return expression.Code; } } - uint32_t MaterialGraph::AddNode(const SMaterialNode& node) + uint32_t MaterialGraph::AddNode(Scope node) { - SMaterialNode copy = node; - copy.Id = m_NextId++; - m_Nodes[copy.Id] = copy; - return copy.Id; + if (!node) return 0; + + const uint32_t id = m_NextId++; + m_Nodes.emplace(id, SGraphNode{ .Node = std::move(node) }); + + return id; } void MaterialGraph::Connect( @@ -160,7 +64,8 @@ namespace Elixir ) { const auto it = m_Nodes.find(toNode); - if (it == m_Nodes.end()) + + if (it == m_Nodes.end() || toSlot >= it->second.Node->GetInputs().size()) return; if (it->second.Inputs.size() <= toSlot) @@ -174,6 +79,28 @@ namespace Elixir 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({}); @@ -182,27 +109,29 @@ namespace Elixir std::string MaterialGraph::GenerateHLSL(const SMaterialGraphBindings& bindings) const { std::string body; - std::unordered_map emitted; - std::unordered_map types; + std::unordered_map emitted; + std::unordered_set visiting; for (const auto& [channel, nodeId] : m_Channels) { - const std::string var = EmitNode(nodeId, emitted, types, body, &bindings); - const EMaterialGraphValueType from = types.contains(nodeId) - ? types[nodeId] - : EMaterialGraphValueType::Float4; - - const auto channelName = ChannelName(channel); - body += " surface." + std::string(channelName) + " = " + Coerce(var, from, channel) + ";\n"; + const auto expression = EmitNode(nodeId, emitted, visiting, body, &bindings); + body += " surface." + std::string(ChannelName(channel)) + " = " + + CoerceForChannel(expression, channel) + ";\n"; } return body; } - std::string MaterialGraph::EmitNode( + 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_map& types, + std::unordered_map& emitted, + std::unordered_set& visiting, std::string& body, const SMaterialGraphBindings* bindings ) const @@ -211,184 +140,45 @@ namespace Elixir return it->second; const auto it = m_Nodes.find(id); - if (it == m_Nodes.end()) - { - types[id] = EMaterialGraphValueType::Float4; - return "0.0"; - } + if (it == m_Nodes.end() || visiting.contains(id)) + return { .Code = "0.0", .ValueType = EMaterialValueType::Float }; - const SMaterialNode& node = it->second; + visiting.insert(id); + const auto& node = it->second; + const auto& definitions = node.Node->GetInputs(); + std::vector inputs; + inputs.reserve(definitions.size()); - // Resolve each input to a variable name (recursing) or a default literal, - // and remember the value type flowing out of each so ops can pick a common width. - std::vector in; - std::vector inTypes; - for (size_t i = 0; i < node.Inputs.size(); ++i) + for (size_t slot = 0; slot < definitions.size(); ++slot) { - const auto& input = node.Inputs[i]; - - if (input >= 0) - { - in.push_back(EmitNode((uint32_t)input, emitted, types, body, bindings)); - inTypes.push_back(types[(uint32_t)input]); - } - else if (i < node.DefaultInputs.size()) - { - in.push_back(node.DefaultInputs[i]); - inTypes.push_back(EMaterialGraphValueType::Float); - } + 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 - { - in.emplace_back("0.0"); - inTypes.push_back(EMaterialGraphValueType::Float); - } + inputs.push_back({ + .Code = definitions[slot].DefaultExpression, + .ValueType = definitions[slot].DefaultValueType + }); } - auto A = [&](size_t i) { return i < in.size() ? in[i] : std::string("0.0"); }; - auto AT = [&](size_t i) { return i < inTypes.size() ? inTypes[i] : EMaterialGraphValueType::Float; }; - - std::string expr; - EMaterialGraphValueType type = node.OutputType; + const MaterialEmitContext context(inputs, bindings); + SMaterialExpression expression = node.Node->Emit(context); - // Component-wise binary op: coerce both operands to their common width. - auto binOp = [&](const char* op) - { - const EMaterialGraphValueType to = Wider(AT(0), AT(1)); - expr = "(" + Widen(A(0), AT(0), to) + " " + op + " " + Widen(A(1), AT(1), to) + ")"; - type = to; - }; - - switch (node.Type) - { - case EMaterialNodeType::Constant: - expr = ConstantExpr(node); - type = node.OutputType; - break; - case EMaterialNodeType::Parameter: - expr = bindings && bindings->Values.contains(node.ParameterName) - ? bindings->Values.at(node.ParameterName) - : "mat." + node.ParameterName; - type = node.OutputType; - break; - case EMaterialNodeType::TexCoord: - expr = "input.TexCoord"; - type = EMaterialGraphValueType::Float2; - break; - case EMaterialNodeType::TextureSample: - { - const std::string idx = bindings && bindings->Textures.contains(node.TextureParameterName) - ? bindings->Textures.at(node.TextureParameterName) - : "mat." + node.TextureParameterName + ".x"; - const std::string uv = node.Inputs.empty() || node.Inputs[0] < 0 - ? "input.TexCoord" - : Widen(A(0), AT(0), EMaterialGraphValueType::Float2); - expr = "(" + idx + " == 0xFFFFFFFFu ? float4(1.0, 1.0, 1.0, 1.0) : SampleTex(" + idx + ", " + uv + "))"; - type = EMaterialGraphValueType::Float4; - break; - } - case EMaterialNodeType::ComponentMask: - { - static constexpr std::array components{ ".x", ".y", ".z", ".w" }; - const auto component = std::min(node.ComponentIndex, uint32_t(components.size() - 1)); - expr = "(" + A(0) + ")" + components[component]; - type = EMaterialGraphValueType::Float; - break; - } - case EMaterialNodeType::Time: - expr = "Time"; - type = EMaterialGraphValueType::Float; - break; - case EMaterialNodeType::Sine: - expr = "sin(" + A(0) + ")"; - type = AT(0); - break; - case EMaterialNodeType::Panner: - { - const std::string uv = node.Inputs.empty() || node.Inputs[0] < 0 - ? "input.TexCoord" - : Widen(A(0), AT(0), EMaterialGraphValueType::Float2); - const std::string speed = "float2(" + Num(node.ConstantValue.x) + ", " + Num(node.ConstantValue.y) + ")"; - expr = "(" + uv + " + Time * " + speed + ")"; - type = EMaterialGraphValueType::Float2; - break; - } - case EMaterialNodeType::Checkerboard: - { - const std::string uv = node.Inputs.empty() || node.Inputs[0] < 0 - ? "input.TexCoord" - : Widen(A(0), AT(0), EMaterialGraphValueType::Float2); - const std::string scale = Num(std::max(node.ConstantValue.x, 1.0f)); - expr = "(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))"; - type = EMaterialGraphValueType::Float3; - break; - } - case EMaterialNodeType::RadialGradientExponential: - { - const std::string uv = node.Inputs.empty() || node.Inputs[0] < 0 - ? "input.TexCoord" - : Widen(A(0), AT(0), EMaterialGraphValueType::Float2); - - const std::string center = "float2(" + Num(node.RadialGradientCenter.x) + ", " + Num(node.RadialGradientCenter.y) + ")"; - const std::string radius = Num(std::max(node.RadialGradientRadius, 0.0001f)); - const std::string exponent = Num(std::max(node.RadialGradientExponent, 0.0001f)); - - expr = "pow(saturate(1.0 - length((" + uv + " - " + center + ") / " + radius + ")), " + exponent + ")"; - type = EMaterialGraphValueType::Float; + const std::string variable = "n" + std::to_string(id); + body += " " + std::string(MaterialEmitContext::TypeName(expression.ValueType)) + + " " + variable + " = " + expression.Code + ";\n"; + expression.Code = variable; - break; - } - case EMaterialNodeType::Multiply: - binOp("*"); - break; - case EMaterialNodeType::Add: - binOp("+"); - break; - case EMaterialNodeType::Subtract: - binOp("-"); - break; - case EMaterialNodeType::Divide: - binOp("/"); - break; - case EMaterialNodeType::Power: - { - const EMaterialGraphValueType to = Wider(AT(0), AT(1)); - expr = "pow(" + Widen(A(0), AT(0), to) + ", " + Widen(A(1), AT(1), to) + ")"; - type = to; - break; - } - case EMaterialNodeType::Dot: - { - const EMaterialGraphValueType to = Wider(AT(0), AT(1)); - expr = "dot(" + Widen(A(0), AT(0), to) + ", " + Widen(A(1), AT(1), to) + ")"; - type = EMaterialGraphValueType::Float; - break; - } - case EMaterialNodeType::Lerp: - { - const EMaterialGraphValueType to = Wider(AT(0), AT(1)); - expr = "lerp(" + Widen(A(0), AT(0), to) + ", " + Widen(A(1), AT(1), to) + ", " - + Widen(A(2), AT(2), to) + ")"; - type = to; - break; - } - case EMaterialNodeType::OneMinus: - expr = "(1.0 - " + in[0] + ")"; - type = AT(0); - break; - case EMaterialNodeType::Saturate: - expr = "saturate(" + in[0] + ")"; - type = AT(0); - break; - case EMaterialNodeType::Fresnel: - expr = "pow(saturate(1.0 - dot(N, V)), 5.0)"; - type = EMaterialGraphValueType::Float; - break; - } + visiting.erase(id); + emitted.emplace(id, expression); - const std::string var = "n" + std::to_string(id); - body += " " + std::string(TypeName(type)) + " " + var + " = " + expr + ";\n"; - emitted[id] = var; - types[id] = type; - return var; + return expression; } } diff --git a/Elixir/Source/Engine/Material/MaterialGraph.h b/Elixir/Source/Engine/Material/MaterialGraph.h index 01a79502..ce7bd5ef 100644 --- a/Elixir/Source/Engine/Material/MaterialGraph.h +++ b/Elixir/Source/Engine/Material/MaterialGraph.h @@ -1,91 +1,9 @@ #pragma once +#include + namespace Elixir { - /** - * @brief Defines the HLSL value type produced by a graph node. - */ - enum class EMaterialGraphValueType : uint8_t - { - /** @brief One floating-point component. */ - Float, - - /** @brief Two floating-point components. */ - Float2, - - /** @brief Three floating-point components. */ - Float3, - - /** @brief Four floating-point components. */ - Float4, - }; - - /** - * @brief Defines the operation performed by a material graph node. - */ - enum class EMaterialNodeType : uint8_t - { - /** @brief Outputs a literal value. */ - Constant, - - /** @brief Reads a named material value parameter. */ - Parameter, // mat. - - /** @brief Outputs the input texture coordinates. */ - TexCoord, // input.TexCoord - - /** @brief Samples a named material texture parameter. */ - TextureSample, // sample a bound texture at a UV (input 0) - - /** @brief Selects one component from an input value. */ - ComponentMask, - - /** @brief Outputs elapsed time in seconds. */ - Time, // cbFrame.Time - - /** @brief Applies the sine function to an input. */ - Sine, // sin(a) - - /** @brief Offsets texture coordinates over time. */ - Panner, // uv + Time * speed (speed from ConstantValue.xy) - - /** @brief Generates a procedural checkerboard. */ - Checkerboard, - - /** @brief Generates an exponential radial gradient. */ - RadialGradientExponential, // pow(saturate(1 - distance / radius), exponent) - - /** @brief Multiplies two input values. */ - Multiply, // a * b - - /** @brief Adds two input values. */ - Add, // a + b - - /** @brief Subtracts two input values. */ - Subtract, // a - b - - /** @brief Divides two input values. */ - Divide, // a / b - - /** @brief Raises one input value to another. */ - Power, // pow(a, b) - - /** @brief Calculates the dot product of two input values. */ - Dot, // dot(a, b) -> scalar - - /** @brief Linearly interpolates between two input values. */ - Lerp, // lerp(a, b, t) - - /** @brief Subtracts an input value from one. */ - OneMinus, // 1 - a - - /** @brief Clamps an input value to the zero-to-one range. */ - Saturate, // saturate(a) - - /** @brief Calculates a Schlick Fresnel factor. */ - Fresnel, - }; - /** * @brief Defines a surface property driven by a material graph. */ @@ -110,81 +28,38 @@ namespace Elixir Emissive, }; - /** - * @brief Maps material parameter names to generated HLSL expressions. - */ + /** @brief Maps material parameter names to generated HLSL expressions. */ struct SMaterialGraphBindings { - /** @brief Expressions for numeric material parameters. */ std::unordered_map Values; - - /** @brief Expressions for texture material parameters. */ std::unordered_map Textures; }; - /** - * @brief Stores the data and input connections for one material graph node. - */ - struct SMaterialNode - { - /** @brief Unique graph identifier assigned when the node is added. */ - uint32_t Id = 0; - - /** @brief Operation performed by the node. */ - EMaterialNodeType Type = EMaterialNodeType::Constant; - - /** @brief Value type produced by the node. */ - EMaterialGraphValueType OutputType = EMaterialGraphValueType::Float4; - - /** - * @brief Source node IDs for each input slot. - * - * A value of -1 selects the matching entry in DefaultInputs. - */ - std::vector Inputs; - - /** @brief Literal expressions used by unconnected input slots. */ - std::vector DefaultInputs; - - /** @brief Constant value and type-specific numeric settings. */ - glm::vec4 ConstantValue{ 0.0f }; - - /** @brief Material value parameter read by a Parameter node. */ - std::string ParameterName; - - /** @brief Material texture parameter sampled by a TextureSample node. */ - std::string TextureParameterName; - - /** @brief Component selected by a ComponentMask node. */ - uint32_t ComponentIndex = 0; - - /** @brief Center used by a RadialGradientExponential node. */ - glm::vec2 RadialGradientCenter{ 0.5f }; - - /** @brief Radius used by a RadialGradientExponential node. */ - float RadialGradientRadius = 0.5f; - - /** @brief Exponent used by a RadialGradientExponential node. */ - float RadialGradientExponent = 1.0f; - }; - /** * @brief Stores a node graph that defines material surface properties. * - * The graph generates an HLSL body that writes values to a material surface - * structure in a shader template. + * 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: - /** - * @brief Adds a node to the graph. - * @param node Node data to add. - * @return The unique ID assigned to the added node. - * - * The graph assigns Id and does not use the ID supplied in node. - */ - uint32_t AddNode(const SMaterialNode& node); + 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. @@ -192,7 +67,7 @@ namespace Elixir * @param toNode Destination node ID. * @param toSlot Destination input slot. * - * The call has no effect when toNode does not exist. + * 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); @@ -203,10 +78,12 @@ namespace Elixir */ void SetChannel(EMaterialChannel channel, uint32_t nodeId); - /** - * @brief Generates HLSL statements for the configured surface channels. - * @return Generated HLSL that uses default material parameter expressions. - */ + bool Validate( + const MaterialNodeValidationContext& parameters, + std::string* error = nullptr + ) const; + + /** @brief Generates HLSL statements for the configured surface channels. */ std::string GenerateHLSL() const; /** @@ -216,20 +93,25 @@ namespace Elixir */ std::string GenerateHLSL(const SMaterialGraphBindings& bindings) const; - /** @brief Gets the graph nodes indexed by their assigned IDs. */ - const std::unordered_map& GetNodes() const { return m_Nodes; } + const MaterialNode* FindNode(uint32_t id) const; private: /** @brief Emits HLSL for a node and its dependencies. */ - std::string EmitNode( + SMaterialExpression EmitNode( uint32_t id, - std::unordered_map& emitted, - std::unordered_map& types, + std::unordered_map& emitted, + std::unordered_set& visiting, std::string& body, const SMaterialGraphBindings* bindings ) const; - std::unordered_map m_Nodes; + 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/Material/MaterialNode.cpp b/Elixir/Source/Engine/Material/MaterialNode.cpp new file mode 100644 index 00000000..9590d2a2 --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialNode.cpp @@ -0,0 +1,111 @@ +#include "epch.h" +#include "MaterialNode.h" + +#include +#include + +namespace Elixir +{ + 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/Material/MaterialNode.h b/Elixir/Source/Engine/Material/MaterialNode.h new file mode 100644 index 00000000..899206f9 --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialNode.h @@ -0,0 +1,122 @@ +#pragma once + +namespace Elixir +{ + 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 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 Gets the input slots defined by the node. */ + virtual const std::vector& GetInputs() const = 0; + + /** @brief Validates node-specific references against material parameters. */ + virtual bool Validate( + const MaterialNodeValidationContext& parameters, + std::string& error + ) const = 0; + + /** @brief Emits the HLSL expression represented by this node. */ + virtual SMaterialExpression Emit(const MaterialEmitContext& context) const = 0; + }; +} diff --git a/Elixir/Source/Engine/Material/Nodes/TimeNode.h b/Elixir/Source/Engine/Material/Nodes/TimeNode.h new file mode 100644 index 00000000..8d0ae852 --- /dev/null +++ b/Elixir/Source/Engine/Material/Nodes/TimeNode.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +#include +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Outputs elapsed time in seconds. + */ + class TimeNode : public MaterialNode + { + public: + std::string_view GetTypeName() const override { return "material.time"; } + const std::vector& GetInputs() const override { return m_Inputs; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + return { .Code = "Time", .ValueType = EMaterialValueType::Float }; + } + + private: + std::vector m_Inputs; + }; +} \ No newline at end of file From ba5f0f00caa63e6e2101241d56ca727bd4a93a96 Mon Sep 17 00:00:00 2001 From: MrChampz Date: Tue, 1 Sep 2026 20:34:24 -0300 Subject: [PATCH 72/89] Complete material graph node refactor --- .../Engine/Aether/Effect/MaterialFactory.cpp | 67 +++++++------ .../Engine/Material/DefaultMaterials.cpp | 40 ++++---- Elixir/Source/Engine/Material/Material.h | 14 +-- .../Source/Engine/Material/MaterialNode.cpp | 3 +- Elixir/Source/Engine/Material/MaterialNode.h | 5 +- Elixir/Source/Engine/Material/Nodes/Add.h | 26 +++++ .../Material/Nodes/BinaryOperationNode.h | 37 +++++++ .../Engine/Material/Nodes/Checkerboard.h | 50 ++++++++++ .../Engine/Material/Nodes/ComponentMask.h | 47 +++++++++ .../Source/Engine/Material/Nodes/Constant.h | 63 ++++++++++++ Elixir/Source/Engine/Material/Nodes/Divide.h | 26 +++++ Elixir/Source/Engine/Material/Nodes/Dot.h | 26 +++++ Elixir/Source/Engine/Material/Nodes/Fresnel.h | 31 ++++++ Elixir/Source/Engine/Material/Nodes/Lerp.h | 44 +++++++++ .../Source/Engine/Material/Nodes/Multiply.h | 26 +++++ .../Source/Engine/Material/Nodes/OneMinus.h | 24 +++++ Elixir/Source/Engine/Material/Nodes/Panner.h | 44 +++++++++ .../Source/Engine/Material/Nodes/Parameter.h | 52 ++++++++++ Elixir/Source/Engine/Material/Nodes/Power.h | 26 +++++ .../Nodes/RadialGradientExponential.h | 59 +++++++++++ .../Source/Engine/Material/Nodes/Saturate.h | 24 +++++ Elixir/Source/Engine/Material/Nodes/Sine.h | 24 +++++ .../Source/Engine/Material/Nodes/Subtract.h | 26 +++++ .../Source/Engine/Material/Nodes/TexCoord.h | 31 ++++++ .../Engine/Material/Nodes/TextureSample.h | 51 ++++++++++ .../Material/Nodes/{TimeNode.h => Time.h} | 4 +- .../Material/Nodes/UnaryOperationNode.h | 30 ++++++ Elixir/Tests/Engine/Aether/SystemTest.cpp | 2 +- .../Engine/Material/MaterialCompilerTest.cpp | 2 +- .../Material/MaterialFrameTableTest.cpp | 2 +- .../Engine/Material/MaterialGraphTest.cpp | 97 ++++++++----------- .../Material/MaterialRenderProxyTest.cpp | 4 +- Elixir/Tests/Engine/Material/MaterialTest.cpp | 25 ++--- 33 files changed, 884 insertions(+), 148 deletions(-) create mode 100644 Elixir/Source/Engine/Material/Nodes/Add.h create mode 100644 Elixir/Source/Engine/Material/Nodes/BinaryOperationNode.h create mode 100644 Elixir/Source/Engine/Material/Nodes/Checkerboard.h create mode 100644 Elixir/Source/Engine/Material/Nodes/ComponentMask.h create mode 100644 Elixir/Source/Engine/Material/Nodes/Constant.h create mode 100644 Elixir/Source/Engine/Material/Nodes/Divide.h create mode 100644 Elixir/Source/Engine/Material/Nodes/Dot.h create mode 100644 Elixir/Source/Engine/Material/Nodes/Fresnel.h create mode 100644 Elixir/Source/Engine/Material/Nodes/Lerp.h create mode 100644 Elixir/Source/Engine/Material/Nodes/Multiply.h create mode 100644 Elixir/Source/Engine/Material/Nodes/OneMinus.h create mode 100644 Elixir/Source/Engine/Material/Nodes/Panner.h create mode 100644 Elixir/Source/Engine/Material/Nodes/Parameter.h create mode 100644 Elixir/Source/Engine/Material/Nodes/Power.h create mode 100644 Elixir/Source/Engine/Material/Nodes/RadialGradientExponential.h create mode 100644 Elixir/Source/Engine/Material/Nodes/Saturate.h create mode 100644 Elixir/Source/Engine/Material/Nodes/Sine.h create mode 100644 Elixir/Source/Engine/Material/Nodes/Subtract.h create mode 100644 Elixir/Source/Engine/Material/Nodes/TexCoord.h create mode 100644 Elixir/Source/Engine/Material/Nodes/TextureSample.h rename Elixir/Source/Engine/Material/Nodes/{TimeNode.h => Time.h} (91%) create mode 100644 Elixir/Source/Engine/Material/Nodes/UnaryOperationNode.h diff --git a/Elixir/Source/Engine/Aether/Effect/MaterialFactory.cpp b/Elixir/Source/Engine/Aether/Effect/MaterialFactory.cpp index 1742f17e..468ed005 100644 --- a/Elixir/Source/Engine/Aether/Effect/MaterialFactory.cpp +++ b/Elixir/Source/Engine/Aether/Effect/MaterialFactory.cpp @@ -3,8 +3,18 @@ #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) @@ -30,28 +40,26 @@ namespace Elixir::Aether::Effect MaterialGraph graph; - const auto baseColor = graph.AddNode({ - .Type = EMaterialNodeType::Constant, - .OutputType = EMaterialGraphValueType::Float3, - .ConstantValue = { desc.BaseColor, 0.0f }, - }); + const auto baseColor = graph.AddNode( + glm::vec4{ desc.BaseColor, 0.0f }, + EMaterialValueType::Float3 + ); graph.SetChannel(EMaterialChannel::BaseColor, baseColor); - const auto opacity = graph.AddNode({ - .Type = EMaterialNodeType::Constant, - .OutputType = EMaterialGraphValueType::Float, - .ConstantValue = { desc.Opacity, 0.0f, 0.0f, 0.0f }, - }); + 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({ - .Type = EMaterialNodeType::Constant, - .OutputType = EMaterialGraphValueType::Float3, - .ConstantValue = { desc.Emissive, 0.0f }, - }); + 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()) + if (renderMode == Core::EParticleRenderMode::Sprite && + !desc.BaseColorTexturePath.empty()) { constexpr auto texParam = "BaseColorTexture"; const auto tex = TextureLoader::Load(desc.BaseColorTexturePath); @@ -60,27 +68,18 @@ namespace Elixir::Aether::Effect .DefaultValue = SMaterialParam::MakeTexture(tex), }); - const auto texture = graph.AddNode({ - .Type = EMaterialNodeType::TextureSample, - .TextureParameterName = texParam, - }); + const auto texture = graph.AddNode(texParam); + const auto alpha = graph.AddNode(3); + graph.Connect(texture, alpha, 0); - const auto alpha = graph.AddNode({ - .Type = EMaterialNodeType::ComponentMask, - .Inputs = { int32_t(texture) }, - .ComponentIndex = 3 - }); - - const auto baseColorMul = graph.AddNode({ - .Type = EMaterialNodeType::Multiply, - .Inputs = { int32_t(baseColor), int32_t(texture) }, - }); + 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({ - .Type = EMaterialNodeType::Multiply, - .Inputs = { int32_t(opacity), int32_t(alpha) }, - }); + const auto opacityMul = graph.AddNode(); + graph.Connect(opacity, opacityMul, 0); + graph.Connect(alpha, opacityMul, 1); graph.SetChannel(EMaterialChannel::Opacity, opacityMul); } diff --git a/Elixir/Source/Engine/Material/DefaultMaterials.cpp b/Elixir/Source/Engine/Material/DefaultMaterials.cpp index d5f27d91..0e215d65 100644 --- a/Elixir/Source/Engine/Material/DefaultMaterials.cpp +++ b/Elixir/Source/Engine/Material/DefaultMaterials.cpp @@ -1,8 +1,14 @@ #include "epch.h" #include "DefaultMaterials.h" +#include +#include +#include + namespace Elixir { + using namespace Materials::Nodes; + namespace { Ref MakeMaterial( @@ -22,19 +28,17 @@ namespace Elixir { MaterialGraph graph; - const auto baseColor = graph.AddNode({ - .Type = EMaterialNodeType::Constant, - .OutputType = EMaterialGraphValueType::Float3, - .ConstantValue = { 1.0f, 1.0f, 1.0f, 0.0f }, - }); - const auto opacity = graph.AddNode({ - .Type = EMaterialNodeType::RadialGradientExponential, - .OutputType = EMaterialGraphValueType::Float, - .RadialGradientCenter = { 0.5f, 0.5f }, - .RadialGradientRadius = 0.5f, - .RadialGradientExponent = 2.0f, - }); + 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( @@ -48,11 +52,7 @@ namespace Elixir { MaterialGraph graph; - const auto checkerboard = graph.AddNode({ - .Type = EMaterialNodeType::Constant, - .OutputType = EMaterialGraphValueType::Float3, - .ConstantValue = { 1.0f, 1.0f, 1.0f, 0.0f }, - }); + const auto checkerboard = graph.AddNode(8.0f); graph.SetChannel(EMaterialChannel::BaseColor, checkerboard); return MakeMaterial( @@ -66,11 +66,7 @@ namespace Elixir { MaterialGraph graph; - const auto checkerboard = graph.AddNode({ - .Type = EMaterialNodeType::Checkerboard, - .OutputType = EMaterialGraphValueType::Float3, - .ConstantValue = { 8.0f, 0.0f, 0.0f, 0.0f }, - }); + const auto checkerboard = graph.AddNode(8.0f); graph.SetChannel(EMaterialChannel::BaseColor, checkerboard); return MakeMaterial( diff --git a/Elixir/Source/Engine/Material/Material.h b/Elixir/Source/Engine/Material/Material.h index 149a2013..8cd22cfd 100644 --- a/Elixir/Source/Engine/Material/Material.h +++ b/Elixir/Source/Engine/Material/Material.h @@ -115,21 +115,11 @@ namespace Elixir }; /** - * @brief Defines the HLSL value type carried by a material graph connection. + * @brief Defines the numeric value type used by a material. */ enum class EMaterialValueType : uint8_t { - /** @brief One floating-point component. */ - Float, - - /** @brief Two floating-point components. */ - Float2, - - /** @brief Three floating-point components. */ - Float3, - - /** @brief Four floating-point components. */ - Float4, + Float, Float2, Float3, Float4, }; /** diff --git a/Elixir/Source/Engine/Material/MaterialNode.cpp b/Elixir/Source/Engine/Material/MaterialNode.cpp index 9590d2a2..2b55d6f4 100644 --- a/Elixir/Source/Engine/Material/MaterialNode.cpp +++ b/Elixir/Source/Engine/Material/MaterialNode.cpp @@ -107,5 +107,6 @@ namespace Elixir const std::vector& inputs, const SMaterialGraphBindings* bindings ) : m_Inputs(inputs), - m_Bindings(bindings) {} + m_Bindings(bindings) { + } } diff --git a/Elixir/Source/Engine/Material/MaterialNode.h b/Elixir/Source/Engine/Material/MaterialNode.h index 899206f9..beb6bebc 100644 --- a/Elixir/Source/Engine/Material/MaterialNode.h +++ b/Elixir/Source/Engine/Material/MaterialNode.h @@ -114,7 +114,10 @@ namespace Elixir virtual bool Validate( const MaterialNodeValidationContext& parameters, std::string& error - ) const = 0; + ) const + { + return true; + } /** @brief Emits the HLSL expression represented by this node. */ virtual SMaterialExpression Emit(const MaterialEmitContext& context) const = 0; diff --git a/Elixir/Source/Engine/Material/Nodes/Add.h b/Elixir/Source/Engine/Material/Nodes/Add.h new file mode 100644 index 00000000..fe385f80 --- /dev/null +++ b/Elixir/Source/Engine/Material/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/Material/Nodes/BinaryOperationNode.h b/Elixir/Source/Engine/Material/Nodes/BinaryOperationNode.h new file mode 100644 index 00000000..f859a76d --- /dev/null +++ b/Elixir/Source/Engine/Material/Nodes/BinaryOperationNode.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include + +#include +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Combines two values, widening operands to a shared width. + */ + class BinaryOperationNode : public MaterialNode + { + public: + const std::vector& GetInputs() const override { return m_Inputs; } + + protected: + BinaryOperationNode() + : m_Inputs{ + { "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 + ); + } + + private: + std::vector m_Inputs; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/Checkerboard.h b/Elixir/Source/Engine/Material/Nodes/Checkerboard.h new file mode 100644 index 00000000..f756d435 --- /dev/null +++ b/Elixir/Source/Engine/Material/Nodes/Checkerboard.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include + +#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) + : m_Scale(scale), + m_Inputs{{ + "UV", + EMaterialValueType::Float2, + "input.TexCoord", + EMaterialValueType::Float2 + }} {} + + std::string_view GetTypeName() const override { return "Material.Checkerboard"; } + const std::vector& GetInputs() const override { return m_Inputs; } + + 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; + std::vector m_Inputs; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/ComponentMask.h b/Elixir/Source/Engine/Material/Nodes/ComponentMask.h new file mode 100644 index 00000000..18e5689a --- /dev/null +++ b/Elixir/Source/Engine/Material/Nodes/ComponentMask.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include + +#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) + : m_ComponentIndex(componentIndex), + m_Inputs{{ + .Name = "Value", + .ValueType = EMaterialValueType::Float4, + .DefaultExpression = "float4(0.0, 0.0, 0.0, 0.0)", + .DefaultValueType = EMaterialValueType::Float4 + }} {} + + std::string_view GetTypeName() const override { return "Material.ComponentMask"; } + const std::vector& GetInputs() const override { return m_Inputs; } + + 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; + std::vector m_Inputs; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/Constant.h b/Elixir/Source/Engine/Material/Nodes/Constant.h new file mode 100644 index 00000000..0a5fcbcd --- /dev/null +++ b/Elixir/Source/Engine/Material/Nodes/Constant.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include + +#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"; } + const std::vector& GetInputs() const override { return m_Inputs; } + + 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; + std::vector m_Inputs; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/Divide.h b/Elixir/Source/Engine/Material/Nodes/Divide.h new file mode 100644 index 00000000..af29325c --- /dev/null +++ b/Elixir/Source/Engine/Material/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/Material/Nodes/Dot.h b/Elixir/Source/Engine/Material/Nodes/Dot.h new file mode 100644 index 00000000..978e98c4 --- /dev/null +++ b/Elixir/Source/Engine/Material/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/Material/Nodes/Fresnel.h b/Elixir/Source/Engine/Material/Nodes/Fresnel.h new file mode 100644 index 00000000..61876ea3 --- /dev/null +++ b/Elixir/Source/Engine/Material/Nodes/Fresnel.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +#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"; } + const std::vector& GetInputs() const override { return m_Inputs; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + return { + .Code = "pow(saturate(1.0 - dot(N, V)), 5.0)", + .ValueType = EMaterialValueType::Float + }; + } + + private: + std::vector m_Inputs; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/Lerp.h b/Elixir/Source/Engine/Material/Nodes/Lerp.h new file mode 100644 index 00000000..efdedaaf --- /dev/null +++ b/Elixir/Source/Engine/Material/Nodes/Lerp.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +#include +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Linearly interpolates between two values. + */ + class Lerp final : public MaterialNode + { + public: + Lerp() : m_Inputs{ + { "A", EMaterialValueType::Float4, "0.0" }, + { "B", EMaterialValueType::Float4, "0.0" }, + { "T", EMaterialValueType::Float4, "0.0" } + } {} + + std::string_view GetTypeName() const override { return "Material.Lerp"; } + const std::vector& GetInputs() const override { return m_Inputs; } + + 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 + }; + } + + private: + std::vector m_Inputs; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/Multiply.h b/Elixir/Source/Engine/Material/Nodes/Multiply.h new file mode 100644 index 00000000..758d2df8 --- /dev/null +++ b/Elixir/Source/Engine/Material/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/Material/Nodes/OneMinus.h b/Elixir/Source/Engine/Material/Nodes/OneMinus.h new file mode 100644 index 00000000..20669db2 --- /dev/null +++ b/Elixir/Source/Engine/Material/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/Material/Nodes/Panner.h b/Elixir/Source/Engine/Material/Nodes/Panner.h new file mode 100644 index 00000000..a4457011 --- /dev/null +++ b/Elixir/Source/Engine/Material/Nodes/Panner.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +#include +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Offsets texture coordinates over time. + */ + class Panner final : public MaterialNode + { + public: + explicit Panner(const glm::vec2& speed) + : m_Speed(speed), + m_Inputs{{ + "UV", + EMaterialValueType::Float2, + "input.TexCoord", + EMaterialValueType::Float2 + }} {} + + std::string_view GetTypeName() const override { return "Material.Panner"; } + const std::vector& GetInputs() const override { return m_Inputs; } + + 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; + std::vector m_Inputs; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/Parameter.h b/Elixir/Source/Engine/Material/Nodes/Parameter.h new file mode 100644 index 00000000..c9323cea --- /dev/null +++ b/Elixir/Source/Engine/Material/Nodes/Parameter.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +#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"; } + const std::vector& GetInputs() const override { return m_Inputs; } + + 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; + std::vector m_Inputs; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/Power.h b/Elixir/Source/Engine/Material/Nodes/Power.h new file mode 100644 index 00000000..e962aa73 --- /dev/null +++ b/Elixir/Source/Engine/Material/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/Material/Nodes/RadialGradientExponential.h b/Elixir/Source/Engine/Material/Nodes/RadialGradientExponential.h new file mode 100644 index 00000000..94cf0b99 --- /dev/null +++ b/Elixir/Source/Engine/Material/Nodes/RadialGradientExponential.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include + +#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 + ) : m_Center(center), + m_Radius(radius), + m_Exponent(exponent), + m_Inputs{{ + "UV", + EMaterialValueType::Float2, + "input.TexCoord", + EMaterialValueType::Float2 + }} {} + + std::string_view GetTypeName() const override { return "Material.RadialGradientExponential"; } + const std::vector& GetInputs() const override { return m_Inputs; } + + 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; + std::vector m_Inputs; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/Saturate.h b/Elixir/Source/Engine/Material/Nodes/Saturate.h new file mode 100644 index 00000000..7b053a28 --- /dev/null +++ b/Elixir/Source/Engine/Material/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/Material/Nodes/Sine.h b/Elixir/Source/Engine/Material/Nodes/Sine.h new file mode 100644 index 00000000..1b7ce2e4 --- /dev/null +++ b/Elixir/Source/Engine/Material/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/Material/Nodes/Subtract.h b/Elixir/Source/Engine/Material/Nodes/Subtract.h new file mode 100644 index 00000000..edce4414 --- /dev/null +++ b/Elixir/Source/Engine/Material/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/Material/Nodes/TexCoord.h b/Elixir/Source/Engine/Material/Nodes/TexCoord.h new file mode 100644 index 00000000..2df3e393 --- /dev/null +++ b/Elixir/Source/Engine/Material/Nodes/TexCoord.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +#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"; } + const std::vector& GetInputs() const override { return m_Inputs; } + + SMaterialExpression Emit(const MaterialEmitContext& context) const override + { + return { + .Code = "input.TexCoord", + .ValueType = EMaterialValueType::Float2 + }; + } + + private: + std::vector m_Inputs; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/TextureSample.h b/Elixir/Source/Engine/Material/Nodes/TextureSample.h new file mode 100644 index 00000000..30131cae --- /dev/null +++ b/Elixir/Source/Engine/Material/Nodes/TextureSample.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include + +#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) + : m_ParameterName(std::move(parameterName)), + m_Inputs{{ + "UV", + EMaterialValueType::Float2, + "input.TexCoord", + EMaterialValueType::Float2 + }} {} + + std::string_view GetTypeName() const override { return "Material.TextureSample"; } + const std::vector& GetInputs() const override { return m_Inputs; } + + 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; + std::vector m_Inputs; + }; +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/TimeNode.h b/Elixir/Source/Engine/Material/Nodes/Time.h similarity index 91% rename from Elixir/Source/Engine/Material/Nodes/TimeNode.h rename to Elixir/Source/Engine/Material/Nodes/Time.h index 8d0ae852..614b312d 100644 --- a/Elixir/Source/Engine/Material/Nodes/TimeNode.h +++ b/Elixir/Source/Engine/Material/Nodes/Time.h @@ -11,10 +11,10 @@ namespace Elixir::Materials::Nodes /** * @brief Outputs elapsed time in seconds. */ - class TimeNode : public MaterialNode + class Time final : public MaterialNode { public: - std::string_view GetTypeName() const override { return "material.time"; } + std::string_view GetTypeName() const override { return "Material.Time"; } const std::vector& GetInputs() const override { return m_Inputs; } SMaterialExpression Emit(const MaterialEmitContext& context) const override diff --git a/Elixir/Source/Engine/Material/Nodes/UnaryOperationNode.h b/Elixir/Source/Engine/Material/Nodes/UnaryOperationNode.h new file mode 100644 index 00000000..2d700f07 --- /dev/null +++ b/Elixir/Source/Engine/Material/Nodes/UnaryOperationNode.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +#include +#include + +namespace Elixir::Materials::Nodes +{ + /** + * @brief Applies a single-input operation while preserving input width. + */ + class UnaryOperationNode : public MaterialNode + { + public: + const std::vector& GetInputs() const override { return m_Inputs; } + + protected: + UnaryOperationNode() + : m_Inputs{{ + "Value", + EMaterialValueType::Float4, + "0.0" + }} {} + + private: + std::vector m_Inputs; + }; +} \ No newline at end of file diff --git a/Elixir/Tests/Engine/Aether/SystemTest.cpp b/Elixir/Tests/Engine/Aether/SystemTest.cpp index c0c0724d..4b7d31f3 100644 --- a/Elixir/Tests/Engine/Aether/SystemTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemTest.cpp @@ -133,7 +133,7 @@ TEST(SystemTest, CompileSnapshotsParticleSpriteMaterialForRenderData) ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleSprite, true)); ASSERT_TRUE(material->DefineParameter("Tint", { .Kind = EMaterialParameterKind::Value, - .ValueType = EMaterialGraphValueType::Float4, + .ValueType = EMaterialValueType::Float4, .DefaultValue = SMaterialParam::MakeVector({ 1.0f, 1.0f, 1.0f, 1.0f }), })); diff --git a/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp b/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp index d7eaa542..1b3aac3e 100644 --- a/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp @@ -12,7 +12,7 @@ TEST(MaterialCompilerTest, AssignsStableSlotsByParameterKindAndName) ASSERT_TRUE(material->DefineParameter("Tint", { .Kind = EMaterialParameterKind::Value, - .ValueType = EMaterialGraphValueType::Float4, + .ValueType = EMaterialValueType::Float4, .DefaultValue = SMaterialParam::MakeVector(glm::vec4(1.0f)), })); ASSERT_TRUE(material->DefineParameter("Albedo", { diff --git a/Elixir/Tests/Engine/Material/MaterialFrameTableTest.cpp b/Elixir/Tests/Engine/Material/MaterialFrameTableTest.cpp index f049aa8e..1c4916dc 100644 --- a/Elixir/Tests/Engine/Material/MaterialFrameTableTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialFrameTableTest.cpp @@ -44,7 +44,7 @@ TEST(MaterialFrameTableTest, DeduplicatesAProxyAndPreserveItsValues) ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleSprite, true)); ASSERT_TRUE(material->DefineParameter("Tint", { .Kind = EMaterialParameterKind::Value, - .ValueType = EMaterialGraphValueType::Float4, + .ValueType = EMaterialValueType::Float4, .DefaultValue = SMaterialParam::MakeVector({ 1.0f, 1.0f, 1.0f, 1.0f }), })); diff --git a/Elixir/Tests/Engine/Material/MaterialGraphTest.cpp b/Elixir/Tests/Engine/Material/MaterialGraphTest.cpp index 392fd11a..f864cd8d 100644 --- a/Elixir/Tests/Engine/Material/MaterialGraphTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialGraphTest.cpp @@ -2,46 +2,41 @@ #include +#include +#include +#include +#include +#include +#include + using namespace Elixir; +using namespace Elixir::Materials::Nodes; // BaseColor = Constant([1,0,0,1]) * Parameter(BaseColorFactor) TEST(MaterialGraphTest, GeneratesMultiplyBaseColor) { MaterialGraph graph; - SMaterialNode constant; - constant.Type = EMaterialNodeType::Constant; - constant.OutputType = EMaterialGraphValueType::Float4; - constant.ConstantValue = { 1.0f, 0.0f, 0.0f, 1.0f }; - const uint32_t constantNodeId = graph.AddNode(constant); - - SMaterialNode param; - param.Type = EMaterialNodeType::Parameter; - param.OutputType = EMaterialGraphValueType::Float4; - param.ParameterName = "BaseColorFactor"; - const uint32_t paramNodeId = graph.AddNode(param); - - SMaterialNode mul; - mul.Type = EMaterialNodeType::Multiply; - mul.OutputType = EMaterialGraphValueType::Float4; - mul.Inputs = { -1, -1 }; - const uint32_t mulNodeId = graph.AddNode(mul); - - graph.Connect(constantNodeId, mulNodeId, 0); - graph.Connect(paramNodeId, mulNodeId, 1); - graph.SetChannel(EMaterialChannel::BaseColor, mulNodeId); - - const std::string hlsl = graph.GenerateHLSL(); - std::cout - << "\n--- Generated HLSL (BaseColor) ---\n" - << hlsl - << "----------------------------------\n"; + 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); // float4 coerced to the float3 channel + EXPECT_NE(hlsl.find(").rgb"), std::string::npos); } // Scalar channels coerce and a shared node is emitted once. @@ -49,25 +44,18 @@ TEST(MaterialGraphTest, ScalarChannelsAndSharedNode) { MaterialGraph graph; - SMaterialNode metallic; - metallic.Type = EMaterialNodeType::Constant; - metallic.OutputType = EMaterialGraphValueType::Float; - metallic.ConstantValue = { 0.5f, 0.0f, 0.0f, 0.0f }; - const uint32_t metallicNodeId = graph.AddNode(metallic); + 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); - graph.SetChannel(EMaterialChannel::Metallic, metallicNodeId); - graph.SetChannel(EMaterialChannel::Roughness, metallicNodeId); - - const std::string hlsl = graph.GenerateHLSL(); - std::cout - << "\n--- Generated HLSL (scalars) ---\n" - << hlsl - << "----------------------------------\n"; + const auto hlsl = graph.GenerateHLSL(); EXPECT_NE(hlsl.find("surface.Metallic ="), std::string::npos); EXPECT_NE(hlsl.find("surface.Roughness ="), std::string::npos); - // The shared constant node should be declared exactly once. const auto first = hlsl.find("float n"); ASSERT_NE(first, std::string::npos); EXPECT_EQ(hlsl.find("float n", first + 1), std::string::npos); @@ -76,15 +64,9 @@ TEST(MaterialGraphTest, ScalarChannelsAndSharedNode) TEST(MaterialGraphTest, RoutesTextureAlphaToOpacity) { MaterialGraph graph; - const auto texture = graph.AddNode({ - .Type = EMaterialNodeType::TextureSample, - .TextureParameterName = "Albedo", - }); - const auto alpha = graph.AddNode({ - .Type = EMaterialNodeType::ComponentMask, - .Inputs = { int32_t(texture) }, - .ComponentIndex = 3, - }); + 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); @@ -99,14 +81,11 @@ TEST(MaterialGraphTest, GeneratesExponentialRadialGradientForOpacity) { MaterialGraph graph; - const auto gradient = graph.AddNode({ - .Type = EMaterialNodeType::RadialGradientExponential, - .OutputType = EMaterialGraphValueType::Float, - .RadialGradientCenter = { 0.25f, 0.75f }, - .RadialGradientRadius = 0.4f, - .RadialGradientExponent = 3.0f, - }); - + 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(); diff --git a/Elixir/Tests/Engine/Material/MaterialRenderProxyTest.cpp b/Elixir/Tests/Engine/Material/MaterialRenderProxyTest.cpp index e164ca77..5838ef5c 100644 --- a/Elixir/Tests/Engine/Material/MaterialRenderProxyTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialRenderProxyTest.cpp @@ -9,7 +9,7 @@ TEST(MaterialRenderProxyTest, ResolvesOverridesIntoAnImmutableSnapshot) const auto material = CreateRef("Tinted"); ASSERT_TRUE(material->DefineParameter("Tint", { .Kind = EMaterialParameterKind::Value, - .ValueType = EMaterialGraphValueType::Float4, + .ValueType = EMaterialValueType::Float4, .DefaultValue = SMaterialParam::MakeVector(glm::vec4(1.0f)), })); @@ -31,7 +31,7 @@ TEST(MaterialRenderProxyTest, RejectsACompiledMaterialForAnOldSchema) const auto material = CreateRef("Tinted"); ASSERT_TRUE(material->DefineParameter("Tint", { .Kind = EMaterialParameterKind::Value, - .ValueType = EMaterialGraphValueType::Float4, + .ValueType = EMaterialValueType::Float4, .DefaultValue = SMaterialParam::MakeVector(glm::vec4(1.0f)), })); diff --git a/Elixir/Tests/Engine/Material/MaterialTest.cpp b/Elixir/Tests/Engine/Material/MaterialTest.cpp index 2df97120..19638664 100644 --- a/Elixir/Tests/Engine/Material/MaterialTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialTest.cpp @@ -1,25 +1,27 @@ #include #include +#include +#include using namespace Elixir; +using namespace Elixir::Materials::Nodes; TEST(MaterialTest, ValidateGraphParametersAgainstMaterialSchema) { MaterialGraph graph; - SMaterialNode tint; - tint.Type = EMaterialNodeType::Parameter; - tint.OutputType = EMaterialGraphValueType::Float4; - tint.ParameterName = "Tint"; - graph.SetChannel(EMaterialChannel::BaseColor, graph.AddNode(tint)); + 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 = EMaterialGraphValueType::Float4, + .ValueType = EMaterialValueType::Float4, .DefaultValue = SMaterialParam::MakeVector(glm::vec4{ 1.0f }), })); EXPECT_TRUE(material->ValidateGraph()); @@ -31,7 +33,7 @@ TEST(MaterialTest, RejectsOverridesThatDoNotMatchTheSchema) ASSERT_TRUE(material->DefineParameter("Tint", { .Kind = EMaterialParameterKind::Value, - .ValueType = EMaterialGraphValueType::Float4, + .ValueType = EMaterialValueType::Float4, .DefaultValue = SMaterialParam::MakeVector(glm::vec4{ 1.0f }), })); @@ -47,11 +49,10 @@ TEST(MaterialTest, ValidatesTextureSampleAgainstTextureParameter) { MaterialGraph graph; - SMaterialNode sample; - sample.Type = EMaterialNodeType::TextureSample; - sample.TextureParameterName = "AlbedoTexture"; - sample.ParameterName = "Tint"; - graph.SetChannel(EMaterialChannel::BaseColor, graph.AddNode(sample)); + graph.SetChannel( + EMaterialChannel::BaseColor, + graph.AddNode("AlbedoTexture") + ); auto material = CreateRef("Textured"); material->SetGraph(std::move(graph)); From 328b01108b72b84fddff2bcd6f7e8675df90e177 Mon Sep 17 00:00:00 2001 From: MrChampz Date: Tue, 1 Sep 2026 23:40:52 -0300 Subject: [PATCH 73/89] Extract material parameter types --- .../Engine/Aether/Effect/MaterialFactory.cpp | 2 +- Elixir/Source/Engine/Material/Material.cpp | 8 +- Elixir/Source/Engine/Material/Material.h | 98 ++----------------- .../Engine/Material/MaterialInstance.cpp | 19 ++-- .../Source/Engine/Material/MaterialInstance.h | 8 +- Elixir/Source/Engine/Material/MaterialNode.h | 3 + .../Engine/Material/MaterialParameter.h | 90 +++++++++++++++++ .../Material/Nodes/BinaryOperationNode.h | 5 +- .../Engine/Material/Nodes/Checkerboard.h | 5 +- .../Engine/Material/Nodes/ComponentMask.h | 5 +- .../Source/Engine/Material/Nodes/Constant.h | 5 +- Elixir/Source/Engine/Material/Nodes/Fresnel.h | 5 +- Elixir/Source/Engine/Material/Nodes/Lerp.h | 5 +- Elixir/Source/Engine/Material/Nodes/Panner.h | 5 +- .../Source/Engine/Material/Nodes/Parameter.h | 5 +- .../Nodes/RadialGradientExponential.h | 5 +- .../Source/Engine/Material/Nodes/TexCoord.h | 5 +- .../Engine/Material/Nodes/TextureSample.h | 5 +- Elixir/Source/Engine/Material/Nodes/Time.h | 5 +- .../Material/Nodes/UnaryOperationNode.h | 5 +- Elixir/Tests/Engine/Aether/SystemTest.cpp | 2 +- .../Engine/Material/MaterialCompilerTest.cpp | 6 +- .../Material/MaterialFrameTableTest.cpp | 6 +- .../Material/MaterialRenderProxyTest.cpp | 10 +- Elixir/Tests/Engine/Material/MaterialTest.cpp | 8 +- 25 files changed, 149 insertions(+), 176 deletions(-) create mode 100644 Elixir/Source/Engine/Material/MaterialParameter.h diff --git a/Elixir/Source/Engine/Aether/Effect/MaterialFactory.cpp b/Elixir/Source/Engine/Aether/Effect/MaterialFactory.cpp index 468ed005..1cbaddd7 100644 --- a/Elixir/Source/Engine/Aether/Effect/MaterialFactory.cpp +++ b/Elixir/Source/Engine/Aether/Effect/MaterialFactory.cpp @@ -65,7 +65,7 @@ namespace Elixir::Aether::Effect const auto tex = TextureLoader::Load(desc.BaseColorTexturePath); material->DefineParameter(texParam, { .Kind = EMaterialParameterKind::Texture, - .DefaultValue = SMaterialParam::MakeTexture(tex), + .DefaultValue = SMaterialParameter::MakeTexture(tex), }); const auto texture = graph.AddNode(texParam); diff --git a/Elixir/Source/Engine/Material/Material.cpp b/Elixir/Source/Engine/Material/Material.cpp index 7dc846ab..0104a144 100644 --- a/Elixir/Source/Engine/Material/Material.cpp +++ b/Elixir/Source/Engine/Material/Material.cpp @@ -36,7 +36,7 @@ namespace Elixir return (m_UsageMask & GetMaterialUsageMask(usage)) != 0; } - bool Material::SetDefaultParam(const std::string& name, const SMaterialParam& value) + 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)) @@ -47,7 +47,7 @@ namespace Elixir return true; } - const SMaterialParam* Material::GetDefaultParam(const std::string& name) const + const SMaterialParameter* Material::GetDefaultParameter(const std::string& name) const { const auto* parameter = FindParameter(name); return parameter ? ¶meter->DefaultValue : nullptr; @@ -75,7 +75,7 @@ namespace Elixir bool Material::IsParameterValueCompatible( const std::string& name, - const SMaterialParam& value + const SMaterialParameter& value ) const { const auto* parameter = FindParameter(name); @@ -114,7 +114,7 @@ namespace Elixir bool Material::IsValueCompatible( const SMaterialParameterDefinition& definition, - const SMaterialParam& value + const SMaterialParameter& value ) { if (definition.Kind == EMaterialParameterKind::Texture) diff --git a/Elixir/Source/Engine/Material/Material.h b/Elixir/Source/Engine/Material/Material.h index 8cd22cfd..3b842725 100644 --- a/Elixir/Source/Engine/Material/Material.h +++ b/Elixir/Source/Engine/Material/Material.h @@ -1,7 +1,7 @@ #pragma once #include -#include +#include namespace Elixir { @@ -28,92 +28,6 @@ namespace Elixir Count }; - /** - * @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 SMaterialParam - { - /** 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 SMaterialParam MakeScalar(const float value) - { - SMaterialParam 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 SMaterialParam MakeVector(const glm::vec4& value) - { - SMaterialParam 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 SMaterialParam MakeTexture(const Ref& texture) - { - SMaterialParam param; - param.Type = EMaterialParameterType::Texture; - param.Texture = texture; - return param; - } - }; - /** * @brief Defines the numeric value type used by a material. */ @@ -134,7 +48,7 @@ namespace Elixir EMaterialValueType ValueType = EMaterialValueType::Float4; /** Value used when an instance does not provide an override. */ - SMaterialParam DefaultValue; + SMaterialParameter DefaultValue; }; /** @@ -193,14 +107,14 @@ namespace Elixir * @param value Compatible value to store. * @return `true` if the parameter exists and accepts @p value. */ - bool SetDefaultParam(const std::string& name, const SMaterialParam& 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 SMaterialParam* GetDefaultParam(const std::string& name) const; + const SMaterialParameter* GetDefaultParameter(const std::string& name) const; /** * @brief Adds a parameter to the material schema. @@ -230,7 +144,7 @@ namespace Elixir */ bool IsParameterValueCompatible( const std::string& name, - const SMaterialParam& value + const SMaterialParameter& value ) const; /** @@ -256,7 +170,7 @@ namespace Elixir /** Checks whether a value matches a parameter definition. */ static bool IsValueCompatible( const SMaterialParameterDefinition& definition, - const SMaterialParam& value + const SMaterialParameter& value ); std::string m_Name; diff --git a/Elixir/Source/Engine/Material/MaterialInstance.cpp b/Elixir/Source/Engine/Material/MaterialInstance.cpp index 6ea22180..bb677273 100644 --- a/Elixir/Source/Engine/Material/MaterialInstance.cpp +++ b/Elixir/Source/Engine/Material/MaterialInstance.cpp @@ -5,7 +5,7 @@ namespace Elixir { bool MaterialInstance::SetScalar(const std::string& name, const float value) { - return SetOverride(name, SMaterialParam::MakeScalar(value)); + return SetOverride(name, SMaterialParameter::MakeScalar(value)); } float MaterialInstance::GetScalar(const std::string& name) const @@ -16,7 +16,7 @@ namespace Elixir bool MaterialInstance::SetVector(const std::string& name, const glm::vec4& value) { - return SetOverride(name, SMaterialParam::MakeVector(value)); + return SetOverride(name, SMaterialParameter::MakeVector(value)); } glm::vec4 MaterialInstance::GetVector(const std::string& name) const @@ -27,7 +27,7 @@ namespace Elixir bool MaterialInstance::SetTexture(const std::string& name, const Ref& texture) { - return SetOverride(name, SMaterialParam::MakeTexture(texture)); + return SetOverride(name, SMaterialParameter::MakeTexture(texture)); } Ref MaterialInstance::GetTexture(const std::string& name) const @@ -36,12 +36,17 @@ namespace Elixir return param ? param->Texture : nullptr; } - const SMaterialParam* MaterialInstance::GetResolvedParameter(const std::string& name) const + const SMaterialParameter* MaterialInstance::GetResolvedParameter( + const std::string& name + ) const { return Resolve(name); } - bool MaterialInstance::SetOverride(const std::string& name, const SMaterialParam& value) + bool MaterialInstance::SetOverride( + const std::string& name, + const SMaterialParameter& value + ) { if (!m_Parent || !m_Parent->IsParameterValueCompatible(name, value)) return false; @@ -52,11 +57,11 @@ namespace Elixir return true; } - const SMaterialParam* MaterialInstance::Resolve(const std::string& name) const + 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->GetDefaultParam(name) : nullptr; + return m_Parent ? m_Parent->GetDefaultParameter(name) : nullptr; } } diff --git a/Elixir/Source/Engine/Material/MaterialInstance.h b/Elixir/Source/Engine/Material/MaterialInstance.h index fad5193b..dcbf615d 100644 --- a/Elixir/Source/Engine/Material/MaterialInstance.h +++ b/Elixir/Source/Engine/Material/MaterialInstance.h @@ -72,7 +72,7 @@ namespace Elixir * @param name Parameter name. * @return Resolved parameter, or null if it is unavailable. */ - const SMaterialParam* GetResolvedParameter(const std::string& name) const; + const SMaterialParameter* GetResolvedParameter(const std::string& name) const; /** @brief Returns the parent material. */ const Ref& GetParent() const { return m_Parent; } @@ -82,13 +82,13 @@ namespace Elixir private: // Stores a compatible parameter override. - bool SetOverride(const std::string& name, const SMaterialParam& value); + bool SetOverride(const std::string& name, const SMaterialParameter& value); // Finds an override or the parent material's default value. - const SMaterialParam* Resolve(const std::string& name) const; + const SMaterialParameter* Resolve(const std::string& name) const; Ref m_Parent; - std::unordered_map m_Overrides; + std::unordered_map m_Overrides; uint32_t m_Revision = 1; }; } diff --git a/Elixir/Source/Engine/Material/MaterialNode.h b/Elixir/Source/Engine/Material/MaterialNode.h index beb6bebc..87aa3dbf 100644 --- a/Elixir/Source/Engine/Material/MaterialNode.h +++ b/Elixir/Source/Engine/Material/MaterialNode.h @@ -1,5 +1,8 @@ #pragma once +#include +#include + namespace Elixir { struct SMaterialGraphBindings; diff --git a/Elixir/Source/Engine/Material/MaterialParameter.h b/Elixir/Source/Engine/Material/MaterialParameter.h new file mode 100644 index 00000000..d806af34 --- /dev/null +++ b/Elixir/Source/Engine/Material/MaterialParameter.h @@ -0,0 +1,90 @@ +#pragma once + +namespace Elixir +{ + /** + * @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/Material/Nodes/BinaryOperationNode.h b/Elixir/Source/Engine/Material/Nodes/BinaryOperationNode.h index f859a76d..a0540aa0 100644 --- a/Elixir/Source/Engine/Material/Nodes/BinaryOperationNode.h +++ b/Elixir/Source/Engine/Material/Nodes/BinaryOperationNode.h @@ -1,10 +1,7 @@ #pragma once -#include -#include - #include -#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/Checkerboard.h b/Elixir/Source/Engine/Material/Nodes/Checkerboard.h index f756d435..ac1855da 100644 --- a/Elixir/Source/Engine/Material/Nodes/Checkerboard.h +++ b/Elixir/Source/Engine/Material/Nodes/Checkerboard.h @@ -1,10 +1,7 @@ #pragma once -#include -#include - #include -#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/ComponentMask.h b/Elixir/Source/Engine/Material/Nodes/ComponentMask.h index 18e5689a..09615f03 100644 --- a/Elixir/Source/Engine/Material/Nodes/ComponentMask.h +++ b/Elixir/Source/Engine/Material/Nodes/ComponentMask.h @@ -1,10 +1,7 @@ #pragma once -#include -#include - #include -#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/Constant.h b/Elixir/Source/Engine/Material/Nodes/Constant.h index 0a5fcbcd..26efe888 100644 --- a/Elixir/Source/Engine/Material/Nodes/Constant.h +++ b/Elixir/Source/Engine/Material/Nodes/Constant.h @@ -1,10 +1,7 @@ #pragma once -#include -#include - #include -#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/Fresnel.h b/Elixir/Source/Engine/Material/Nodes/Fresnel.h index 61876ea3..0875faec 100644 --- a/Elixir/Source/Engine/Material/Nodes/Fresnel.h +++ b/Elixir/Source/Engine/Material/Nodes/Fresnel.h @@ -1,10 +1,7 @@ #pragma once -#include -#include - #include -#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/Lerp.h b/Elixir/Source/Engine/Material/Nodes/Lerp.h index efdedaaf..c54169db 100644 --- a/Elixir/Source/Engine/Material/Nodes/Lerp.h +++ b/Elixir/Source/Engine/Material/Nodes/Lerp.h @@ -1,10 +1,7 @@ #pragma once -#include -#include - #include -#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/Panner.h b/Elixir/Source/Engine/Material/Nodes/Panner.h index a4457011..080f47a9 100644 --- a/Elixir/Source/Engine/Material/Nodes/Panner.h +++ b/Elixir/Source/Engine/Material/Nodes/Panner.h @@ -1,10 +1,7 @@ #pragma once -#include -#include - #include -#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/Parameter.h b/Elixir/Source/Engine/Material/Nodes/Parameter.h index c9323cea..05b9aa4d 100644 --- a/Elixir/Source/Engine/Material/Nodes/Parameter.h +++ b/Elixir/Source/Engine/Material/Nodes/Parameter.h @@ -1,10 +1,7 @@ #pragma once -#include -#include - #include -#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/RadialGradientExponential.h b/Elixir/Source/Engine/Material/Nodes/RadialGradientExponential.h index 94cf0b99..1023dddb 100644 --- a/Elixir/Source/Engine/Material/Nodes/RadialGradientExponential.h +++ b/Elixir/Source/Engine/Material/Nodes/RadialGradientExponential.h @@ -1,10 +1,7 @@ #pragma once -#include -#include - #include -#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/TexCoord.h b/Elixir/Source/Engine/Material/Nodes/TexCoord.h index 2df3e393..777c0375 100644 --- a/Elixir/Source/Engine/Material/Nodes/TexCoord.h +++ b/Elixir/Source/Engine/Material/Nodes/TexCoord.h @@ -1,10 +1,7 @@ #pragma once -#include -#include - #include -#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/TextureSample.h b/Elixir/Source/Engine/Material/Nodes/TextureSample.h index 30131cae..2904ffd8 100644 --- a/Elixir/Source/Engine/Material/Nodes/TextureSample.h +++ b/Elixir/Source/Engine/Material/Nodes/TextureSample.h @@ -1,10 +1,7 @@ #pragma once -#include -#include - #include -#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/Time.h b/Elixir/Source/Engine/Material/Nodes/Time.h index 614b312d..d7773a55 100644 --- a/Elixir/Source/Engine/Material/Nodes/Time.h +++ b/Elixir/Source/Engine/Material/Nodes/Time.h @@ -1,10 +1,7 @@ #pragma once -#include -#include - #include -#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/UnaryOperationNode.h b/Elixir/Source/Engine/Material/Nodes/UnaryOperationNode.h index 2d700f07..fbf637cf 100644 --- a/Elixir/Source/Engine/Material/Nodes/UnaryOperationNode.h +++ b/Elixir/Source/Engine/Material/Nodes/UnaryOperationNode.h @@ -1,10 +1,7 @@ #pragma once -#include -#include - #include -#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Tests/Engine/Aether/SystemTest.cpp b/Elixir/Tests/Engine/Aether/SystemTest.cpp index 4b7d31f3..764d73d3 100644 --- a/Elixir/Tests/Engine/Aether/SystemTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemTest.cpp @@ -134,7 +134,7 @@ TEST(SystemTest, CompileSnapshotsParticleSpriteMaterialForRenderData) ASSERT_TRUE(material->DefineParameter("Tint", { .Kind = EMaterialParameterKind::Value, .ValueType = EMaterialValueType::Float4, - .DefaultValue = SMaterialParam::MakeVector({ 1.0f, 1.0f, 1.0f, 1.0f }), + .DefaultValue = SMaterialParameter::MakeVector({ 1.0f, 1.0f, 1.0f, 1.0f }), })); const auto instance = material->CreateInstance(); diff --git a/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp b/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp index 1b3aac3e..ad3ddc6a 100644 --- a/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp @@ -13,11 +13,11 @@ TEST(MaterialCompilerTest, AssignsStableSlotsByParameterKindAndName) ASSERT_TRUE(material->DefineParameter("Tint", { .Kind = EMaterialParameterKind::Value, .ValueType = EMaterialValueType::Float4, - .DefaultValue = SMaterialParam::MakeVector(glm::vec4(1.0f)), + .DefaultValue = SMaterialParameter::MakeVector(glm::vec4(1.0f)), })); ASSERT_TRUE(material->DefineParameter("Albedo", { .Kind = EMaterialParameterKind::Texture, - .DefaultValue = SMaterialParam::MakeTexture(nullptr), + .DefaultValue = SMaterialParameter::MakeTexture(nullptr), })); const auto result = MaterialCompiler::Build(*material); @@ -60,4 +60,4 @@ TEST(MaterialCompilerTest, DoesNotAliasParticleUsageShadersToSurfaceShader) EXPECT_FALSE(meshShader); EXPECT_NE(&ribbonShader, &material.SurfaceShader); EXPECT_NE(&meshShader, &material.SurfaceShader); -} \ No newline at end of file +} diff --git a/Elixir/Tests/Engine/Material/MaterialFrameTableTest.cpp b/Elixir/Tests/Engine/Material/MaterialFrameTableTest.cpp index 1c4916dc..5464088e 100644 --- a/Elixir/Tests/Engine/Material/MaterialFrameTableTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialFrameTableTest.cpp @@ -45,7 +45,7 @@ TEST(MaterialFrameTableTest, DeduplicatesAProxyAndPreserveItsValues) ASSERT_TRUE(material->DefineParameter("Tint", { .Kind = EMaterialParameterKind::Value, .ValueType = EMaterialValueType::Float4, - .DefaultValue = SMaterialParam::MakeVector({ 1.0f, 1.0f, 1.0f, 1.0f }), + .DefaultValue = SMaterialParameter::MakeVector({ 1.0f, 1.0f, 1.0f, 1.0f }), })); auto instance = CreateRef(material); @@ -102,7 +102,7 @@ TEST(MaterialFrameTableTest, ResolvesAuthoredTextureSlots) auto material = CreateRef("Particle material"); ASSERT_TRUE(material->DefineParameter("Albedo", { .Kind = EMaterialParameterKind::Texture, - .DefaultValue = SMaterialParam::MakeTexture(texture), + .DefaultValue = SMaterialParameter::MakeTexture(texture), })); auto instance = CreateRef(material); @@ -131,4 +131,4 @@ TEST(MaterialFrameTableTest, ResolvesAuthoredTextureSlots) EXPECT_EQ(resolveCount, 1); EXPECT_EQ(data.TextureIndices[0], 37); EXPECT_EQ(data.TextureIndices[1], 5); -} \ No newline at end of file +} diff --git a/Elixir/Tests/Engine/Material/MaterialRenderProxyTest.cpp b/Elixir/Tests/Engine/Material/MaterialRenderProxyTest.cpp index 5838ef5c..e0d05d7a 100644 --- a/Elixir/Tests/Engine/Material/MaterialRenderProxyTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialRenderProxyTest.cpp @@ -10,7 +10,7 @@ TEST(MaterialRenderProxyTest, ResolvesOverridesIntoAnImmutableSnapshot) ASSERT_TRUE(material->DefineParameter("Tint", { .Kind = EMaterialParameterKind::Value, .ValueType = EMaterialValueType::Float4, - .DefaultValue = SMaterialParam::MakeVector(glm::vec4(1.0f)), + .DefaultValue = SMaterialParameter::MakeVector(glm::vec4(1.0f)), })); const auto compiled = MaterialCompiler::Build(*material).Material; @@ -32,16 +32,16 @@ TEST(MaterialRenderProxyTest, RejectsACompiledMaterialForAnOldSchema) ASSERT_TRUE(material->DefineParameter("Tint", { .Kind = EMaterialParameterKind::Value, .ValueType = EMaterialValueType::Float4, - .DefaultValue = SMaterialParam::MakeVector(glm::vec4(1.0f)), + .DefaultValue = SMaterialParameter::MakeVector(glm::vec4(1.0f)), })); const auto compiled = MaterialCompiler::Build(*material).Material; ASSERT_TRUE(compiled); - ASSERT_TRUE(material->SetDefaultParam( + ASSERT_TRUE(material->SetDefaultParameter( "Tint", - SMaterialParam::MakeVector(glm::vec4(0.5f)) + SMaterialParameter::MakeVector(glm::vec4(0.5f)) )); MaterialInstance instance(material); EXPECT_FALSE(MaterialRenderProxy::Create(compiled, instance)); -} \ No newline at end of file +} diff --git a/Elixir/Tests/Engine/Material/MaterialTest.cpp b/Elixir/Tests/Engine/Material/MaterialTest.cpp index 19638664..6db741f0 100644 --- a/Elixir/Tests/Engine/Material/MaterialTest.cpp +++ b/Elixir/Tests/Engine/Material/MaterialTest.cpp @@ -22,7 +22,7 @@ TEST(MaterialTest, ValidateGraphParametersAgainstMaterialSchema) EXPECT_TRUE(material->DefineParameter("Tint", { .Kind = EMaterialParameterKind::Value, .ValueType = EMaterialValueType::Float4, - .DefaultValue = SMaterialParam::MakeVector(glm::vec4{ 1.0f }), + .DefaultValue = SMaterialParameter::MakeVector(glm::vec4{ 1.0f }), })); EXPECT_TRUE(material->ValidateGraph()); } @@ -34,7 +34,7 @@ TEST(MaterialTest, RejectsOverridesThatDoNotMatchTheSchema) ASSERT_TRUE(material->DefineParameter("Tint", { .Kind = EMaterialParameterKind::Value, .ValueType = EMaterialValueType::Float4, - .DefaultValue = SMaterialParam::MakeVector(glm::vec4{ 1.0f }), + .DefaultValue = SMaterialParameter::MakeVector(glm::vec4{ 1.0f }), })); MaterialInstance instance(material); @@ -59,7 +59,7 @@ TEST(MaterialTest, ValidatesTextureSampleAgainstTextureParameter) EXPECT_TRUE(material->DefineParameter("AlbedoTexture", { .Kind = EMaterialParameterKind::Texture, - .DefaultValue = SMaterialParam::MakeTexture(nullptr), + .DefaultValue = SMaterialParameter::MakeTexture(nullptr), })); EXPECT_TRUE(material->ValidateGraph()); } @@ -74,4 +74,4 @@ TEST(MaterialTest, CreatesInstancesThatKeepTheirParentAlive) ASSERT_TRUE(instance); ASSERT_TRUE(parent); EXPECT_EQ(instance->GetParent(), parent); -} \ No newline at end of file +} From bd2d8b40701de039f79c138abac154bf4cf8340b Mon Sep 17 00:00:00 2001 From: MrChampz Date: Wed, 2 Sep 2026 00:04:41 -0300 Subject: [PATCH 74/89] Share material node input definitions --- Elixir/Source/Engine/Material/MaterialNode.h | 12 +++++++++--- .../Engine/Material/Nodes/BinaryOperationNode.h | 14 ++++---------- .../Source/Engine/Material/Nodes/Checkerboard.h | 8 +++----- .../Source/Engine/Material/Nodes/ComponentMask.h | 8 +++----- Elixir/Source/Engine/Material/Nodes/Constant.h | 2 -- Elixir/Source/Engine/Material/Nodes/Fresnel.h | 4 ---- Elixir/Source/Engine/Material/Nodes/Lerp.h | 15 ++++++--------- Elixir/Source/Engine/Material/Nodes/Panner.h | 8 +++----- Elixir/Source/Engine/Material/Nodes/Parameter.h | 2 -- .../Material/Nodes/RadialGradientExponential.h | 12 +++++------- Elixir/Source/Engine/Material/Nodes/TexCoord.h | 4 ---- .../Source/Engine/Material/Nodes/TextureSample.h | 8 +++----- Elixir/Source/Engine/Material/Nodes/Time.h | 4 ---- .../Engine/Material/Nodes/UnaryOperationNode.h | 6 ------ 14 files changed, 36 insertions(+), 71 deletions(-) diff --git a/Elixir/Source/Engine/Material/MaterialNode.h b/Elixir/Source/Engine/Material/MaterialNode.h index 87aa3dbf..2b93df8d 100644 --- a/Elixir/Source/Engine/Material/MaterialNode.h +++ b/Elixir/Source/Engine/Material/MaterialNode.h @@ -110,9 +110,6 @@ namespace Elixir /** @brief Gets the stable identifier used by tools and serialization. */ virtual std::string_view GetTypeName() const = 0; - /** @brief Gets the input slots defined by the node. */ - virtual const std::vector& GetInputs() const = 0; - /** @brief Validates node-specific references against material parameters. */ virtual bool Validate( const MaterialNodeValidationContext& parameters, @@ -124,5 +121,14 @@ namespace Elixir /** @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/Material/Nodes/BinaryOperationNode.h b/Elixir/Source/Engine/Material/Nodes/BinaryOperationNode.h index a0540aa0..be6bc337 100644 --- a/Elixir/Source/Engine/Material/Nodes/BinaryOperationNode.h +++ b/Elixir/Source/Engine/Material/Nodes/BinaryOperationNode.h @@ -10,15 +10,12 @@ namespace Elixir::Materials::Nodes */ class BinaryOperationNode : public MaterialNode { - public: - const std::vector& GetInputs() const override { return m_Inputs; } - protected: BinaryOperationNode() - : m_Inputs{ - { "A", EMaterialValueType::Float4, "0.0" }, - { "B", EMaterialValueType::Float4, "0.0" } - } {} + : MaterialNode({ + { "A", EMaterialValueType::Float4, "0.0" }, + { "B", EMaterialValueType::Float4, "0.0" } + }) {} static EMaterialValueType GetOutputType(const MaterialEmitContext& context) { @@ -27,8 +24,5 @@ namespace Elixir::Materials::Nodes context.Input(1).ValueType ); } - - private: - std::vector m_Inputs; }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/Checkerboard.h b/Elixir/Source/Engine/Material/Nodes/Checkerboard.h index ac1855da..0bdb9b49 100644 --- a/Elixir/Source/Engine/Material/Nodes/Checkerboard.h +++ b/Elixir/Source/Engine/Material/Nodes/Checkerboard.h @@ -16,16 +16,15 @@ namespace Elixir::Materials::Nodes * @param scale The checkerboard scale. */ explicit Checkerboard(const float scale) - : m_Scale(scale), - m_Inputs{{ + : MaterialNode({{ "UV", EMaterialValueType::Float2, "input.TexCoord", EMaterialValueType::Float2 - }} {} + }}), + m_Scale(scale) {} std::string_view GetTypeName() const override { return "Material.Checkerboard"; } - const std::vector& GetInputs() const override { return m_Inputs; } SMaterialExpression Emit(const MaterialEmitContext& context) const override { @@ -42,6 +41,5 @@ namespace Elixir::Materials::Nodes private: float m_Scale; - std::vector m_Inputs; }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/ComponentMask.h b/Elixir/Source/Engine/Material/Nodes/ComponentMask.h index 09615f03..1c1c8571 100644 --- a/Elixir/Source/Engine/Material/Nodes/ComponentMask.h +++ b/Elixir/Source/Engine/Material/Nodes/ComponentMask.h @@ -16,16 +16,15 @@ namespace Elixir::Materials::Nodes * @param componentIndex Zero-based component index. */ explicit ComponentMask(const uint32_t componentIndex) - : m_ComponentIndex(componentIndex), - m_Inputs{{ + : 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"; } - const std::vector& GetInputs() const override { return m_Inputs; } SMaterialExpression Emit(const MaterialEmitContext& context) const override { @@ -39,6 +38,5 @@ namespace Elixir::Materials::Nodes private: uint32_t m_ComponentIndex; - std::vector m_Inputs; }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/Constant.h b/Elixir/Source/Engine/Material/Nodes/Constant.h index 26efe888..fc9dc1a9 100644 --- a/Elixir/Source/Engine/Material/Nodes/Constant.h +++ b/Elixir/Source/Engine/Material/Nodes/Constant.h @@ -21,7 +21,6 @@ namespace Elixir::Materials::Nodes m_ValueType(type) {} std::string_view GetTypeName() const override { return "Material.Constant"; } - const std::vector& GetInputs() const override { return m_Inputs; } SMaterialExpression Emit(const MaterialEmitContext& context) const override { @@ -55,6 +54,5 @@ namespace Elixir::Materials::Nodes private: glm::vec4 m_Value; EMaterialValueType m_ValueType; - std::vector m_Inputs; }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/Fresnel.h b/Elixir/Source/Engine/Material/Nodes/Fresnel.h index 0875faec..1e2e53e8 100644 --- a/Elixir/Source/Engine/Material/Nodes/Fresnel.h +++ b/Elixir/Source/Engine/Material/Nodes/Fresnel.h @@ -12,7 +12,6 @@ namespace Elixir::Materials::Nodes { public: std::string_view GetTypeName() const override { return "Material.Fresnel"; } - const std::vector& GetInputs() const override { return m_Inputs; } SMaterialExpression Emit(const MaterialEmitContext& context) const override { @@ -21,8 +20,5 @@ namespace Elixir::Materials::Nodes .ValueType = EMaterialValueType::Float }; } - - private: - std::vector m_Inputs; }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/Lerp.h b/Elixir/Source/Engine/Material/Nodes/Lerp.h index c54169db..2e8abf3c 100644 --- a/Elixir/Source/Engine/Material/Nodes/Lerp.h +++ b/Elixir/Source/Engine/Material/Nodes/Lerp.h @@ -11,14 +11,14 @@ namespace Elixir::Materials::Nodes class Lerp final : public MaterialNode { public: - Lerp() : m_Inputs{ - { "A", EMaterialValueType::Float4, "0.0" }, - { "B", EMaterialValueType::Float4, "0.0" }, - { "T", EMaterialValueType::Float4, "0.0" } - } {} + 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"; } - const std::vector& GetInputs() const override { return m_Inputs; } SMaterialExpression Emit(const MaterialEmitContext& context) const override { @@ -34,8 +34,5 @@ namespace Elixir::Materials::Nodes .ValueType = type }; } - - private: - std::vector m_Inputs; }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/Panner.h b/Elixir/Source/Engine/Material/Nodes/Panner.h index 080f47a9..c8c4816d 100644 --- a/Elixir/Source/Engine/Material/Nodes/Panner.h +++ b/Elixir/Source/Engine/Material/Nodes/Panner.h @@ -12,16 +12,15 @@ namespace Elixir::Materials::Nodes { public: explicit Panner(const glm::vec2& speed) - : m_Speed(speed), - m_Inputs{{ + : MaterialNode({{ "UV", EMaterialValueType::Float2, "input.TexCoord", EMaterialValueType::Float2 - }} {} + }}), + m_Speed(speed) {} std::string_view GetTypeName() const override { return "Material.Panner"; } - const std::vector& GetInputs() const override { return m_Inputs; } SMaterialExpression Emit(const MaterialEmitContext& context) const override { @@ -36,6 +35,5 @@ namespace Elixir::Materials::Nodes private: glm::vec2 m_Speed; - std::vector m_Inputs; }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/Parameter.h b/Elixir/Source/Engine/Material/Nodes/Parameter.h index 05b9aa4d..ba457b8a 100644 --- a/Elixir/Source/Engine/Material/Nodes/Parameter.h +++ b/Elixir/Source/Engine/Material/Nodes/Parameter.h @@ -21,7 +21,6 @@ namespace Elixir::Materials::Nodes m_ValueType(type) {} std::string_view GetTypeName() const override { return "Material.Parameter"; } - const std::vector& GetInputs() const override { return m_Inputs; } bool Validate( const MaterialNodeValidationContext& parameters, @@ -44,6 +43,5 @@ namespace Elixir::Materials::Nodes private: std::string m_Name; EMaterialValueType m_ValueType; - std::vector m_Inputs; }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/RadialGradientExponential.h b/Elixir/Source/Engine/Material/Nodes/RadialGradientExponential.h index 1023dddb..d4b7dd79 100644 --- a/Elixir/Source/Engine/Material/Nodes/RadialGradientExponential.h +++ b/Elixir/Source/Engine/Material/Nodes/RadialGradientExponential.h @@ -21,18 +21,17 @@ namespace Elixir::Materials::Nodes const glm::vec2& center, const float radius, const float exponent - ) : m_Center(center), - m_Radius(radius), - m_Exponent(exponent), - m_Inputs{{ + ) : 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"; } - const std::vector& GetInputs() const override { return m_Inputs; } SMaterialExpression Emit(const MaterialEmitContext& context) const override { @@ -51,6 +50,5 @@ namespace Elixir::Materials::Nodes glm::vec2 m_Center; float m_Radius; float m_Exponent; - std::vector m_Inputs; }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/TexCoord.h b/Elixir/Source/Engine/Material/Nodes/TexCoord.h index 777c0375..8c439692 100644 --- a/Elixir/Source/Engine/Material/Nodes/TexCoord.h +++ b/Elixir/Source/Engine/Material/Nodes/TexCoord.h @@ -12,7 +12,6 @@ namespace Elixir::Materials::Nodes { public: std::string_view GetTypeName() const override { return "Material.TexCoord"; } - const std::vector& GetInputs() const override { return m_Inputs; } SMaterialExpression Emit(const MaterialEmitContext& context) const override { @@ -21,8 +20,5 @@ namespace Elixir::Materials::Nodes .ValueType = EMaterialValueType::Float2 }; } - - private: - std::vector m_Inputs; }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/TextureSample.h b/Elixir/Source/Engine/Material/Nodes/TextureSample.h index 2904ffd8..aa487a94 100644 --- a/Elixir/Source/Engine/Material/Nodes/TextureSample.h +++ b/Elixir/Source/Engine/Material/Nodes/TextureSample.h @@ -16,16 +16,15 @@ namespace Elixir::Materials::Nodes * @param parameterName Texture parameter name. */ explicit TextureSample(std::string parameterName) - : m_ParameterName(std::move(parameterName)), - m_Inputs{{ + : MaterialNode({{ "UV", EMaterialValueType::Float2, "input.TexCoord", EMaterialValueType::Float2 - }} {} + }}), + m_ParameterName(std::move(parameterName)) {} std::string_view GetTypeName() const override { return "Material.TextureSample"; } - const std::vector& GetInputs() const override { return m_Inputs; } SMaterialExpression Emit(const MaterialEmitContext& context) const override { @@ -43,6 +42,5 @@ namespace Elixir::Materials::Nodes private: std::string m_ParameterName; - std::vector m_Inputs; }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/Time.h b/Elixir/Source/Engine/Material/Nodes/Time.h index d7773a55..c7836b52 100644 --- a/Elixir/Source/Engine/Material/Nodes/Time.h +++ b/Elixir/Source/Engine/Material/Nodes/Time.h @@ -12,14 +12,10 @@ namespace Elixir::Materials::Nodes { public: std::string_view GetTypeName() const override { return "Material.Time"; } - const std::vector& GetInputs() const override { return m_Inputs; } SMaterialExpression Emit(const MaterialEmitContext& context) const override { return { .Code = "Time", .ValueType = EMaterialValueType::Float }; } - - private: - std::vector m_Inputs; }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/Nodes/UnaryOperationNode.h b/Elixir/Source/Engine/Material/Nodes/UnaryOperationNode.h index fbf637cf..5e2a8cb6 100644 --- a/Elixir/Source/Engine/Material/Nodes/UnaryOperationNode.h +++ b/Elixir/Source/Engine/Material/Nodes/UnaryOperationNode.h @@ -10,9 +10,6 @@ namespace Elixir::Materials::Nodes */ class UnaryOperationNode : public MaterialNode { - public: - const std::vector& GetInputs() const override { return m_Inputs; } - protected: UnaryOperationNode() : m_Inputs{{ @@ -20,8 +17,5 @@ namespace Elixir::Materials::Nodes EMaterialValueType::Float4, "0.0" }} {} - - private: - std::vector m_Inputs; }; } \ No newline at end of file From cbf60d0e5c2dab3f34cb0b1974df27198e2d0777 Mon Sep 17 00:00:00 2001 From: MrChampz Date: Wed, 2 Sep 2026 00:16:52 -0300 Subject: [PATCH 75/89] chore: missing Dissolve changes --- Dissolve/Source/Dissolve.cpp | 100 ++++++++++++++--------------------- 1 file changed, 40 insertions(+), 60 deletions(-) diff --git a/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index a0ebcbf9..dd09e0cc 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -1,5 +1,7 @@ #include "Dissolve.h" +#include "Engine/Material/Nodes/Parameter.h" + #include #include #include @@ -8,6 +10,11 @@ #include #include #include +#include +#include +#include + +using namespace Elixir::Materials::Nodes; Ref pipeline; std::array, 2> m_ParticleSystems; @@ -83,34 +90,23 @@ Dissolve::Dissolve() EE_CORE_ASSERT(graphMaterial->DefineParameter("Tint", { .Kind = EMaterialParameterKind::Value, - .ValueType = EMaterialGraphValueType::Float4, - .DefaultValue = SMaterialParam::MakeVector({ 1.0f, 0.5f, 0.2f, 1.0f }), + .ValueType = EMaterialValueType::Float4, + .DefaultValue = SMaterialParameter::MakeVector({ 1.0f, 0.5f, 0.2f, 1.0f }), }), "") EE_CORE_ASSERT(graphMaterial->DefineParameter("Albedo", { .Kind = EMaterialParameterKind::Texture, - .DefaultValue = SMaterialParam::MakeTexture(tex), + .DefaultValue = SMaterialParameter::MakeTexture(tex), }), "") - SMaterialNode albedo; - albedo.Type = EMaterialNodeType::TextureSample; - albedo.TextureParameterName = "Albedo"; - const auto albedoNode = graph.AddNode(albedo); - - SMaterialNode tint; - tint.Type = EMaterialNodeType::Parameter; - tint.OutputType = EMaterialGraphValueType::Float4; - tint.ParameterName = "Tint"; - const auto tintNode = graph.AddNode(tint); - - SMaterialNode baseColor; - baseColor.Type = EMaterialNodeType::Multiply; - baseColor.OutputType = EMaterialGraphValueType::Float4; - baseColor.Inputs = { - (int32_t)albedoNode, - (int32_t)tintNode, - }; - graph.SetChannel(EMaterialChannel::BaseColor, graph.AddNode(baseColor)); + 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.") @@ -147,54 +143,38 @@ Dissolve::Dissolve() EE_CORE_ASSERT(ribbonMaterial->DefineParameter("Tint", { .Kind = EMaterialParameterKind::Value, - .ValueType = EMaterialGraphValueType::Float4, - .DefaultValue = SMaterialParam::MakeVector({ 0.2f, 0.5f, 1.0f, 1.0f }), + .ValueType = EMaterialValueType::Float4, + .DefaultValue = SMaterialParameter::MakeVector({ 0.2f, 0.5f, 1.0f, 1.0f }), }), "") EE_CORE_ASSERT(ribbonMaterial->DefineParameter("Glow", { .Kind = EMaterialParameterKind::Value, - .ValueType = EMaterialGraphValueType::Float4, - .DefaultValue = SMaterialParam::MakeVector({ 0.05f, 0.2f, 1.0f, 1.0f }), + .ValueType = EMaterialValueType::Float4, + .DefaultValue = SMaterialParameter::MakeVector({ 0.05f, 0.2f, 1.0f, 1.0f }), }), "") EE_CORE_ASSERT(ribbonMaterial->DefineParameter("Albedo", { .Kind = EMaterialParameterKind::Texture, - .DefaultValue = SMaterialParam::MakeTexture(tex), + .DefaultValue = SMaterialParameter::MakeTexture(tex), }), "") - SMaterialNode panner; - panner.Type = EMaterialNodeType::Panner; - panner.OutputType = EMaterialGraphValueType::Float2; - panner.ConstantValue = { 0.08f, -0.35f, 0.0f, 0.0f }; - const auto pannerNode = graph1.AddNode(panner); - - SMaterialNode albedo1; - albedo1.Type = EMaterialNodeType::TextureSample; - albedo1.OutputType = EMaterialGraphValueType::Float3; - albedo1.TextureParameterName = "Albedo"; - albedo1.Inputs = { static_cast(pannerNode) }; - const auto albedoNode1 = graph1.AddNode(albedo1); - - SMaterialNode tint1; - tint1.Type = EMaterialNodeType::Parameter; - tint1.OutputType = EMaterialGraphValueType::Float4; - tint1.ParameterName = "Tint"; - const auto tintNode1 = graph1.AddNode(tint1); - - SMaterialNode color; - color.Type = EMaterialNodeType::Multiply; - color.OutputType = EMaterialGraphValueType::Float4; - color.Inputs = { - static_cast(albedoNode1), - static_cast(tintNode1), - }; - graph1.SetChannel(EMaterialChannel::BaseColor, albedoNode1); - - SMaterialNode glow; - glow.Type = EMaterialNodeType::Parameter; - glow.OutputType = EMaterialGraphValueType::Float4; - glow.ParameterName = "Glow"; - //graph1.SetChannel(EMaterialChannel::Emissive, graph1.AddNode(glow)); + 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.") From 05f5de866e95f685a0d3cec4d2b3abfa7f877f12 Mon Sep 17 00:00:00 2001 From: MrChampz Date: Wed, 2 Sep 2026 00:30:42 -0300 Subject: [PATCH 76/89] fix: adjust to changes that were made --- Editor/Source/Editor.cpp | 4 ++-- Editor/Source/Editor.h | 2 +- Editor/Source/UI/Panels/ViewportPanel.h | 1 + Elixir/Source/Engine/Icon/IconManager.h | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) 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/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); From 9a24d0fffa2692181d40d9c4b833378b1bd6d021 Mon Sep 17 00:00:00 2001 From: MrChampz Date: Wed, 2 Sep 2026 09:10:40 -0300 Subject: [PATCH 77/89] Fix constexpr material shader marker --- Elixir/Source/Engine/Material/MaterialCompiler.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Elixir/Source/Engine/Material/MaterialCompiler.cpp b/Elixir/Source/Engine/Material/MaterialCompiler.cpp index 44537aa8..2dd2145e 100644 --- a/Elixir/Source/Engine/Material/MaterialCompiler.cpp +++ b/Elixir/Source/Engine/Material/MaterialCompiler.cpp @@ -154,7 +154,7 @@ namespace Elixir { std::string out = hlsl; - constexpr std::string marker = "// __GRAPH_BODY__"; + constexpr std::string_view marker = "// __GRAPH_BODY__"; if (const auto pos = out.find(marker); pos != std::string::npos) out.replace(pos, marker.size(), graphBody); From 0f1b273ad1df9c26241901e3b4d27eec3517b634 Mon Sep 17 00:00:00 2001 From: MrChampz Date: Wed, 2 Sep 2026 13:13:59 -0300 Subject: [PATCH 78/89] Export material emit context --- Elixir/Source/Engine/Material/MaterialNode.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Elixir/Source/Engine/Material/MaterialNode.h b/Elixir/Source/Engine/Material/MaterialNode.h index 2b93df8d..d6af28db 100644 --- a/Elixir/Source/Engine/Material/MaterialNode.h +++ b/Elixir/Source/Engine/Material/MaterialNode.h @@ -63,7 +63,7 @@ namespace Elixir /** * @brief Gives a node access to resolved inputs and material parameter bindings. */ - class MaterialEmitContext + class ELIXIR_API MaterialEmitContext { friend class MaterialGraph; From 8e2f643f3c8a0fcb624bbd00c86ced357b1bc6d3 Mon Sep 17 00:00:00 2001 From: MrChampz Date: Wed, 2 Sep 2026 13:58:47 -0300 Subject: [PATCH 79/89] Export material public helpers --- Elixir/Source/Engine/Material/DefaultMaterials.h | 2 +- Elixir/Source/Engine/Material/MaterialRenderScene.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Elixir/Source/Engine/Material/DefaultMaterials.h b/Elixir/Source/Engine/Material/DefaultMaterials.h index d0038d87..8e6eef30 100644 --- a/Elixir/Source/Engine/Material/DefaultMaterials.h +++ b/Elixir/Source/Engine/Material/DefaultMaterials.h @@ -22,5 +22,5 @@ namespace Elixir * @brief Creates the engine's built-in default materials. * @return Default materials indexed by their material usage. */ - DefaultMaterialArray CreateDefaultMaterials(); + ELIXIR_API DefaultMaterialArray CreateDefaultMaterials(); } \ No newline at end of file diff --git a/Elixir/Source/Engine/Material/MaterialRenderScene.h b/Elixir/Source/Engine/Material/MaterialRenderScene.h index 2dcb3305..f446d0a4 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderScene.h +++ b/Elixir/Source/Engine/Material/MaterialRenderScene.h @@ -10,7 +10,7 @@ namespace Elixir * The structure keeps the raw constant data and can replace the material index * before the draw is recorded. */ - struct SMaterialPushConstants + struct ELIXIR_API SMaterialPushConstants { /** Maximum number of bytes available for push constants. */ static constexpr uint32_t CAPACITY = 128; From b723f972585fd9efc414ce60ea4e4ca7c8a2835f Mon Sep 17 00:00:00 2001 From: MrChampz Date: Wed, 2 Sep 2026 19:47:03 -0300 Subject: [PATCH 80/89] Reorganize materials module --- AGENTS.md | 5 ++ Dissolve/Source/Dissolve.cpp | 16 ++--- .../Engine/Aether/Effect/MaterialFactory.cpp | 14 ++-- .../Engine/Aether/Effect/MaterialFactory.h | 4 +- .../Engine/Aether/Effect/MaterialResolver.cpp | 2 +- .../Engine/Aether/Effect/MaterialResolver.h | 4 +- Elixir/Source/Engine/Aether/Emitter.h | 7 +- Elixir/Source/Engine/Aether/Manager.cpp | 2 +- Elixir/Source/Engine/Aether/Manager.h | 15 ++++- .../Engine/Aether/Rendering/Renderer.cpp | 9 +-- .../Source/Engine/Aether/Rendering/Renderer.h | 11 ++-- .../Aether/Runtime/InstanceRegistry.cpp | 4 +- .../Engine/Aether/Runtime/InstanceRegistry.h | 4 +- .../Engine/Aether/Simulation/RenderFrame.h | 4 +- Elixir/Source/Engine/Aether/System.h | 2 +- Elixir/Source/Engine/Core/Application.cpp | 4 +- Elixir/Source/Engine/Core/Application.h | 18 ++--- .../Compilation/CompilationCache.cpp} | 12 ++-- .../Compilation/CompilationCache.h} | 14 ++-- .../Compilation/Compiler.cpp} | 40 ++++++----- .../Compilation/Compiler.h} | 35 +++++----- .../DefaultMaterials.cpp | 10 +-- .../DefaultMaterials.h | 4 +- .../{Material => Materials}/Material.cpp | 4 +- .../Engine/{Material => Materials}/Material.h | 6 +- .../{Material => Materials}/MaterialGraph.cpp | 4 +- .../{Material => Materials}/MaterialGraph.h | 4 +- .../MaterialInstance.cpp | 2 +- .../MaterialInstance.h | 4 +- .../{Material => Materials}/MaterialNode.cpp | 6 +- .../{Material => Materials}/MaterialNode.h | 2 +- .../MaterialParameter.h | 2 +- .../MaterialRegistry.cpp | 4 +- .../MaterialRegistry.h | 4 +- .../MaterialSystem.cpp | 20 +++--- .../{Material => Materials}/MaterialSystem.h | 28 ++++---- .../{Material => Materials}/Nodes/Add.h | 2 +- .../Nodes/BinaryOperationNode.h | 4 +- .../Nodes/Checkerboard.h | 4 +- .../Nodes/ComponentMask.h | 4 +- .../{Material => Materials}/Nodes/Constant.h | 4 +- .../{Material => Materials}/Nodes/Divide.h | 2 +- .../{Material => Materials}/Nodes/Dot.h | 0 .../{Material => Materials}/Nodes/Fresnel.h | 0 .../{Material => Materials}/Nodes/Lerp.h | 0 .../{Material => Materials}/Nodes/Multiply.h | 2 +- .../{Material => Materials}/Nodes/OneMinus.h | 0 .../{Material => Materials}/Nodes/Panner.h | 4 +- .../{Material => Materials}/Nodes/Parameter.h | 4 +- .../{Material => Materials}/Nodes/Power.h | 0 .../Nodes/RadialGradientExponential.h | 4 +- .../{Material => Materials}/Nodes/Saturate.h | 0 .../{Material => Materials}/Nodes/Sine.h | 0 .../{Material => Materials}/Nodes/Subtract.h | 2 +- .../{Material => Materials}/Nodes/TexCoord.h | 0 .../Nodes/TextureSample.h | 4 +- .../{Material => Materials}/Nodes/Time.h | 4 +- .../Nodes/UnaryOperationNode.h | 4 +- .../Rendering/FrameTable.cpp} | 12 ++-- .../Rendering/FrameTable.h} | 11 ++-- .../Rendering}/MaterialRenderProxy.cpp | 4 +- .../Rendering}/MaterialRenderProxy.h | 7 +- .../Rendering}/MaterialRenderScene.cpp | 8 +-- .../Rendering}/MaterialRenderScene.h | 36 +++++----- .../Rendering}/MaterialResolver.h | 6 +- .../Rendering/Renderer.cpp} | 47 +++++++------ .../Rendering/Renderer.h} | 66 ++++++++----------- .../Rendering/TextureRegistry.cpp} | 14 ++-- .../Rendering/TextureRegistry.h} | 10 +-- 69 files changed, 305 insertions(+), 294 deletions(-) rename Elixir/Source/Engine/{Material/MaterialCompilationCache.cpp => Materials/Compilation/CompilationCache.cpp} (73%) rename Elixir/Source/Engine/{Material/MaterialCompilationCache.h => Materials/Compilation/CompilationCache.h} (84%) rename Elixir/Source/Engine/{Material/MaterialCompiler.cpp => Materials/Compilation/Compiler.cpp} (94%) rename Elixir/Source/Engine/{Material/MaterialCompiler.h => Materials/Compilation/Compiler.h} (85%) rename Elixir/Source/Engine/{Material => Materials}/DefaultMaterials.cpp (91%) rename Elixir/Source/Engine/{Material => Materials}/DefaultMaterials.h (90%) rename Elixir/Source/Engine/{Material => Materials}/Material.cpp (98%) rename Elixir/Source/Engine/{Material => Materials}/Material.h (98%) rename Elixir/Source/Engine/{Material => Materials}/MaterialGraph.cpp (98%) rename Elixir/Source/Engine/{Material => Materials}/MaterialGraph.h (98%) rename Elixir/Source/Engine/{Material => Materials}/MaterialInstance.cpp (98%) rename Elixir/Source/Engine/{Material => Materials}/MaterialInstance.h (98%) rename Elixir/Source/Engine/{Material => Materials}/MaterialNode.cpp (96%) rename Elixir/Source/Engine/{Material => Materials}/MaterialNode.h (99%) rename Elixir/Source/Engine/{Material => Materials}/MaterialParameter.h (98%) rename Elixir/Source/Engine/{Material => Materials}/MaterialRegistry.cpp (93%) rename Elixir/Source/Engine/{Material => Materials}/MaterialRegistry.h (94%) rename Elixir/Source/Engine/{Material => Materials}/MaterialSystem.cpp (92%) rename Elixir/Source/Engine/{Material => Materials}/MaterialSystem.h (87%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/Add.h (92%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/BinaryOperationNode.h (89%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/Checkerboard.h (94%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/ComponentMask.h (94%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/Constant.h (95%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/Divide.h (92%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/Dot.h (100%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/Fresnel.h (100%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/Lerp.h (100%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/Multiply.h (92%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/OneMinus.h (100%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/Panner.h (92%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/Parameter.h (93%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/Power.h (100%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/RadialGradientExponential.h (95%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/Saturate.h (100%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/Sine.h (100%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/Subtract.h (92%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/TexCoord.h (100%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/TextureSample.h (94%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/Time.h (84%) rename Elixir/Source/Engine/{Material => Materials}/Nodes/UnaryOperationNode.h (82%) rename Elixir/Source/Engine/{Material/MaterialFrameTable.cpp => Materials/Rendering/FrameTable.cpp} (79%) rename Elixir/Source/Engine/{Material/MaterialFrameTable.h => Materials/Rendering/FrameTable.h} (93%) rename Elixir/Source/Engine/{Material => Materials/Rendering}/MaterialRenderProxy.cpp (94%) rename Elixir/Source/Engine/{Material => Materials/Rendering}/MaterialRenderProxy.h (94%) rename Elixir/Source/Engine/{Material => Materials/Rendering}/MaterialRenderScene.cpp (84%) rename Elixir/Source/Engine/{Material => Materials/Rendering}/MaterialRenderScene.h (85%) rename Elixir/Source/Engine/{Material => Materials/Rendering}/MaterialResolver.h (82%) rename Elixir/Source/Engine/{Material/MaterialRenderer.cpp => Materials/Rendering/Renderer.cpp} (83%) rename Elixir/Source/Engine/{Material/MaterialRenderer.h => Materials/Rendering/Renderer.h} (84%) rename Elixir/Source/Engine/{Material/MaterialTextureRegistry.cpp => Materials/Rendering/TextureRegistry.cpp} (76%) rename Elixir/Source/Engine/{Material/MaterialTextureRegistry.h => Materials/Rendering/TextureRegistry.h} (92%) diff --git a/AGENTS.md b/AGENTS.md index c80bd00d..7e8e0a36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,3 +11,8 @@ - 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/Dissolve/Source/Dissolve.cpp b/Dissolve/Source/Dissolve.cpp index dd09e0cc..5b450d6f 100644 --- a/Dissolve/Source/Dissolve.cpp +++ b/Dissolve/Source/Dissolve.cpp @@ -1,18 +1,18 @@ #include "Dissolve.h" -#include "Engine/Material/Nodes/Parameter.h" +#include "Engine/Materials/Nodes/Parameter.h" #include #include #include -#include -#include -#include -#include -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include using namespace Elixir::Materials::Nodes; diff --git a/Elixir/Source/Engine/Aether/Effect/MaterialFactory.cpp b/Elixir/Source/Engine/Aether/Effect/MaterialFactory.cpp index 1cbaddd7..43aa3aaa 100644 --- a/Elixir/Source/Engine/Aether/Effect/MaterialFactory.cpp +++ b/Elixir/Source/Engine/Aether/Effect/MaterialFactory.cpp @@ -3,13 +3,13 @@ #include -#include -#include -#include -#include -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include namespace Elixir::Aether::Effect { diff --git a/Elixir/Source/Engine/Aether/Effect/MaterialFactory.h b/Elixir/Source/Engine/Aether/Effect/MaterialFactory.h index 3408e702..babe086d 100644 --- a/Elixir/Source/Engine/Aether/Effect/MaterialFactory.h +++ b/Elixir/Source/Engine/Aether/Effect/MaterialFactory.h @@ -2,10 +2,12 @@ #include #include -#include +#include namespace Elixir::Aether::Effect { + using namespace Materials; + /** * @brief Returns the material usage required by an Aether render mode. * diff --git a/Elixir/Source/Engine/Aether/Effect/MaterialResolver.cpp b/Elixir/Source/Engine/Aether/Effect/MaterialResolver.cpp index 9c0125f3..bf6a7732 100644 --- a/Elixir/Source/Engine/Aether/Effect/MaterialResolver.cpp +++ b/Elixir/Source/Engine/Aether/Effect/MaterialResolver.cpp @@ -3,7 +3,7 @@ #include #include -#include +#include namespace Elixir::Aether::Effect { diff --git a/Elixir/Source/Engine/Aether/Effect/MaterialResolver.h b/Elixir/Source/Engine/Aether/Effect/MaterialResolver.h index 65a4a3de..1e5f9b4b 100644 --- a/Elixir/Source/Engine/Aether/Effect/MaterialResolver.h +++ b/Elixir/Source/Engine/Aether/Effect/MaterialResolver.h @@ -1,10 +1,12 @@ #pragma once -namespace Elixir { class MaterialRegistry; } +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. * diff --git a/Elixir/Source/Engine/Aether/Emitter.h b/Elixir/Source/Engine/Aether/Emitter.h index 49990029..e823bd48 100644 --- a/Elixir/Source/Engine/Aether/Emitter.h +++ b/Elixir/Source/Engine/Aether/Emitter.h @@ -1,8 +1,8 @@ #pragma once #include -#include -#include +#include +#include #include #include #include @@ -14,6 +14,9 @@ namespace Elixir::Aether { using namespace Core; using namespace Modules; + using namespace Materials; + using namespace Materials::Rendering; + /** * @brief Stores immutable GPU-ready data for one compiled emitter. * diff --git a/Elixir/Source/Engine/Aether/Manager.cpp b/Elixir/Source/Engine/Aether/Manager.cpp index de7a3eb5..de23f770 100644 --- a/Elixir/Source/Engine/Aether/Manager.cpp +++ b/Elixir/Source/Engine/Aether/Manager.cpp @@ -1,7 +1,7 @@ #include "epch.h" #include "Manager.h" -#include +#include #include #include #include diff --git a/Elixir/Source/Engine/Aether/Manager.h b/Elixir/Source/Engine/Aether/Manager.h index 90714f09..81d2ea04 100644 --- a/Elixir/Source/Engine/Aether/Manager.h +++ b/Elixir/Source/Engine/Aether/Manager.h @@ -10,9 +10,17 @@ namespace Elixir class ShaderLoader; class Timestep; - class MaterialRegistry; - class MaterialResolver; - class MaterialSystem; + namespace Materials + { + class MaterialSystem; + class MaterialRegistry; + + namespace Rendering + { + class Resolver; + class RenderContext; + } + } namespace Aether { @@ -37,6 +45,7 @@ namespace Elixir::Aether using namespace Runtime; using namespace Simulation; using namespace Rendering; + using namespace Materials; /** * @brief Coordinates Aether effects, runtime instances, simulation, and rendering. diff --git a/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp b/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp index 54fa3a92..e904d531 100644 --- a/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp @@ -1,11 +1,12 @@ #include "epch.h" #include "Renderer.h" -#include +#include namespace Elixir::Aether::Rendering { using namespace Core; + using namespace Materials::Rendering; namespace { @@ -282,18 +283,18 @@ namespace Elixir::Aether::Rendering return std::nullopt; const std::array constantBuffers{ - SMaterialConstantBufferBinding{ + SConstantBufferBinding{ .Name = "cbFrame", .Buffer = m_FrameConstantBuffer, }, }; const std::array ribbonStorageBuffers{ - SMaterialStorageBufferBinding{ + SStorageBufferBinding{ .Name = "particles", .Buffer = MaterialStorageBuffer{ resource->ParticleStateBuffer }, }, - SMaterialStorageBufferBinding{ + SStorageBufferBinding{ .Name = "emitters", .Buffer = MaterialStorageBuffer{ frame.GetEmitterBuffer() }, }, diff --git a/Elixir/Source/Engine/Aether/Rendering/Renderer.h b/Elixir/Source/Engine/Aether/Rendering/Renderer.h index 84b32ec2..cc0c837c 100644 --- a/Elixir/Source/Engine/Aether/Rendering/Renderer.h +++ b/Elixir/Source/Engine/Aether/Rendering/Renderer.h @@ -3,16 +3,18 @@ #include #include #include -#include +#include #include #include -namespace Elixir { class MaterialSystem; } +namespace Elixir::Materials { class MaterialSystem; } namespace Elixir::Aether::Rendering { using namespace Core; using namespace Simulation; + using namespace Materials; + using namespace Materials::Rendering; struct alignas(16) SFrameData { @@ -61,10 +63,7 @@ namespace Elixir::Aether::Rendering * @pre context is not null and outlives the renderer. * @pre materialSystem outlives the renderer. */ - Renderer( - const GraphicsContext* context, - MaterialSystem& materialSystem - ); + Renderer(const GraphicsContext* context, MaterialSystem& materialSystem); /** * @brief Records the draw commands for a simulated particle frame. diff --git a/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp index b2d14715..213a504f 100644 --- a/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp +++ b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp @@ -1,8 +1,8 @@ #include "epch.h" #include "InstanceRegistry.h" -#include -#include +#include +#include namespace Elixir::Aether::Runtime { diff --git a/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.h b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.h index dc1e86ef..e5d7ec30 100644 --- a/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.h +++ b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.h @@ -4,10 +4,10 @@ #include #include -namespace Elixir +namespace Elixir::Materials { class MaterialRegistry; - class MaterialResolver; + namespace Rendering { class MaterialResolver; } } namespace Elixir::Aether::Runtime diff --git a/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h b/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h index 66912207..69f79e7d 100644 --- a/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h +++ b/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h @@ -1,13 +1,15 @@ #pragma once #include -#include +#include #include #include #include namespace Elixir::Aether::Simulation { + using namespace Materials::Rendering; + /** * @brief Retains the particle-state buffer for one simulation layout. * diff --git a/Elixir/Source/Engine/Aether/System.h b/Elixir/Source/Engine/Aether/System.h index cfe4d8a5..bc4e8617 100644 --- a/Elixir/Source/Engine/Aether/System.h +++ b/Elixir/Source/Engine/Aether/System.h @@ -8,7 +8,7 @@ namespace Elixir { - class MaterialResolver; + namespace Materials::Rendering { class Resolver; } namespace Aether::Runtime { class InstanceRegistry; } } diff --git a/Elixir/Source/Engine/Core/Application.cpp b/Elixir/Source/Engine/Core/Application.cpp index 53cd8acb..7362afce 100644 --- a/Elixir/Source/Engine/Core/Application.cpp +++ b/Elixir/Source/Engine/Core/Application.cpp @@ -13,8 +13,8 @@ #include #include #include -#include -#include +#include +#include #include namespace Elixir diff --git a/Elixir/Source/Engine/Core/Application.h b/Elixir/Source/Engine/Core/Application.h index 4a6dd7d8..fc7e1c49 100644 --- a/Elixir/Source/Engine/Core/Application.h +++ b/Elixir/Source/Engine/Core/Application.h @@ -12,18 +12,18 @@ namespace Elixir { - namespace GUI + namespace GUI { class TextBlock; } + namespace Aether { class Manager; } + namespace Materials { - class TextBlock; - } - - namespace Aether - { - class Manager; + class MaterialSystem; + class MaterialRegistry; } +} - class MaterialSystem; - class MaterialRegistry; +namespace Elixir +{ + using namespace Elixir::Materials; class ELIXIR_API Application { diff --git a/Elixir/Source/Engine/Material/MaterialCompilationCache.cpp b/Elixir/Source/Engine/Materials/Compilation/CompilationCache.cpp similarity index 73% rename from Elixir/Source/Engine/Material/MaterialCompilationCache.cpp rename to Elixir/Source/Engine/Materials/Compilation/CompilationCache.cpp index 9361ecaf..9f65c68b 100644 --- a/Elixir/Source/Engine/Material/MaterialCompilationCache.cpp +++ b/Elixir/Source/Engine/Materials/Compilation/CompilationCache.cpp @@ -1,14 +1,14 @@ #include "epch.h" -#include "MaterialCompilationCache.h" +#include "CompilationCache.h" #include -namespace Elixir +namespace Elixir::Materials::Compilation { - MaterialCompilationCache::MaterialCompilationCache(const ShaderLoader* shaderLoader) + CompilationCache::CompilationCache(const ShaderLoader* shaderLoader) : m_ShaderLoader(shaderLoader) {} - Ref MaterialCompilationCache::GetOrCompile( + Ref CompilationCache::GetOrCompile( const Ref& material ) { @@ -23,8 +23,8 @@ namespace Elixir return entry.Compiled; const auto result = m_ShaderLoader - ? MaterialCompiler::Compile(m_ShaderLoader, *material) - : MaterialCompiler::Build(*material); + ? Compiler::Compile(m_ShaderLoader, *material) + : Compiler::Build(*material); if (!result) { diff --git a/Elixir/Source/Engine/Material/MaterialCompilationCache.h b/Elixir/Source/Engine/Materials/Compilation/CompilationCache.h similarity index 84% rename from Elixir/Source/Engine/Material/MaterialCompilationCache.h rename to Elixir/Source/Engine/Materials/Compilation/CompilationCache.h index 368d7fcd..e18c5efb 100644 --- a/Elixir/Source/Engine/Material/MaterialCompilationCache.h +++ b/Elixir/Source/Engine/Materials/Compilation/CompilationCache.h @@ -1,19 +1,19 @@ #pragma once -#include -#include +#include +#include -namespace Elixir -{ - class ShaderLoader; +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 MaterialCompilationCache final + class ELIXIR_API CompilationCache final { public: /** @@ -24,7 +24,7 @@ namespace Elixir * * @param shaderLoader Shader loader used for full shader compilation. */ - explicit MaterialCompilationCache(const ShaderLoader* shaderLoader); + explicit CompilationCache(const ShaderLoader* shaderLoader); /** * @brief Gets compiled data for a material. diff --git a/Elixir/Source/Engine/Material/MaterialCompiler.cpp b/Elixir/Source/Engine/Materials/Compilation/Compiler.cpp similarity index 94% rename from Elixir/Source/Engine/Material/MaterialCompiler.cpp rename to Elixir/Source/Engine/Materials/Compilation/Compiler.cpp index 2dd2145e..77c4034a 100644 --- a/Elixir/Source/Engine/Material/MaterialCompiler.cpp +++ b/Elixir/Source/Engine/Materials/Compilation/Compiler.cpp @@ -1,7 +1,11 @@ #include "epch.h" -#include "MaterialCompiler.h" +#include "Compiler.h" -namespace Elixir +#include +#include +#include + +namespace Elixir::Materials::Compilation { namespace fs = std::filesystem; @@ -35,7 +39,7 @@ namespace Elixir return ss.str(); } - std::string ValueExpression(const SCompiledMaterialParameter& parameter) + std::string ValueExpression(const SCompiledParameter& parameter) { std::string value = "mat.Values[" + std::to_string(parameter.Slot) + "]"; @@ -74,7 +78,7 @@ namespace Elixir } } - SMaterialCompileResult MaterialCompiler::Build(const Material& material) + SCompileResult Compiler::Build(const Material& material) { std::string diagnostics; if (!material.ValidateGraph(&diagnostics)) @@ -87,7 +91,7 @@ namespace Elixir std::ranges::sort(parameters, {}, &decltype(parameters)::value_type::first); SMaterialGraphBindings bindings; - std::vector layout; + std::vector layout; uint32_t valueSlot = 0; uint32_t textureSlot = 0; @@ -115,10 +119,7 @@ namespace Elixir return { .Material = compiled }; } - SMaterialCompileResult MaterialCompiler::Compile( - const ShaderLoader* loader, - const Material& material - ) + SCompileResult Compiler::Compile(const ShaderLoader* loader, const Material& material) { auto result = Build(material); if (!result) return result; @@ -147,10 +148,7 @@ namespace Elixir return result; } - std::string MaterialCompiler::InjectBody( - const std::string& hlsl, - const std::string& graphBody - ) + std::string Compiler::InjectBody(const std::string& hlsl, const std::string& graphBody) { std::string out = hlsl; @@ -161,10 +159,10 @@ namespace Elixir return out; } - SMaterialCompileResult MaterialCompiler::CompileSurface( + SCompileResult Compiler::CompileSurface( const ShaderLoader* loader, const Material& material, - SMaterialCompileResult result + SCompileResult result ) { const auto hlsl = ReadFile(s_ShadersDir / "Material" / "Material.ps.hlsl"); @@ -222,10 +220,10 @@ namespace Elixir return result; } - SMaterialCompileResult MaterialCompiler::CompileParticleSprite( + SCompileResult Compiler::CompileParticleSprite( const ShaderLoader* loader, const Material& material, - SMaterialCompileResult result + SCompileResult result ) { const auto hlsl = ReadFile(s_ShadersDir / "Material" / "ParticleSprite.ps.hlsl"); @@ -300,10 +298,10 @@ namespace Elixir return result; } - SMaterialCompileResult MaterialCompiler::CompileParticleRibbon( + SCompileResult Compiler::CompileParticleRibbon( const ShaderLoader* loader, const Material& material, - SMaterialCompileResult result + SCompileResult result ) { const auto vertexHlsl = ReadFile(s_ShadersDir / "Material" / "ParticleRibbon.vs.hlsl"); @@ -385,10 +383,10 @@ namespace Elixir return result; } - SMaterialCompileResult MaterialCompiler::CompileParticleMesh( + SCompileResult Compiler::CompileParticleMesh( const ShaderLoader* loader, const Material& material, - SMaterialCompileResult result + SCompileResult result ) { const auto vertexHlsl = ReadFile(s_ShadersDir / "Material" / "ParticleMesh.vs.hlsl"); diff --git a/Elixir/Source/Engine/Material/MaterialCompiler.h b/Elixir/Source/Engine/Materials/Compilation/Compiler.h similarity index 85% rename from Elixir/Source/Engine/Material/MaterialCompiler.h rename to Elixir/Source/Engine/Materials/Compilation/Compiler.h index 7f5cf9a6..de11dbb7 100644 --- a/Elixir/Source/Engine/Material/MaterialCompiler.h +++ b/Elixir/Source/Engine/Materials/Compilation/Compiler.h @@ -1,14 +1,14 @@ #pragma once -#include #include +#include -namespace Elixir +namespace Elixir::Materials::Compilation { /** * @brief Describes one material parameter in compiled GPU data. */ - struct SCompiledMaterialParameter + struct SCompiledParameter { /** @brief Name used by the material graph. */ std::string Name; @@ -47,7 +47,7 @@ namespace Elixir Ref ParticleMeshShader; /** @brief Parameter layout shared by the material graph and GPU data. */ - std::vector Parameters; + std::vector Parameters; /** * @brief Checks whether the compiled material supports a usage. @@ -87,7 +87,7 @@ namespace Elixir /** * @brief Reports the outcome of material compilation. */ - struct SMaterialCompileResult + struct SCompileResult { /** @brief Compiled material data when compilation succeeds. */ Ref Material; @@ -105,7 +105,7 @@ namespace Elixir * Compilation validates the graph, generates HLSL for supported usages, invokes * DXC, and loads the resulting shader programs. */ - class ELIXIR_API MaterialCompiler + class ELIXIR_API Compiler { public: /** @@ -114,7 +114,7 @@ namespace Elixir * @return Compiled metadata, or diagnostics when validation fails. * @note This phase does not invoke the shader compiler. */ - static SMaterialCompileResult Build(const Material& material); + static SCompileResult Build(const Material& material); /** * @brief Compiles a material graph into render-ready shader programs. @@ -123,41 +123,38 @@ namespace Elixir * @return Compiled material data, or diagnostics when compilation fails. * @pre loader is valid. */ - static SMaterialCompileResult Compile( - const ShaderLoader* loader, - const Material& material - ); + 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 SMaterialCompileResult CompileSurface( + static SCompileResult CompileSurface( const ShaderLoader* loader, const Material& material, - SMaterialCompileResult result + SCompileResult result ); /** @brief Compiles the particle sprite shader program. */ - static SMaterialCompileResult CompileParticleSprite( + static SCompileResult CompileParticleSprite( const ShaderLoader* loader, const Material& material, - SMaterialCompileResult result + SCompileResult result ); /** @brief Compiles the particle ribbon shader program. */ - static SMaterialCompileResult CompileParticleRibbon( + static SCompileResult CompileParticleRibbon( const ShaderLoader* loader, const Material& material, - SMaterialCompileResult result + SCompileResult result ); /** @brief Compiles the particle mesh shader program. */ - static SMaterialCompileResult CompileParticleMesh( + static SCompileResult CompileParticleMesh( const ShaderLoader* loader, const Material& material, - SMaterialCompileResult result + SCompileResult result ); }; } diff --git a/Elixir/Source/Engine/Material/DefaultMaterials.cpp b/Elixir/Source/Engine/Materials/DefaultMaterials.cpp similarity index 91% rename from Elixir/Source/Engine/Material/DefaultMaterials.cpp rename to Elixir/Source/Engine/Materials/DefaultMaterials.cpp index 0e215d65..699ea011 100644 --- a/Elixir/Source/Engine/Material/DefaultMaterials.cpp +++ b/Elixir/Source/Engine/Materials/DefaultMaterials.cpp @@ -1,13 +1,13 @@ #include "epch.h" #include "DefaultMaterials.h" -#include -#include -#include +#include +#include +#include -namespace Elixir +namespace Elixir::Materials { - using namespace Materials::Nodes; + using namespace Nodes; namespace { diff --git a/Elixir/Source/Engine/Material/DefaultMaterials.h b/Elixir/Source/Engine/Materials/DefaultMaterials.h similarity index 90% rename from Elixir/Source/Engine/Material/DefaultMaterials.h rename to Elixir/Source/Engine/Materials/DefaultMaterials.h index 8e6eef30..aea4f20c 100644 --- a/Elixir/Source/Engine/Material/DefaultMaterials.h +++ b/Elixir/Source/Engine/Materials/DefaultMaterials.h @@ -1,8 +1,8 @@ #pragma once -#include +#include -namespace Elixir +namespace Elixir::Materials { /** * @brief Number of built-in default material entries. diff --git a/Elixir/Source/Engine/Material/Material.cpp b/Elixir/Source/Engine/Materials/Material.cpp similarity index 98% rename from Elixir/Source/Engine/Material/Material.cpp rename to Elixir/Source/Engine/Materials/Material.cpp index 0104a144..6fc5076a 100644 --- a/Elixir/Source/Engine/Material/Material.cpp +++ b/Elixir/Source/Engine/Materials/Material.cpp @@ -1,9 +1,9 @@ #include "epch.h" #include "Material.h" -#include +#include -namespace Elixir +namespace Elixir::Materials { Ref Material::CreateInstance() { diff --git a/Elixir/Source/Engine/Material/Material.h b/Elixir/Source/Engine/Materials/Material.h similarity index 98% rename from Elixir/Source/Engine/Material/Material.h rename to Elixir/Source/Engine/Materials/Material.h index 3b842725..8e25774f 100644 --- a/Elixir/Source/Engine/Material/Material.h +++ b/Elixir/Source/Engine/Materials/Material.h @@ -1,9 +1,9 @@ #pragma once -#include -#include +#include +#include -namespace Elixir +namespace Elixir::Materials { class MaterialInstance; diff --git a/Elixir/Source/Engine/Material/MaterialGraph.cpp b/Elixir/Source/Engine/Materials/MaterialGraph.cpp similarity index 98% rename from Elixir/Source/Engine/Material/MaterialGraph.cpp rename to Elixir/Source/Engine/Materials/MaterialGraph.cpp index 125ac394..5ac852a8 100644 --- a/Elixir/Source/Engine/Material/MaterialGraph.cpp +++ b/Elixir/Source/Engine/Materials/MaterialGraph.cpp @@ -1,9 +1,9 @@ #include "epch.h" #include "MaterialGraph.h" -#include +#include -namespace Elixir +namespace Elixir::Materials { namespace { diff --git a/Elixir/Source/Engine/Material/MaterialGraph.h b/Elixir/Source/Engine/Materials/MaterialGraph.h similarity index 98% rename from Elixir/Source/Engine/Material/MaterialGraph.h rename to Elixir/Source/Engine/Materials/MaterialGraph.h index ce7bd5ef..585bfb68 100644 --- a/Elixir/Source/Engine/Material/MaterialGraph.h +++ b/Elixir/Source/Engine/Materials/MaterialGraph.h @@ -1,8 +1,8 @@ #pragma once -#include +#include -namespace Elixir +namespace Elixir::Materials { /** * @brief Defines a surface property driven by a material graph. diff --git a/Elixir/Source/Engine/Material/MaterialInstance.cpp b/Elixir/Source/Engine/Materials/MaterialInstance.cpp similarity index 98% rename from Elixir/Source/Engine/Material/MaterialInstance.cpp rename to Elixir/Source/Engine/Materials/MaterialInstance.cpp index bb677273..630f32cf 100644 --- a/Elixir/Source/Engine/Material/MaterialInstance.cpp +++ b/Elixir/Source/Engine/Materials/MaterialInstance.cpp @@ -1,7 +1,7 @@ #include "epch.h" #include "MaterialInstance.h" -namespace Elixir +namespace Elixir::Materials { bool MaterialInstance::SetScalar(const std::string& name, const float value) { diff --git a/Elixir/Source/Engine/Material/MaterialInstance.h b/Elixir/Source/Engine/Materials/MaterialInstance.h similarity index 98% rename from Elixir/Source/Engine/Material/MaterialInstance.h rename to Elixir/Source/Engine/Materials/MaterialInstance.h index dcbf615d..6a2910a4 100644 --- a/Elixir/Source/Engine/Material/MaterialInstance.h +++ b/Elixir/Source/Engine/Materials/MaterialInstance.h @@ -1,8 +1,8 @@ #pragma once -#include +#include -namespace Elixir +namespace Elixir::Materials { /** * @brief Stores parameter overrides for one material. diff --git a/Elixir/Source/Engine/Material/MaterialNode.cpp b/Elixir/Source/Engine/Materials/MaterialNode.cpp similarity index 96% rename from Elixir/Source/Engine/Material/MaterialNode.cpp rename to Elixir/Source/Engine/Materials/MaterialNode.cpp index 2b55d6f4..41001e43 100644 --- a/Elixir/Source/Engine/Material/MaterialNode.cpp +++ b/Elixir/Source/Engine/Materials/MaterialNode.cpp @@ -1,10 +1,10 @@ #include "epch.h" #include "MaterialNode.h" -#include -#include +#include +#include -namespace Elixir +namespace Elixir::Materials { namespace { diff --git a/Elixir/Source/Engine/Material/MaterialNode.h b/Elixir/Source/Engine/Materials/MaterialNode.h similarity index 99% rename from Elixir/Source/Engine/Material/MaterialNode.h rename to Elixir/Source/Engine/Materials/MaterialNode.h index d6af28db..2e237f33 100644 --- a/Elixir/Source/Engine/Material/MaterialNode.h +++ b/Elixir/Source/Engine/Materials/MaterialNode.h @@ -3,7 +3,7 @@ #include #include -namespace Elixir +namespace Elixir::Materials { struct SMaterialGraphBindings; enum class EMaterialValueType : uint8_t; diff --git a/Elixir/Source/Engine/Material/MaterialParameter.h b/Elixir/Source/Engine/Materials/MaterialParameter.h similarity index 98% rename from Elixir/Source/Engine/Material/MaterialParameter.h rename to Elixir/Source/Engine/Materials/MaterialParameter.h index d806af34..bd80b323 100644 --- a/Elixir/Source/Engine/Material/MaterialParameter.h +++ b/Elixir/Source/Engine/Materials/MaterialParameter.h @@ -1,6 +1,6 @@ #pragma once -namespace Elixir +namespace Elixir::Materials { /** * @brief Identifies the category of a material parameter. diff --git a/Elixir/Source/Engine/Material/MaterialRegistry.cpp b/Elixir/Source/Engine/Materials/MaterialRegistry.cpp similarity index 93% rename from Elixir/Source/Engine/Material/MaterialRegistry.cpp rename to Elixir/Source/Engine/Materials/MaterialRegistry.cpp index 750d17d7..aec7154c 100644 --- a/Elixir/Source/Engine/Material/MaterialRegistry.cpp +++ b/Elixir/Source/Engine/Materials/MaterialRegistry.cpp @@ -1,9 +1,9 @@ #include "epch.h" #include "MaterialRegistry.h" -#include +#include -namespace Elixir +namespace Elixir::Materials { MaterialRegistry::MaterialRegistry() : m_Defaults(CreateDefaultMaterials()) diff --git a/Elixir/Source/Engine/Material/MaterialRegistry.h b/Elixir/Source/Engine/Materials/MaterialRegistry.h similarity index 94% rename from Elixir/Source/Engine/Material/MaterialRegistry.h rename to Elixir/Source/Engine/Materials/MaterialRegistry.h index 15f35045..c19d0648 100644 --- a/Elixir/Source/Engine/Material/MaterialRegistry.h +++ b/Elixir/Source/Engine/Materials/MaterialRegistry.h @@ -1,8 +1,8 @@ #pragma once -#include +#include -namespace Elixir +namespace Elixir::Materials { /** diff --git a/Elixir/Source/Engine/Material/MaterialSystem.cpp b/Elixir/Source/Engine/Materials/MaterialSystem.cpp similarity index 92% rename from Elixir/Source/Engine/Material/MaterialSystem.cpp rename to Elixir/Source/Engine/Materials/MaterialSystem.cpp index d5538bc4..b890ca89 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.cpp +++ b/Elixir/Source/Engine/Materials/MaterialSystem.cpp @@ -1,7 +1,7 @@ #include "epch.h" #include "MaterialSystem.h" -namespace Elixir +namespace Elixir::Materials { namespace { @@ -9,14 +9,14 @@ namespace Elixir { EMaterialPass Pass = EMaterialPass::ParticleSprite; uint32_t GeometryIndex = UINT32_MAX; - SMaterialProgramKey Program; + SProgramKey Program; bool operator==(const SMaterialBatchKey&) const = default; }; struct SMaterialBatchItem { - const SMaterialRenderItem* Item = nullptr; + const SRenderItem* Item = nullptr; uint32_t MaterialIndex = UINT32_MAX; }; @@ -46,7 +46,7 @@ namespace Elixir sizeof(SMaterialFrameData) * m_MaterialCapacity) ), m_Textures(context), - m_Renderer(CreateScope( + m_Renderer(CreateScope( context, m_FrameBuffer, m_Textures, @@ -60,7 +60,7 @@ namespace Elixir { m_Textures.BeginFrame(submissionSerial); - const auto table = CreateRef( + const auto table = CreateRef( m_MaterialCapacity, m_Textures.GetFallbackIndex(), [this](const Ref& texture) @@ -87,7 +87,7 @@ namespace Elixir return { table, table->GetCount(), submissionSerial }; } - std::optional MaterialSystem::GetProgramKey( + std::optional MaterialSystem::GetProgramKey( const EMaterialPass pass, const MaterialRenderProxy& material ) const @@ -95,8 +95,8 @@ namespace Elixir return m_Renderer->GetProgramKey(pass, material); } - std::optional MaterialSystem::PrepareMaterialPass( - const SMaterialPassRequest& request + std::optional MaterialSystem::PrepareMaterialPass( + const SPassRequest& request ) const { return m_Renderer->Prepare(request); @@ -162,8 +162,8 @@ namespace Elixir { if (left.Key.Pass != right.Key.Pass) { - return MaterialRenderer::GetPassOrder(left.Key.Pass) < - MaterialRenderer::GetPassOrder(right.Key.Pass); + return Renderer::GetPassOrder(left.Key.Pass) < + Renderer::GetPassOrder(right.Key.Pass); } if (left.Key.GeometryIndex != right.Key.GeometryIndex) diff --git a/Elixir/Source/Engine/Material/MaterialSystem.h b/Elixir/Source/Engine/Materials/MaterialSystem.h similarity index 87% rename from Elixir/Source/Engine/Material/MaterialSystem.h rename to Elixir/Source/Engine/Materials/MaterialSystem.h index a9c63ea3..fee5c26c 100644 --- a/Elixir/Source/Engine/Material/MaterialSystem.h +++ b/Elixir/Source/Engine/Materials/MaterialSystem.h @@ -1,15 +1,17 @@ #pragma once #include -#include -#include -#include -#include -#include +#include +#include +#include +#include +#include -namespace Elixir +namespace Elixir { class ShaderLoader; } + +namespace Elixir::Materials { - class ShaderLoader; + using namespace Rendering; /** * @brief Configures the initial storage used for frame material data. @@ -26,7 +28,7 @@ namespace Elixir struct SMaterialFrameSnapshot { /** @brief Maps resolved material proxies to their frame data. */ - Ref Table; + Ref Table; /** @brief Number of materials stored in Table. */ uint32_t MaterialCount = 0; @@ -87,7 +89,7 @@ namespace Elixir * @param material Resolved material data. * @return The program key, or no value when the pass is unsupported. */ - std::optional GetProgramKey( + std::optional GetProgramKey( EMaterialPass pass, const MaterialRenderProxy& material ) const; @@ -97,9 +99,7 @@ namespace Elixir * @param request Material pass and geometry requirements. * @return Prepared pass state, or no value when preparation fails. */ - std::optional PrepareMaterialPass( - const SMaterialPassRequest& request - ) const; + std::optional PrepareMaterialPass(const SPassRequest& request) const; /** * @brief Records draw commands for the scene materials. @@ -135,7 +135,7 @@ namespace Elixir private: uint32_t m_MaterialCapacity = 0; Ref m_FrameBuffer; - MaterialTextureRegistry m_Textures; - Scope m_Renderer; + TextureRegistry m_Textures; + Scope m_Renderer; }; } diff --git a/Elixir/Source/Engine/Material/Nodes/Add.h b/Elixir/Source/Engine/Materials/Nodes/Add.h similarity index 92% rename from Elixir/Source/Engine/Material/Nodes/Add.h rename to Elixir/Source/Engine/Materials/Nodes/Add.h index fe385f80..c91b93c6 100644 --- a/Elixir/Source/Engine/Material/Nodes/Add.h +++ b/Elixir/Source/Engine/Materials/Nodes/Add.h @@ -1,6 +1,6 @@ #pragma once -#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/BinaryOperationNode.h b/Elixir/Source/Engine/Materials/Nodes/BinaryOperationNode.h similarity index 89% rename from Elixir/Source/Engine/Material/Nodes/BinaryOperationNode.h rename to Elixir/Source/Engine/Materials/Nodes/BinaryOperationNode.h index be6bc337..e54beecb 100644 --- a/Elixir/Source/Engine/Material/Nodes/BinaryOperationNode.h +++ b/Elixir/Source/Engine/Materials/Nodes/BinaryOperationNode.h @@ -1,7 +1,7 @@ #pragma once -#include -#include +#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/Checkerboard.h b/Elixir/Source/Engine/Materials/Nodes/Checkerboard.h similarity index 94% rename from Elixir/Source/Engine/Material/Nodes/Checkerboard.h rename to Elixir/Source/Engine/Materials/Nodes/Checkerboard.h index 0bdb9b49..f76a8136 100644 --- a/Elixir/Source/Engine/Material/Nodes/Checkerboard.h +++ b/Elixir/Source/Engine/Materials/Nodes/Checkerboard.h @@ -1,7 +1,7 @@ #pragma once -#include -#include +#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/ComponentMask.h b/Elixir/Source/Engine/Materials/Nodes/ComponentMask.h similarity index 94% rename from Elixir/Source/Engine/Material/Nodes/ComponentMask.h rename to Elixir/Source/Engine/Materials/Nodes/ComponentMask.h index 1c1c8571..7c538789 100644 --- a/Elixir/Source/Engine/Material/Nodes/ComponentMask.h +++ b/Elixir/Source/Engine/Materials/Nodes/ComponentMask.h @@ -1,7 +1,7 @@ #pragma once -#include -#include +#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/Constant.h b/Elixir/Source/Engine/Materials/Nodes/Constant.h similarity index 95% rename from Elixir/Source/Engine/Material/Nodes/Constant.h rename to Elixir/Source/Engine/Materials/Nodes/Constant.h index fc9dc1a9..bff946c9 100644 --- a/Elixir/Source/Engine/Material/Nodes/Constant.h +++ b/Elixir/Source/Engine/Materials/Nodes/Constant.h @@ -1,7 +1,7 @@ #pragma once -#include -#include +#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/Divide.h b/Elixir/Source/Engine/Materials/Nodes/Divide.h similarity index 92% rename from Elixir/Source/Engine/Material/Nodes/Divide.h rename to Elixir/Source/Engine/Materials/Nodes/Divide.h index af29325c..262aa796 100644 --- a/Elixir/Source/Engine/Material/Nodes/Divide.h +++ b/Elixir/Source/Engine/Materials/Nodes/Divide.h @@ -1,6 +1,6 @@ #pragma once -#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/Dot.h b/Elixir/Source/Engine/Materials/Nodes/Dot.h similarity index 100% rename from Elixir/Source/Engine/Material/Nodes/Dot.h rename to Elixir/Source/Engine/Materials/Nodes/Dot.h diff --git a/Elixir/Source/Engine/Material/Nodes/Fresnel.h b/Elixir/Source/Engine/Materials/Nodes/Fresnel.h similarity index 100% rename from Elixir/Source/Engine/Material/Nodes/Fresnel.h rename to Elixir/Source/Engine/Materials/Nodes/Fresnel.h diff --git a/Elixir/Source/Engine/Material/Nodes/Lerp.h b/Elixir/Source/Engine/Materials/Nodes/Lerp.h similarity index 100% rename from Elixir/Source/Engine/Material/Nodes/Lerp.h rename to Elixir/Source/Engine/Materials/Nodes/Lerp.h diff --git a/Elixir/Source/Engine/Material/Nodes/Multiply.h b/Elixir/Source/Engine/Materials/Nodes/Multiply.h similarity index 92% rename from Elixir/Source/Engine/Material/Nodes/Multiply.h rename to Elixir/Source/Engine/Materials/Nodes/Multiply.h index 758d2df8..48f8a24a 100644 --- a/Elixir/Source/Engine/Material/Nodes/Multiply.h +++ b/Elixir/Source/Engine/Materials/Nodes/Multiply.h @@ -1,6 +1,6 @@ #pragma once -#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/OneMinus.h b/Elixir/Source/Engine/Materials/Nodes/OneMinus.h similarity index 100% rename from Elixir/Source/Engine/Material/Nodes/OneMinus.h rename to Elixir/Source/Engine/Materials/Nodes/OneMinus.h diff --git a/Elixir/Source/Engine/Material/Nodes/Panner.h b/Elixir/Source/Engine/Materials/Nodes/Panner.h similarity index 92% rename from Elixir/Source/Engine/Material/Nodes/Panner.h rename to Elixir/Source/Engine/Materials/Nodes/Panner.h index c8c4816d..00cac758 100644 --- a/Elixir/Source/Engine/Material/Nodes/Panner.h +++ b/Elixir/Source/Engine/Materials/Nodes/Panner.h @@ -1,7 +1,7 @@ #pragma once -#include -#include +#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/Parameter.h b/Elixir/Source/Engine/Materials/Nodes/Parameter.h similarity index 93% rename from Elixir/Source/Engine/Material/Nodes/Parameter.h rename to Elixir/Source/Engine/Materials/Nodes/Parameter.h index ba457b8a..9ba541f9 100644 --- a/Elixir/Source/Engine/Material/Nodes/Parameter.h +++ b/Elixir/Source/Engine/Materials/Nodes/Parameter.h @@ -1,7 +1,7 @@ #pragma once -#include -#include +#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/Power.h b/Elixir/Source/Engine/Materials/Nodes/Power.h similarity index 100% rename from Elixir/Source/Engine/Material/Nodes/Power.h rename to Elixir/Source/Engine/Materials/Nodes/Power.h diff --git a/Elixir/Source/Engine/Material/Nodes/RadialGradientExponential.h b/Elixir/Source/Engine/Materials/Nodes/RadialGradientExponential.h similarity index 95% rename from Elixir/Source/Engine/Material/Nodes/RadialGradientExponential.h rename to Elixir/Source/Engine/Materials/Nodes/RadialGradientExponential.h index d4b7dd79..90931c6b 100644 --- a/Elixir/Source/Engine/Material/Nodes/RadialGradientExponential.h +++ b/Elixir/Source/Engine/Materials/Nodes/RadialGradientExponential.h @@ -1,7 +1,7 @@ #pragma once -#include -#include +#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/Saturate.h b/Elixir/Source/Engine/Materials/Nodes/Saturate.h similarity index 100% rename from Elixir/Source/Engine/Material/Nodes/Saturate.h rename to Elixir/Source/Engine/Materials/Nodes/Saturate.h diff --git a/Elixir/Source/Engine/Material/Nodes/Sine.h b/Elixir/Source/Engine/Materials/Nodes/Sine.h similarity index 100% rename from Elixir/Source/Engine/Material/Nodes/Sine.h rename to Elixir/Source/Engine/Materials/Nodes/Sine.h diff --git a/Elixir/Source/Engine/Material/Nodes/Subtract.h b/Elixir/Source/Engine/Materials/Nodes/Subtract.h similarity index 92% rename from Elixir/Source/Engine/Material/Nodes/Subtract.h rename to Elixir/Source/Engine/Materials/Nodes/Subtract.h index edce4414..093d2546 100644 --- a/Elixir/Source/Engine/Material/Nodes/Subtract.h +++ b/Elixir/Source/Engine/Materials/Nodes/Subtract.h @@ -1,6 +1,6 @@ #pragma once -#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/TexCoord.h b/Elixir/Source/Engine/Materials/Nodes/TexCoord.h similarity index 100% rename from Elixir/Source/Engine/Material/Nodes/TexCoord.h rename to Elixir/Source/Engine/Materials/Nodes/TexCoord.h diff --git a/Elixir/Source/Engine/Material/Nodes/TextureSample.h b/Elixir/Source/Engine/Materials/Nodes/TextureSample.h similarity index 94% rename from Elixir/Source/Engine/Material/Nodes/TextureSample.h rename to Elixir/Source/Engine/Materials/Nodes/TextureSample.h index aa487a94..8f598628 100644 --- a/Elixir/Source/Engine/Material/Nodes/TextureSample.h +++ b/Elixir/Source/Engine/Materials/Nodes/TextureSample.h @@ -1,7 +1,7 @@ #pragma once -#include -#include +#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/Time.h b/Elixir/Source/Engine/Materials/Nodes/Time.h similarity index 84% rename from Elixir/Source/Engine/Material/Nodes/Time.h rename to Elixir/Source/Engine/Materials/Nodes/Time.h index c7836b52..e9542c70 100644 --- a/Elixir/Source/Engine/Material/Nodes/Time.h +++ b/Elixir/Source/Engine/Materials/Nodes/Time.h @@ -1,7 +1,7 @@ #pragma once -#include -#include +#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/Nodes/UnaryOperationNode.h b/Elixir/Source/Engine/Materials/Nodes/UnaryOperationNode.h similarity index 82% rename from Elixir/Source/Engine/Material/Nodes/UnaryOperationNode.h rename to Elixir/Source/Engine/Materials/Nodes/UnaryOperationNode.h index 5e2a8cb6..b90f0d76 100644 --- a/Elixir/Source/Engine/Material/Nodes/UnaryOperationNode.h +++ b/Elixir/Source/Engine/Materials/Nodes/UnaryOperationNode.h @@ -1,7 +1,7 @@ #pragma once -#include -#include +#include +#include namespace Elixir::Materials::Nodes { diff --git a/Elixir/Source/Engine/Material/MaterialFrameTable.cpp b/Elixir/Source/Engine/Materials/Rendering/FrameTable.cpp similarity index 79% rename from Elixir/Source/Engine/Material/MaterialFrameTable.cpp rename to Elixir/Source/Engine/Materials/Rendering/FrameTable.cpp index 47076589..e89b37aa 100644 --- a/Elixir/Source/Engine/Material/MaterialFrameTable.cpp +++ b/Elixir/Source/Engine/Materials/Rendering/FrameTable.cpp @@ -1,9 +1,9 @@ #include "epch.h" -#include "MaterialFrameTable.h" +#include "FrameTable.h" -namespace Elixir +namespace Elixir::Materials::Rendering { - MaterialFrameTable::MaterialFrameTable( + FrameTable::FrameTable( const uint32_t capacity, const uint32_t fallbackTextureIndex, TextureIndexResolver resolver @@ -11,7 +11,7 @@ namespace Elixir m_FallbackTextureIndex(fallbackTextureIndex), m_TextureIndexResolver(std::move(resolver)) {} - std::optional MaterialFrameTable::Add(const MaterialRenderProxy& material) + std::optional FrameTable::Add(const MaterialRenderProxy& material) { if (const auto found = Find(material)) return found; @@ -25,7 +25,7 @@ namespace Elixir return index; } - std::optional MaterialFrameTable::Find(const MaterialRenderProxy& material) const + std::optional FrameTable::Find(const MaterialRenderProxy& material) const { const auto found = m_Indices.find(&material); if (found == m_Indices.end()) @@ -34,7 +34,7 @@ namespace Elixir return found->second; } - SMaterialFrameData MaterialFrameTable::BuildData(const MaterialRenderProxy& material) const + SMaterialFrameData FrameTable::BuildData(const MaterialRenderProxy& material) const { SMaterialFrameData data{}; std::ranges::fill(data.TextureIndices, m_FallbackTextureIndex); diff --git a/Elixir/Source/Engine/Material/MaterialFrameTable.h b/Elixir/Source/Engine/Materials/Rendering/FrameTable.h similarity index 93% rename from Elixir/Source/Engine/Material/MaterialFrameTable.h rename to Elixir/Source/Engine/Materials/Rendering/FrameTable.h index 116ef185..bcca3e94 100644 --- a/Elixir/Source/Engine/Material/MaterialFrameTable.h +++ b/Elixir/Source/Engine/Materials/Rendering/FrameTable.h @@ -1,8 +1,11 @@ #pragma once -#include +#include +#include -namespace Elixir +#include + +namespace Elixir::Materials::Rendering { /** * @brief Defines the GPU data layout for one resolved material. @@ -24,7 +27,7 @@ namespace Elixir * Each material proxy is assigned one stable table index. Values and textures * beyond the fixed GPU layout are not included. */ - class ELIXIR_API MaterialFrameTable final + class ELIXIR_API FrameTable final { public: /** @brief Resolves a texture into an index usable by the GPU. */ @@ -37,7 +40,7 @@ namespace Elixir * @param resolver Function that resolves material textures to GPU indices. * @pre resolver is valid. */ - MaterialFrameTable( + FrameTable( uint32_t capacity, uint32_t fallbackTextureIndex, TextureIndexResolver resolver diff --git a/Elixir/Source/Engine/Material/MaterialRenderProxy.cpp b/Elixir/Source/Engine/Materials/Rendering/MaterialRenderProxy.cpp similarity index 94% rename from Elixir/Source/Engine/Material/MaterialRenderProxy.cpp rename to Elixir/Source/Engine/Materials/Rendering/MaterialRenderProxy.cpp index 704b20e1..fc4669fa 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderProxy.cpp +++ b/Elixir/Source/Engine/Materials/Rendering/MaterialRenderProxy.cpp @@ -1,7 +1,9 @@ #include "epch.h" #include "MaterialRenderProxy.h" -namespace Elixir +#include + +namespace Elixir::Materials::Rendering { Ref MaterialRenderProxy::Create( Ref material, diff --git a/Elixir/Source/Engine/Material/MaterialRenderProxy.h b/Elixir/Source/Engine/Materials/Rendering/MaterialRenderProxy.h similarity index 94% rename from Elixir/Source/Engine/Material/MaterialRenderProxy.h rename to Elixir/Source/Engine/Materials/Rendering/MaterialRenderProxy.h index 5c0e157b..385d6398 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderProxy.h +++ b/Elixir/Source/Engine/Materials/Rendering/MaterialRenderProxy.h @@ -1,10 +1,11 @@ #pragma once -#include -#include +#include -namespace Elixir +namespace Elixir::Materials::Rendering { + using namespace Compilation; + /** * @brief Stores render-ready data for one material instance. * diff --git a/Elixir/Source/Engine/Material/MaterialRenderScene.cpp b/Elixir/Source/Engine/Materials/Rendering/MaterialRenderScene.cpp similarity index 84% rename from Elixir/Source/Engine/Material/MaterialRenderScene.cpp rename to Elixir/Source/Engine/Materials/Rendering/MaterialRenderScene.cpp index 45df4fbe..b2ad415d 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderScene.cpp +++ b/Elixir/Source/Engine/Materials/Rendering/MaterialRenderScene.cpp @@ -1,7 +1,7 @@ #include "epch.h" #include "MaterialRenderScene.h" -namespace Elixir +namespace Elixir::Materials::Rendering { /* SMaterialPushConstants */ @@ -29,7 +29,7 @@ namespace Elixir /* MaterialRenderScene */ - uint32_t MaterialRenderScene::AddGeometry(SMaterialRenderGeometry geometry) + uint32_t MaterialRenderScene::AddGeometry(SRenderGeometry geometry) { EE_CORE_ASSERT( geometry.Pipeline.VertexLayout, @@ -42,7 +42,7 @@ namespace Elixir return index; } - void MaterialRenderScene::Add(SMaterialRenderItem item) + void MaterialRenderScene::Add(SRenderItem item) { EE_CORE_ASSERT( item.GeometryIndex < m_Geometries.size(), @@ -52,7 +52,7 @@ namespace Elixir m_Items.push_back(std::move(item)); } - const SMaterialRenderGeometry* MaterialRenderScene::FindGeometry(const uint32_t index) const + const SRenderGeometry* MaterialRenderScene::FindGeometry(const uint32_t index) const { if (index >= m_Geometries.size()) return nullptr; diff --git a/Elixir/Source/Engine/Material/MaterialRenderScene.h b/Elixir/Source/Engine/Materials/Rendering/MaterialRenderScene.h similarity index 85% rename from Elixir/Source/Engine/Material/MaterialRenderScene.h rename to Elixir/Source/Engine/Materials/Rendering/MaterialRenderScene.h index f446d0a4..51f5dfc3 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderScene.h +++ b/Elixir/Source/Engine/Materials/Rendering/MaterialRenderScene.h @@ -1,8 +1,10 @@ #pragma once -#include +#include +#include +#include -namespace Elixir +namespace Elixir::Materials::Rendering { /** * @brief Stores push constants for one material draw. @@ -68,7 +70,7 @@ namespace Elixir /** * @brief Associates a vertex buffer with a pipeline binding. */ - struct SMaterialVertexBufferBinding + struct SVertexBufferBinding { /** Source vertex buffer. */ const Buffer* Buffer = nullptr; @@ -80,7 +82,7 @@ namespace Elixir /** * @brief Describes one indexed range of a draw call. */ - struct SMaterialDrawCommand + struct SDrawCommand { /** Number of vertices to draw. */ uint32_t VertexCount = 0; @@ -98,25 +100,25 @@ namespace Elixir /** * @brief Stores shared resources for render items with compatible geometry. */ - struct SMaterialRenderGeometry + struct SRenderGeometry { /** Pipeline configuration for the geometry. */ - SMaterialPipelineRequest Pipeline; + SPipelineRequest Pipeline; /** Constant buffers required by the material pass. */ - std::vector ConstantBuffers; + std::vector ConstantBuffers; /** Storage buffers required by the material pass. */ - std::vector StorageBuffers; + std::vector StorageBuffers; /** Vertex buffers required by the draw. */ - std::vector VertexBuffers; + std::vector VertexBuffers; }; /** * @brief Describes one material draw recorded for the current frame. */ - struct SMaterialRenderItem + struct SRenderItem { /** Material pass used to render the item. */ EMaterialPass Pass = EMaterialPass::ParticleSprite; @@ -131,7 +133,7 @@ namespace Elixir SMaterialPushConstants PushConstants; /** Draw range for the item. */ - SMaterialDrawCommand Draw; + SDrawCommand Draw; }; /** @@ -151,7 +153,7 @@ namespace Elixir * * @pre `geometry.Pipeline.VertexLayout` is not null. */ - uint32_t AddGeometry(SMaterialRenderGeometry geometry); + uint32_t AddGeometry(SRenderGeometry geometry); /** * @brief Adds a material draw to the scene. @@ -160,7 +162,7 @@ namespace Elixir * * @pre `item.GeometryIndex` identifies geometry added to this scene. */ - void Add(SMaterialRenderItem item); + void Add(SRenderItem item); /** * @brief Finds geometry by index. @@ -168,16 +170,16 @@ namespace Elixir * @param index Geometry index. * @return The geometry, or null when @p index is invalid. */ - const SMaterialRenderGeometry* FindGeometry(uint32_t index) const; + 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; } + std::span GetItems() const { return m_Items; } private: - std::vector m_Geometries; - std::vector m_Items; + std::vector m_Geometries; + std::vector m_Items; }; } diff --git a/Elixir/Source/Engine/Material/MaterialResolver.h b/Elixir/Source/Engine/Materials/Rendering/MaterialResolver.h similarity index 82% rename from Elixir/Source/Engine/Material/MaterialResolver.h rename to Elixir/Source/Engine/Materials/Rendering/MaterialResolver.h index 89cfce30..c5f9972f 100644 --- a/Elixir/Source/Engine/Material/MaterialResolver.h +++ b/Elixir/Source/Engine/Materials/Rendering/MaterialResolver.h @@ -1,9 +1,9 @@ #pragma once -#include -#include +#include +#include -namespace Elixir +namespace Elixir::Materials::Rendering { /** * @brief Resolves a material instance into render-ready material data. diff --git a/Elixir/Source/Engine/Material/MaterialRenderer.cpp b/Elixir/Source/Engine/Materials/Rendering/Renderer.cpp similarity index 83% rename from Elixir/Source/Engine/Material/MaterialRenderer.cpp rename to Elixir/Source/Engine/Materials/Rendering/Renderer.cpp index 16fe2113..0dd56e63 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderer.cpp +++ b/Elixir/Source/Engine/Materials/Rendering/Renderer.cpp @@ -1,23 +1,22 @@ #include "epch.h" -#include "MaterialRenderer.h" +#include "Renderer.h" #include +#include -namespace Elixir +namespace Elixir::Materials::Rendering { - MaterialRenderer::MaterialRenderer( + Renderer::Renderer( const GraphicsContext* context, Ref frameBuffer, - const MaterialTextureRegistry& textures, + const TextureRegistry& textures, const ShaderLoader* shaderLoader ) : m_FrameBuffer(std::move(frameBuffer)), m_Textures(textures), m_CompilationCache(shaderLoader), m_Context(context) {} - std::optional MaterialRenderer::Prepare( - const SMaterialPassRequest& request - ) + std::optional Renderer::Prepare(const SPassRequest& request) { if (!request.Material || !request.Pipeline.VertexLayout) return std::nullopt; @@ -41,15 +40,13 @@ namespace Elixir ); } - return SPreparedMaterialPass{ + return SPreparedPass{ .Shader = shader, .Pipeline = GetPipeline(request.Pass, shader, request.Pipeline), }; } - Ref MaterialRenderer::Resolve( - const Ref& instance - ) + Ref Renderer::Resolve(const Ref& instance) { if (!instance || !instance->GetParent()) return nullptr; @@ -59,7 +56,7 @@ namespace Elixir : nullptr; } - std::optional MaterialRenderer::GetProgramKey( + std::optional Renderer::GetProgramKey( const EMaterialPass pass, const MaterialRenderProxy& material ) @@ -73,38 +70,38 @@ namespace Elixir if (!shader) return std::nullopt; - return SMaterialProgramKey{ .Identity = shader.get() }; + return SProgramKey{ .Identity = shader.get() }; } - EMaterialUsage MaterialRenderer::GetUsage(EMaterialPass pass) + 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; + 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 MaterialRenderer::GetPassOrder(const EMaterialPass pass) + uint32_t Renderer::GetPassOrder(const EMaterialPass pass) { switch (pass) { - case EMaterialPass::ParticleSprite: return 2; - case EMaterialPass::ParticleRibbon: return 1; - case EMaterialPass::ParticleMesh: return 0; + case EMaterialPass::ParticleSprite: return 2; + case EMaterialPass::ParticleRibbon: return 1; + case EMaterialPass::ParticleMesh: return 0; } return UINT32_MAX; } - Ref MaterialRenderer::GetPipeline( + Ref Renderer::GetPipeline( const EMaterialPass pass, const Ref& shader, - const SMaterialPipelineRequest& request + const SPipelineRequest& request ) { const SPipelineKey key{ @@ -156,9 +153,9 @@ namespace Elixir return pipeline; } - bool MaterialRenderer::BindDescriptorResources( + bool Renderer::BindDescriptorResources( const Ref& shader, - const SMaterialPassRequest& request + const SPassRequest& request ) { SDescriptorBindingState state{ diff --git a/Elixir/Source/Engine/Material/MaterialRenderer.h b/Elixir/Source/Engine/Materials/Rendering/Renderer.h similarity index 84% rename from Elixir/Source/Engine/Material/MaterialRenderer.h rename to Elixir/Source/Engine/Materials/Rendering/Renderer.h index cca72e8d..fe913b89 100644 --- a/Elixir/Source/Engine/Material/MaterialRenderer.h +++ b/Elixir/Source/Engine/Materials/Rendering/Renderer.h @@ -1,29 +1,22 @@ #pragma once -#include - #include #include -#include -#include -#include +#include +#include +#include -namespace Elixir -{ - class ShaderLoader; +namespace Elixir { class ShaderLoader; } +namespace Elixir::Materials::Rendering +{ /** * @brief Identifies a material render pass. */ enum class EMaterialPass : uint8_t { - /** Pass for particle sprites. */ ParticleSprite, - - /** Pass for particle ribbons. */ ParticleRibbon, - - /** Pass for particle meshes. */ ParticleMesh, }; @@ -32,7 +25,7 @@ namespace Elixir * * The identity is suitable for grouping render items that use the same shader. */ - struct SMaterialProgramKey + struct SProgramKey { /** Opaque identity of the compiled shader program. */ const void* Identity = nullptr; @@ -41,13 +34,13 @@ namespace Elixir explicit operator bool() const { return Identity != nullptr; } /** @brief Compares two program keys. */ - bool operator==(const SMaterialProgramKey&) const = default; + bool operator==(const SProgramKey&) const = default; }; /** * @brief Describes the vertex input layout for a material pipeline. */ - struct SMaterialPipelineRequest + struct SPipelineRequest { /** Stable key that identifies the vertex layout. */ uint64_t VertexLayoutKey = 0; @@ -59,7 +52,7 @@ namespace Elixir /** * @brief Associates a constant buffer with a shader binding name. */ - struct SMaterialConstantBufferBinding + struct SConstantBufferBinding { /** Shader binding name. */ std::string_view Name; @@ -74,7 +67,7 @@ namespace Elixir /** * @brief Associates a storage buffer with a shader binding name. */ - struct SMaterialStorageBufferBinding + struct SStorageBufferBinding { /** Shader binding name. */ std::string_view Name; @@ -86,10 +79,10 @@ namespace Elixir /** * @brief Groups external buffers required by a material pass. */ - struct SMaterialExternalResources + struct SExternalResources { - std::span ConstantBuffers; - std::span StorageBuffers; + std::span ConstantBuffers; + std::span StorageBuffers; /** * @brief Returns the number of external resource bindings. @@ -104,7 +97,7 @@ namespace Elixir /** * @brief Describes the resources required to prepare one material pass. */ - struct SMaterialPassRequest + struct SPassRequest { /** Material pass to prepare. */ EMaterialPass Pass = EMaterialPass::ParticleSprite; @@ -113,10 +106,10 @@ namespace Elixir const MaterialRenderProxy* Material = nullptr; /** Pipeline requirements for the pass. */ - SMaterialPipelineRequest Pipeline; + SPipelineRequest Pipeline; /** External buffers required by the pass. */ - SMaterialExternalResources ExternalResources; + SExternalResources ExternalResources; /** Push constants applied before the first draw. */ std::span InitialPushConstants; @@ -125,7 +118,7 @@ namespace Elixir /** * @brief Stores a prepared shader and graphics pipeline. */ - struct SPreparedMaterialPass + struct SPreparedPass { /** Shader prepared for the material pass. */ Ref Shader; @@ -143,7 +136,7 @@ namespace Elixir * The renderer caches compiled materials, descriptor bindings, and graphics * pipelines for reuse across render items. */ - class ELIXIR_API MaterialRenderer final + class ELIXIR_API Renderer final { public: /** @@ -154,10 +147,10 @@ namespace Elixir * @param shaderLoader Loader used to compile material shaders. * @pre All arguments are valid for the renderer lifetime. */ - MaterialRenderer( + Renderer( const GraphicsContext* context, Ref frameBuffer, - const MaterialTextureRegistry& textures, + const TextureRegistry& textures, const ShaderLoader* shaderLoader ); @@ -168,9 +161,7 @@ namespace Elixir * @pre `request.Material` is not null. * @pre `request.Pipeline.VertexLayout` is not null. */ - std::optional Prepare( - const SMaterialPassRequest& request - ); + std::optional Prepare(const SPassRequest& request); /** * @brief Resolves an instance into render-ready material data. @@ -185,7 +176,7 @@ namespace Elixir * @param material Resolved material data. * @return Program key, or no value if the material does not support the pass. */ - static std::optional GetProgramKey( + static std::optional GetProgramKey( EMaterialPass pass, const MaterialRenderProxy& material ); @@ -261,20 +252,17 @@ namespace Elixir Ref GetPipeline( EMaterialPass pass, const Ref& shader, - const SMaterialPipelineRequest& request + const SPipelineRequest& request ); /** Binds and validates the descriptor resources for a shader. */ - bool BindDescriptorResources( - const Ref& shader, - const SMaterialPassRequest& request - ); + bool BindDescriptorResources(const Ref& shader, const SPassRequest& request); Ref m_FrameBuffer; - const MaterialTextureRegistry& m_Textures; + const TextureRegistry& m_Textures; std::unordered_map, SPipelineKeyHasher> m_Pipelines; std::unordered_map m_DescriptorBindings; - MaterialCompilationCache m_CompilationCache; + CompilationCache m_CompilationCache; const GraphicsContext* m_Context = nullptr; }; diff --git a/Elixir/Source/Engine/Material/MaterialTextureRegistry.cpp b/Elixir/Source/Engine/Materials/Rendering/TextureRegistry.cpp similarity index 76% rename from Elixir/Source/Engine/Material/MaterialTextureRegistry.cpp rename to Elixir/Source/Engine/Materials/Rendering/TextureRegistry.cpp index da89f408..5b834105 100644 --- a/Elixir/Source/Engine/Material/MaterialTextureRegistry.cpp +++ b/Elixir/Source/Engine/Materials/Rendering/TextureRegistry.cpp @@ -1,12 +1,12 @@ #include "epch.h" -#include "MaterialTextureRegistry.h" +#include "TextureRegistry.h" #include #include -namespace Elixir +namespace Elixir::Materials::Rendering { - MaterialTextureRegistry::MaterialTextureRegistry(const GraphicsContext* context) + TextureRegistry::TextureRegistry(const GraphicsContext* context) : m_Textures(TextureSet::Create(context)), m_Sampler(SamplerBuilder().Build(context)), m_GraphicsContext(context) @@ -22,12 +22,12 @@ namespace Elixir m_FallbackTextureHandle = m_Textures->AddTexture(whiteTexture); } - void MaterialTextureRegistry::BeginFrame(const uint64_t submissionSerial) + void TextureRegistry::BeginFrame(const uint64_t submissionSerial) { m_SubmissionSerial = submissionSerial; } - uint32_t MaterialTextureRegistry::Resolve(const Ref& texture) + uint32_t TextureRegistry::Resolve(const Ref& texture) { if (!texture) return GetFallbackIndex(); @@ -38,7 +38,7 @@ namespace Elixir // Bindless descriptor updates become visible before the next render callback. const auto handle = m_Textures->AddTexture(texture); - m_Bindings.emplace(texture, SMaterialTextureBinding{ + m_Bindings.emplace(texture, STextureBinding{ .Handle = handle, .ReadySubmission = m_SubmissionSerial + 1, }); @@ -46,7 +46,7 @@ namespace Elixir return GetFallbackIndex(); } - uint32_t MaterialTextureRegistry::Find(const Ref& texture) const + uint32_t TextureRegistry::Find(const Ref& texture) const { if (!texture) return GetFallbackIndex(); diff --git a/Elixir/Source/Engine/Material/MaterialTextureRegistry.h b/Elixir/Source/Engine/Materials/Rendering/TextureRegistry.h similarity index 92% rename from Elixir/Source/Engine/Material/MaterialTextureRegistry.h rename to Elixir/Source/Engine/Materials/Rendering/TextureRegistry.h index 2f96db1b..5f10e82c 100644 --- a/Elixir/Source/Engine/Material/MaterialTextureRegistry.h +++ b/Elixir/Source/Engine/Materials/Rendering/TextureRegistry.h @@ -3,12 +3,12 @@ #include #include -namespace Elixir +namespace Elixir::Materials::Rendering { /** * @brief Stores a texture binding and submission where it becomes available. */ - struct SMaterialTextureBinding + struct STextureBinding { /** @brief Handle of the texture in the texture set. */ SResourceHandle Handle{}; @@ -39,7 +39,7 @@ namespace Elixir * Newly added bindings use the fallback texture until descriptor updates become * visible to a later submission. */ - class ELIXIR_API MaterialTextureRegistry final + class ELIXIR_API TextureRegistry final { public: /** @@ -47,7 +47,7 @@ namespace Elixir * @param context Graphics context that owns the texture resources. * @pre context is valid. */ - explicit MaterialTextureRegistry(const GraphicsContext* context); + explicit TextureRegistry(const GraphicsContext* context); /** * @brief Starts texture resolution for a frame submission. @@ -85,7 +85,7 @@ namespace Elixir Ref m_Textures; Ref m_Sampler; SResourceHandle m_FallbackTextureHandle; - std::unordered_map, SMaterialTextureBinding> m_Bindings; + std::unordered_map, STextureBinding> m_Bindings; uint64_t m_SubmissionSerial = 0; From f5b01547e375cfc045e5caddcc9d62860af4e432 Mon Sep 17 00:00:00 2001 From: MrChampz Date: Wed, 2 Sep 2026 21:55:30 -0300 Subject: [PATCH 81/89] Centralize material frame preparation --- Documentation/Plans/MaterialSystem.md | 537 +++++++++ .../Plans/MaterialSystemCentralRendering.md | 1058 +++++++++++++++++ .../Engine/Aether/Rendering/Renderer.cpp | 10 +- .../Engine/Materials/MaterialSystem.cpp | 30 +- .../Source/Engine/Materials/MaterialSystem.h | 40 +- .../Aether/Effect/MaterialResolverTest.cpp | 3 +- .../Engine/Aether/Rendering/RendererTest.cpp | 9 +- .../Aether/Simulation/SimulatorTest.cpp | 4 +- Elixir/Tests/Engine/Aether/SystemTest.cpp | 14 +- .../Engine/Aether/TestInstanceRegistry.h | 3 +- .../Engine/Aether/TestMaterialResolver.h | 11 +- .../Engine/Material/MaterialRendererTest.cpp | 21 - .../Compilation/CompilationCacheTest.cpp} | 10 +- .../Compilation/CompilerTest.cpp} | 14 +- .../MaterialGraphTest.cpp | 17 +- .../MaterialRegistryTest.cpp | 5 +- .../{Material => Materials}/MaterialTest.cpp | 7 +- .../Rendering/FrameTableTest.cpp} | 26 +- .../Rendering}/MaterialRenderProxyTest.cpp | 11 +- .../Rendering}/MaterialRenderSceneTest.cpp | 6 +- .../Materials/Rendering/RendererTest.cpp | 23 + .../Rendering/TextureRegistryTest.cpp} | 11 +- 22 files changed, 1750 insertions(+), 120 deletions(-) create mode 100644 Documentation/Plans/MaterialSystem.md create mode 100644 Documentation/Plans/MaterialSystemCentralRendering.md delete mode 100644 Elixir/Tests/Engine/Material/MaterialRendererTest.cpp rename Elixir/Tests/Engine/{Material/MaterialCompilationCacheTest.cpp => Materials/Compilation/CompilationCacheTest.cpp} (70%) rename Elixir/Tests/Engine/{Material/MaterialCompilerTest.cpp => Materials/Compilation/CompilerTest.cpp} (83%) rename Elixir/Tests/Engine/{Material => Materials}/MaterialGraphTest.cpp (89%) rename Elixir/Tests/Engine/{Material => Materials}/MaterialRegistryTest.cpp (91%) rename Elixir/Tests/Engine/{Material => Materials}/MaterialTest.cpp (92%) rename Elixir/Tests/Engine/{Material/MaterialFrameTableTest.cpp => Materials/Rendering/FrameTableTest.cpp} (83%) rename Elixir/Tests/Engine/{Material => Materials/Rendering}/MaterialRenderProxyTest.cpp (78%) rename Elixir/Tests/Engine/{Material => Materials/Rendering}/MaterialRenderSceneTest.cpp (88%) create mode 100644 Elixir/Tests/Engine/Materials/Rendering/RendererTest.cpp rename Elixir/Tests/Engine/{Material/MaterialTextureRegistryTest.cpp => Materials/Rendering/TextureRegistryTest.cpp} (58%) 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/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp b/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp index e904d531..e01452e3 100644 --- a/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp @@ -71,17 +71,13 @@ namespace Elixir::Aether::Rendering return; const auto scene = BuildMaterialRenderScene(frame); - const auto snapshot = m_MaterialSystem.BuildFrameSnapshot( - scene, - frame.GetSubmissionSerial() - ); - - m_LastMetrics.SubmittedMaterialCount = snapshot.MaterialCount; + m_MaterialSystem.PrepareFrame(scene, frame.GetSubmissionSerial()); BeginRendering(cmd); - const auto result = m_MaterialSystem.Render(cmd, scene, snapshot); + const auto result = m_MaterialSystem.Render(cmd, scene, frame.GetSubmissionSerial()); EndRendering(cmd); + m_LastMetrics.SubmittedMaterialCount = result.MaterialCount; m_LastMetrics.RenderBatchCount = result.BatchCount; m_LastMetrics.SubmittedRenderItemCount = result.DrawCount; } diff --git a/Elixir/Source/Engine/Materials/MaterialSystem.cpp b/Elixir/Source/Engine/Materials/MaterialSystem.cpp index b890ca89..d798ea40 100644 --- a/Elixir/Source/Engine/Materials/MaterialSystem.cpp +++ b/Elixir/Source/Engine/Materials/MaterialSystem.cpp @@ -53,7 +53,7 @@ namespace Elixir::Materials shaderLoader )) {} - SMaterialFrameSnapshot MaterialSystem::BuildFrameSnapshot( + void MaterialSystem::PrepareFrame( const MaterialRenderScene& scene, const uint64_t submissionSerial ) @@ -84,7 +84,11 @@ namespace Elixir::Materials ); } - return { table, table->GetCount(), submissionSerial }; + m_PreparedFrame = { + .Table = table, + .MaterialCount = table->GetCount(), + .SubmissionSerial = submissionSerial, + }; } std::optional MaterialSystem::GetProgramKey( @@ -105,11 +109,21 @@ namespace Elixir::Materials SMaterialRenderResult MaterialSystem::Render( const Ref& cmd, const MaterialRenderScene& scene, - const SMaterialFrameSnapshot& snapshot + const uint64_t submissionSerial ) const { - if (!cmd || !snapshot.Table) - return {}; + const auto isPrepared = m_PreparedFrame.Table && + m_PreparedFrame.SubmissionSerial == submissionSerial; + + EE_CORE_ASSERT(isPrepared, "Material rendering requires a prepared frame for the submission.") + + SMaterialRenderResult result{ + .MaterialCount = m_PreparedFrame.MaterialCount, + }; + + if (!cmd || !isPrepared) return result; + + const auto& table = m_PreparedFrame.Table; // Batching @@ -131,8 +145,8 @@ namespace Elixir::Materials EE_CORE_ASSERT(program, "Material render item does not support its requested pass.") if (!program) continue; - const auto materialIndex = snapshot.Table->Find(*item.Material); - EE_CORE_ASSERT(materialIndex, "Material frame snapshot is missing a render item material.") + const auto materialIndex = table->Find(*item.Material); + EE_CORE_ASSERT(materialIndex, "Prepared material frame is missing a render item material.") if (!materialIndex) continue; const SMaterialBatchKey key{ @@ -175,8 +189,6 @@ namespace Elixir::Materials ); }); - SMaterialRenderResult result{}; - for (const auto& batch : batches) { if (batch.Items.empty()) continue; diff --git a/Elixir/Source/Engine/Materials/MaterialSystem.h b/Elixir/Source/Engine/Materials/MaterialSystem.h index fee5c26c..bb9472b0 100644 --- a/Elixir/Source/Engine/Materials/MaterialSystem.h +++ b/Elixir/Source/Engine/Materials/MaterialSystem.h @@ -22,26 +22,14 @@ namespace Elixir::Materials uint32_t InitialFrameCapacity = 256; }; - /** - * @brief Stores material data prepared for one submitted frame. - */ - struct SMaterialFrameSnapshot - { - /** @brief Maps resolved material proxies to their frame data. */ - Ref Table; - - /** @brief Number of materials stored in Table. */ - uint32_t MaterialCount = 0; - - /** @brief Serial that identifies the submission that owns this snapshot. */ - uint64_t SubmissionSerial = 0; - }; - /** * @brief Reports the work recorded by a material render pass. */ struct SMaterialRenderResult { + /** @brief Number of materials prepared for the rendered submission. */ + uint32_t MaterialCount = 0; + /** @brief Number of material batches rendered. */ uint32_t BatchCount = 0; @@ -73,15 +61,11 @@ namespace Elixir::Materials ); /** - * @brief Builds and uploads material data for a scene submission. + * @brief Prepares and uploads material data for a scene submission. * @param scene Scene that provides material render items. * @param submissionSerial Serial that identifies the submission. - * @return A snapshot that contains the resolved frame material data. */ - SMaterialFrameSnapshot BuildFrameSnapshot( - const MaterialRenderScene& scene, - uint64_t submissionSerial - ); + void PrepareFrame(const MaterialRenderScene& scene, uint64_t submissionSerial); /** * @brief Gets the shader program key required for a material pass. @@ -105,13 +89,14 @@ namespace Elixir::Materials * @brief Records draw commands for the scene materials. * @param cmd Command buffer that receives the draw commands. * @param scene Scene that provides render items. - * @param snapshot Material data built for this scene submission. + * @param submissionSerial Serial passed to @ref PreparedFrame. * @return Counts of rendered batches and recorded draw commands. + * @pre @ref PrepareFrame was called for @p submissionSerial. */ SMaterialRenderResult Render( const Ref& cmd, const MaterialRenderScene& scene, - const SMaterialFrameSnapshot& snapshot + uint64_t submissionSerial ) const; /** @@ -133,9 +118,18 @@ namespace Elixir::Materials const Ref& GetSampler() const { return m_Textures.GetSampler(); } private: + /** @brief Stores material data prepared for the current submission. */ + struct SPreparedFrame + { + Ref Table; + uint32_t MaterialCount = 0; + uint64_t SubmissionSerial = 0; + }; + uint32_t m_MaterialCapacity = 0; Ref m_FrameBuffer; TextureRegistry m_Textures; Scope m_Renderer; + SPreparedFrame m_PreparedFrame; }; } diff --git a/Elixir/Tests/Engine/Aether/Effect/MaterialResolverTest.cpp b/Elixir/Tests/Engine/Aether/Effect/MaterialResolverTest.cpp index f4a09276..a8bb1bed 100644 --- a/Elixir/Tests/Engine/Aether/Effect/MaterialResolverTest.cpp +++ b/Elixir/Tests/Engine/Aether/Effect/MaterialResolverTest.cpp @@ -2,11 +2,12 @@ #include #include -#include +#include using namespace Elixir; using namespace Elixir::Aether; using namespace Elixir::Aether::Core; +using namespace Elixir::Materials; TEST(MaterialResolverTest, CreatesAuthoredMaterialsAndUsesUsageDefaults) { diff --git a/Elixir/Tests/Engine/Aether/Rendering/RendererTest.cpp b/Elixir/Tests/Engine/Aether/Rendering/RendererTest.cpp index 0d79b54a..83f8d657 100644 --- a/Elixir/Tests/Engine/Aether/Rendering/RendererTest.cpp +++ b/Elixir/Tests/Engine/Aether/Rendering/RendererTest.cpp @@ -5,12 +5,13 @@ #include #include -#include +#include namespace Elixir { class ShaderLoader; } using namespace Elixir; using namespace Elixir::Aether::Rendering; +using Elixir::Materials::MaterialSystem; namespace { @@ -26,14 +27,14 @@ namespace }; } -static_assert(!RendersFrameSubmission); +static_assert(!RendersFrameSubmission); static_assert(std::is_constructible_v< - Renderer, + Elixir::Aether::Rendering::Renderer, const GraphicsContext*, MaterialSystem& >); static_assert(!std::is_constructible_v< - Renderer, + Elixir::Aether::Rendering::Renderer, const GraphicsContext*, const ShaderLoader* >); diff --git a/Elixir/Tests/Engine/Aether/Simulation/SimulatorTest.cpp b/Elixir/Tests/Engine/Aether/Simulation/SimulatorTest.cpp index bb2155e8..9d466fce 100644 --- a/Elixir/Tests/Engine/Aether/Simulation/SimulatorTest.cpp +++ b/Elixir/Tests/Engine/Aether/Simulation/SimulatorTest.cpp @@ -7,7 +7,7 @@ #include #include -namespace Elixir { class MaterialSystem; } +namespace Elixir::Materials { class MaterialSystem; } using namespace Elixir; using namespace Elixir::Aether::Core; @@ -31,7 +31,7 @@ static_assert(std::is_constructible_v< static_assert(!std::is_constructible_v< Simulator, const GraphicsContext*, - MaterialSystem& + Materials::MaterialSystem& >); TEST(SimulatorTest, MetricsContainOnlySimulationResults) diff --git a/Elixir/Tests/Engine/Aether/SystemTest.cpp b/Elixir/Tests/Engine/Aether/SystemTest.cpp index 764d73d3..0daa3c53 100644 --- a/Elixir/Tests/Engine/Aether/SystemTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemTest.cpp @@ -1,18 +1,22 @@ #include #include -#include -#include -#include +#include +#include +#include #include "TestInstanceRegistry.h" using namespace Elixir; using namespace Elixir::Aether; using namespace Elixir::Aether::Core; +using namespace Elixir::Materials; template -concept HasPublicCompile = requires(const T& system, MaterialResolver& resolver) +concept HasPublicCompile = requires( + const T& system, + Materials::Rendering::MaterialResolver& resolver +) { system.Compile(resolver); }; @@ -30,7 +34,7 @@ namespace if (!instance || !runtime.Registry.Register(instance)) return {}; - Rendering::FrameSubmission submission; + Elixir::Aether::Rendering::FrameSubmission submission; EXPECT_TRUE(submission.Submit(*instance)); if (submission.IsEmpty()) diff --git a/Elixir/Tests/Engine/Aether/TestInstanceRegistry.h b/Elixir/Tests/Engine/Aether/TestInstanceRegistry.h index 0bca0374..dcb4545d 100644 --- a/Elixir/Tests/Engine/Aether/TestInstanceRegistry.h +++ b/Elixir/Tests/Engine/Aether/TestInstanceRegistry.h @@ -1,12 +1,13 @@ #pragma once #include -#include +#include #include "TestMaterialResolver.h" using namespace Elixir; using namespace Elixir::Aether; +using namespace Elixir::Materials; class TestInstanceRegistry final { diff --git a/Elixir/Tests/Engine/Aether/TestMaterialResolver.h b/Elixir/Tests/Engine/Aether/TestMaterialResolver.h index 329061dd..48d27b99 100644 --- a/Elixir/Tests/Engine/Aether/TestMaterialResolver.h +++ b/Elixir/Tests/Engine/Aether/TestMaterialResolver.h @@ -1,8 +1,13 @@ #pragma once -#include +#include +#include +#include using namespace Elixir; +using namespace Elixir::Materials; +using namespace Elixir::Materials::Compilation; +using namespace Elixir::Materials::Rendering; class TestMaterialResolver : public MaterialResolver { @@ -10,7 +15,7 @@ class TestMaterialResolver : public MaterialResolver Ref Resolve(const Ref& instance) override { if (!instance || !instance->GetParent()) return nullptr; - const auto result = MaterialCompiler::Build(*instance->GetParent()); + const auto result = Compiler::Build(*instance->GetParent()); return result ? MaterialRenderProxy::Create(result.Material, *instance) : nullptr; } -}; \ No newline at end of file +}; diff --git a/Elixir/Tests/Engine/Material/MaterialRendererTest.cpp b/Elixir/Tests/Engine/Material/MaterialRendererTest.cpp deleted file mode 100644 index d9b16a42..00000000 --- a/Elixir/Tests/Engine/Material/MaterialRendererTest.cpp +++ /dev/null @@ -1,21 +0,0 @@ -#include - -#include - -using namespace Elixir; - -TEST(MaterialRendererTest, MapsParticlePassesToMaterialUsages) -{ - EXPECT_EQ( - MaterialRenderer::GetUsage(EMaterialPass::ParticleSprite), - EMaterialUsage::ParticleSprite - ); - EXPECT_EQ( - MaterialRenderer::GetUsage(EMaterialPass::ParticleRibbon), - EMaterialUsage::ParticleRibbon - ); - EXPECT_EQ( - MaterialRenderer::GetUsage(EMaterialPass::ParticleMesh), - EMaterialUsage::ParticleMesh - ); -} \ No newline at end of file diff --git a/Elixir/Tests/Engine/Material/MaterialCompilationCacheTest.cpp b/Elixir/Tests/Engine/Materials/Compilation/CompilationCacheTest.cpp similarity index 70% rename from Elixir/Tests/Engine/Material/MaterialCompilationCacheTest.cpp rename to Elixir/Tests/Engine/Materials/Compilation/CompilationCacheTest.cpp index 13b5e74b..25072c78 100644 --- a/Elixir/Tests/Engine/Material/MaterialCompilationCacheTest.cpp +++ b/Elixir/Tests/Engine/Materials/Compilation/CompilationCacheTest.cpp @@ -1,12 +1,14 @@ #include -#include +#include using namespace Elixir; +using namespace Elixir::Materials; +using namespace Elixir::Materials::Compilation; -TEST(MaterialCompilationCacheTest, ReusesACompiledMaterialUntilTheSourceRevisionChanges) +TEST(CompilationCacheTest, ReusesACompiledMaterialUntilTheSourceRevisionChanges) { - MaterialCompilationCache cache{ nullptr }; + CompilationCache cache{ nullptr }; const auto material = CreateRef("Cache test"); ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleSprite, true)); @@ -24,4 +26,4 @@ TEST(MaterialCompilationCacheTest, ReusesACompiledMaterialUntilTheSourceRevision EXPECT_NE(first, rebuilt); EXPECT_TRUE(rebuilt->SupportsUsage(EMaterialUsage::ParticleRibbon)); -} \ No newline at end of file +} diff --git a/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp b/Elixir/Tests/Engine/Materials/Compilation/CompilerTest.cpp similarity index 83% rename from Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp rename to Elixir/Tests/Engine/Materials/Compilation/CompilerTest.cpp index ad3ddc6a..128756a6 100644 --- a/Elixir/Tests/Engine/Material/MaterialCompilerTest.cpp +++ b/Elixir/Tests/Engine/Materials/Compilation/CompilerTest.cpp @@ -1,10 +1,12 @@ #include -#include +#include using namespace Elixir; +using namespace Elixir::Materials; +using namespace Elixir::Materials::Compilation; -TEST(MaterialCompilerTest, AssignsStableSlotsByParameterKindAndName) +TEST(CompilerTest, AssignsStableSlotsByParameterKindAndName) { MaterialGraph graph; const auto material = CreateRef("Test"); @@ -20,7 +22,7 @@ TEST(MaterialCompilerTest, AssignsStableSlotsByParameterKindAndName) .DefaultValue = SMaterialParameter::MakeTexture(nullptr), })); - const auto result = MaterialCompiler::Build(*material); + const auto result = Compiler::Build(*material); ASSERT_TRUE(result); ASSERT_EQ(result.Material->Parameters.size(), 2); @@ -30,14 +32,14 @@ TEST(MaterialCompilerTest, AssignsStableSlotsByParameterKindAndName) EXPECT_EQ(result.Material->Parameters[1].Slot, 0); } -TEST(MaterialCompilerTest, PreservesEnabledRendererUsages) +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 = MaterialCompiler::Build(*material); + const auto result = Compiler::Build(*material); ASSERT_TRUE(result); EXPECT_TRUE(result.Material->SupportsUsage(EMaterialUsage::ParticleSprite)); @@ -45,7 +47,7 @@ TEST(MaterialCompilerTest, PreservesEnabledRendererUsages) EXPECT_TRUE(result.Material->SupportsUsage(EMaterialUsage::ParticleMesh)); } -TEST(MaterialCompilerTest, DoesNotAliasParticleUsageShadersToSurfaceShader) +TEST(CompilerTest, DoesNotAliasParticleUsageShadersToSurfaceShader) { SCompiledMaterial material; diff --git a/Elixir/Tests/Engine/Material/MaterialGraphTest.cpp b/Elixir/Tests/Engine/Materials/MaterialGraphTest.cpp similarity index 89% rename from Elixir/Tests/Engine/Material/MaterialGraphTest.cpp rename to Elixir/Tests/Engine/Materials/MaterialGraphTest.cpp index f864cd8d..02f13201 100644 --- a/Elixir/Tests/Engine/Material/MaterialGraphTest.cpp +++ b/Elixir/Tests/Engine/Materials/MaterialGraphTest.cpp @@ -1,15 +1,16 @@ #include -#include +#include -#include -#include -#include -#include -#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) @@ -94,4 +95,4 @@ TEST(MaterialGraphTest, GeneratesExponentialRadialGradientForOpacity) 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); -} \ No newline at end of file +} diff --git a/Elixir/Tests/Engine/Material/MaterialRegistryTest.cpp b/Elixir/Tests/Engine/Materials/MaterialRegistryTest.cpp similarity index 91% rename from Elixir/Tests/Engine/Material/MaterialRegistryTest.cpp rename to Elixir/Tests/Engine/Materials/MaterialRegistryTest.cpp index 23a31a11..ae291d69 100644 --- a/Elixir/Tests/Engine/Material/MaterialRegistryTest.cpp +++ b/Elixir/Tests/Engine/Materials/MaterialRegistryTest.cpp @@ -1,8 +1,9 @@ #include -#include +#include using namespace Elixir; +using namespace Elixir::Materials; TEST(MaterialRegistryTest, RegistersAndFindsDefaultMaterials) { @@ -28,4 +29,4 @@ TEST(MaterialRegistryTest, RejectsDuplicateMaterialNames) MaterialRegistry registry; EXPECT_TRUE(registry.Register(CreateRef("Game.Custom"))); EXPECT_FALSE(registry.Register(CreateRef("Game.Custom"))); -} \ No newline at end of file +} diff --git a/Elixir/Tests/Engine/Material/MaterialTest.cpp b/Elixir/Tests/Engine/Materials/MaterialTest.cpp similarity index 92% rename from Elixir/Tests/Engine/Material/MaterialTest.cpp rename to Elixir/Tests/Engine/Materials/MaterialTest.cpp index 6db741f0..afc85ccc 100644 --- a/Elixir/Tests/Engine/Material/MaterialTest.cpp +++ b/Elixir/Tests/Engine/Materials/MaterialTest.cpp @@ -1,10 +1,11 @@ #include -#include -#include -#include +#include +#include +#include using namespace Elixir; +using namespace Elixir::Materials; using namespace Elixir::Materials::Nodes; TEST(MaterialTest, ValidateGraphParametersAgainstMaterialSchema) diff --git a/Elixir/Tests/Engine/Material/MaterialFrameTableTest.cpp b/Elixir/Tests/Engine/Materials/Rendering/FrameTableTest.cpp similarity index 83% rename from Elixir/Tests/Engine/Material/MaterialFrameTableTest.cpp rename to Elixir/Tests/Engine/Materials/Rendering/FrameTableTest.cpp index 5464088e..e7f405f4 100644 --- a/Elixir/Tests/Engine/Material/MaterialFrameTableTest.cpp +++ b/Elixir/Tests/Engine/Materials/Rendering/FrameTableTest.cpp @@ -1,10 +1,14 @@ #include #include -#include -#include +#include +#include +#include using namespace Elixir; +using namespace Elixir::Materials; +using namespace Elixir::Materials::Compilation; +using namespace Elixir::Materials::Rendering; namespace { @@ -38,7 +42,7 @@ namespace }; } -TEST(MaterialFrameTableTest, DeduplicatesAProxyAndPreserveItsValues) +TEST(FrameTableTest, DeduplicatesAProxyAndPreservesItsValues) { auto material = CreateRef("Particle material"); ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleSprite, true)); @@ -51,13 +55,13 @@ TEST(MaterialFrameTableTest, DeduplicatesAProxyAndPreserveItsValues) auto instance = CreateRef(material); ASSERT_TRUE(instance->SetVector("Tint", { 0.25f, 0.5f, 0.75f, 1.0f })); - const auto compiled = MaterialCompiler::Build(*material); + const auto compiled = Compiler::Build(*material); ASSERT_TRUE(compiled); const auto proxy = MaterialRenderProxy::Create(compiled.Material, *instance); ASSERT_TRUE(proxy); - MaterialFrameTable table( + FrameTable table( 1, 17, [](const Ref&) { return 23; } @@ -77,9 +81,9 @@ TEST(MaterialFrameTableTest, DeduplicatesAProxyAndPreserveItsValues) EXPECT_EQ(data.TextureIndices.back(), 17); } -TEST(MaterialFrameTableTest, RejectsAUniqueProxyPastCapacity) +TEST(FrameTableTest, RejectsAUniqueProxyPastCapacity) { - MaterialFrameTable table( + FrameTable table( 0, 0, [](const Ref&) { return 0; } @@ -87,7 +91,7 @@ TEST(MaterialFrameTableTest, RejectsAUniqueProxyPastCapacity) auto material = CreateRef("Particle material"); auto instance = CreateRef(material); - const auto compiled = MaterialCompiler::Build(*material); + const auto compiled = Compiler::Build(*material); ASSERT_TRUE(compiled); const auto proxy = MaterialRenderProxy::Create(compiled.Material, *instance); @@ -95,7 +99,7 @@ TEST(MaterialFrameTableTest, RejectsAUniqueProxyPastCapacity) EXPECT_FALSE(table.Add(*proxy)); } -TEST(MaterialFrameTableTest, ResolvesAuthoredTextureSlots) +TEST(FrameTableTest, ResolvesAuthoredTextureSlots) { const auto texture = CreateRef(); @@ -106,14 +110,14 @@ TEST(MaterialFrameTableTest, ResolvesAuthoredTextureSlots) })); auto instance = CreateRef(material); - const auto compiled = MaterialCompiler::Build(*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; - MaterialFrameTable table( + FrameTable table( 1, 5, [&resolveCount, &texture](const Ref& resolved) diff --git a/Elixir/Tests/Engine/Material/MaterialRenderProxyTest.cpp b/Elixir/Tests/Engine/Materials/Rendering/MaterialRenderProxyTest.cpp similarity index 78% rename from Elixir/Tests/Engine/Material/MaterialRenderProxyTest.cpp rename to Elixir/Tests/Engine/Materials/Rendering/MaterialRenderProxyTest.cpp index e0d05d7a..9edc7bf3 100644 --- a/Elixir/Tests/Engine/Material/MaterialRenderProxyTest.cpp +++ b/Elixir/Tests/Engine/Materials/Rendering/MaterialRenderProxyTest.cpp @@ -1,8 +1,13 @@ #include -#include +#include +#include +#include using namespace Elixir; +using namespace Elixir::Materials; +using namespace Elixir::Materials::Compilation; +using namespace Elixir::Materials::Rendering; TEST(MaterialRenderProxyTest, ResolvesOverridesIntoAnImmutableSnapshot) { @@ -13,7 +18,7 @@ TEST(MaterialRenderProxyTest, ResolvesOverridesIntoAnImmutableSnapshot) .DefaultValue = SMaterialParameter::MakeVector(glm::vec4(1.0f)), })); - const auto compiled = MaterialCompiler::Build(*material).Material; + const auto compiled = Compiler::Build(*material).Material; ASSERT_TRUE(compiled); MaterialInstance instance(material); @@ -35,7 +40,7 @@ TEST(MaterialRenderProxyTest, RejectsACompiledMaterialForAnOldSchema) .DefaultValue = SMaterialParameter::MakeVector(glm::vec4(1.0f)), })); - const auto compiled = MaterialCompiler::Build(*material).Material; + const auto compiled = Compiler::Build(*material).Material; ASSERT_TRUE(compiled); ASSERT_TRUE(material->SetDefaultParameter( "Tint", diff --git a/Elixir/Tests/Engine/Material/MaterialRenderSceneTest.cpp b/Elixir/Tests/Engine/Materials/Rendering/MaterialRenderSceneTest.cpp similarity index 88% rename from Elixir/Tests/Engine/Material/MaterialRenderSceneTest.cpp rename to Elixir/Tests/Engine/Materials/Rendering/MaterialRenderSceneTest.cpp index 8f895856..aec3ca07 100644 --- a/Elixir/Tests/Engine/Material/MaterialRenderSceneTest.cpp +++ b/Elixir/Tests/Engine/Materials/Rendering/MaterialRenderSceneTest.cpp @@ -1,9 +1,11 @@ #include #include -#include +#include using namespace Elixir; +using namespace Elixir::Materials; +using namespace Elixir::Materials::Rendering; TEST(MaterialRenderSceneTest, PreservesAnUnboundMaterialItem) { @@ -46,4 +48,4 @@ TEST(MaterialRenderSceneTest, ResolvesLateMaterialIndex) Memory::Memcpy(&values, resolved.data(), sizeof(values)); EXPECT_EQ(values.MaterialIndex, 17); -} \ No newline at end of file +} diff --git a/Elixir/Tests/Engine/Materials/Rendering/RendererTest.cpp b/Elixir/Tests/Engine/Materials/Rendering/RendererTest.cpp new file mode 100644 index 00000000..342f4f3c --- /dev/null +++ b/Elixir/Tests/Engine/Materials/Rendering/RendererTest.cpp @@ -0,0 +1,23 @@ +#include + +#include + +using namespace Elixir; +using namespace Elixir::Materials; +using namespace Elixir::Materials::Rendering; + +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 + ); +} diff --git a/Elixir/Tests/Engine/Material/MaterialTextureRegistryTest.cpp b/Elixir/Tests/Engine/Materials/Rendering/TextureRegistryTest.cpp similarity index 58% rename from Elixir/Tests/Engine/Material/MaterialTextureRegistryTest.cpp rename to Elixir/Tests/Engine/Materials/Rendering/TextureRegistryTest.cpp index bde5b6f0..d1f79e59 100644 --- a/Elixir/Tests/Engine/Material/MaterialTextureRegistryTest.cpp +++ b/Elixir/Tests/Engine/Materials/Rendering/TextureRegistryTest.cpp @@ -1,19 +1,20 @@ #include -#include +#include using namespace Elixir; +using namespace Elixir::Materials::Rendering; -TEST(MaterialTextureRegistryTest, UsesFallbackUntilDescriptorIsVisible) +TEST(TextureRegistryTest, UsesFallbackUntilDescriptorIsVisible) { constexpr uint32_t fallbackIndex = 3; - const SMaterialTextureBinding binding{ - . Handle = SResourceHandle::Texture(17), + 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); -} \ No newline at end of file +} From a7109637485b2cfde13fa91f73efb272408d4612 Mon Sep 17 00:00:00 2001 From: MrChampz Date: Thu, 3 Sep 2026 15:03:30 -0300 Subject: [PATCH 82/89] Track descriptor state per frame slot --- .../Source/Engine/Graphics/FrameSlotState.h | 158 +++++++++++++ .../Graphics/Vulkan/VulkanGraphicsContext.cpp | 2 +- .../Source/Graphics/Vulkan/VulkanShader.cpp | 217 ++++++------------ Elixir/Source/Graphics/Vulkan/VulkanShader.h | 39 ++-- .../Engine/Graphics/FrameSlotStateTest.cpp | 89 +++++++ 5 files changed, 338 insertions(+), 167 deletions(-) create mode 100644 Elixir/Source/Engine/Graphics/FrameSlotState.h create mode 100644 Elixir/Tests/Engine/Graphics/FrameSlotStateTest.cpp diff --git a/Elixir/Source/Engine/Graphics/FrameSlotState.h b/Elixir/Source/Engine/Graphics/FrameSlotState.h new file mode 100644 index 00000000..05516800 --- /dev/null +++ b/Elixir/Source/Engine/Graphics/FrameSlotState.h @@ -0,0 +1,158 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace Elixir +{ + /** + * @brief Stores a resource for each frame slot and applies pending keyed state. + * + * The associated graphics context selects the current frame slot. Each key + * keeps its desired value and the revision applied to every frame slot. + * + * @tparam TResource Resource stored for each frame slot. + * @tparam TKey Key that identifies an independently tracked state value. + * @tparam TValue Desired value associated with a key. + */ + template + class FrameSlotState final + { + public: + /** + * @brief Describes one value pending application to a frame resource. + * + * References remain valid only for the duration of ApplyPendingState. + */ + struct SPendingState + { + const TKey& Key; + const TValue& Value; + }; + + /** + * @brief Creates frame resources associated with a graphics context. + * @param context Context that owns the frame slots. + */ + explicit FrameSlotState(const GraphicsContext& context) + : m_Context(context) {} + + /** + * @brief Stores a desired value when it differs from the current value. + * @param key The key. + * @param value The value. + * @return True when the value changed and must be applied to frame slots. + */ + 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_Context.GetFrameIndex(); + EE_CORE_ASSERT(frameIndex < FRAMES, "Frame slot index is out of range.") + + 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[frameIndex], + std::span(pendingValues) + ); + + for (auto& state : pendingStates) + state.get().AppliedRevisions[frameIndex] = state.get().Revision; + } + + /** @brief Returns the resource for the current frame slot. */ + TResource& GetCurrentFrameResource() + { + return m_Resources[m_Context.GetFrameIndex()]; + } + + /** @brief Returns the resource for the current frame slot. */ + const TResource& GetCurrentFrameResource() const + { + return m_Resources[m_Context.GetFrameIndex()]; + } + + /** @brief Returns the resource for a specific frame slot. */ + TResource& GetResource(const uint32_t frameIndex) + { + EE_CORE_ASSERT(frameIndex < FRAMES, "Frame slot index is out of range.") + return m_Resources[frameIndex]; + } + + /** @brief Returns the resource for a specific frame slot. */ + const TResource& GetResource(const uint32_t frameIndex) const + { + EE_CORE_ASSERT(frameIndex < FRAMES, "Frame slot index is out of range.") + return m_Resources[frameIndex]; + } + + private: + static constexpr uint32_t FRAMES = GraphicsContext::FRAMES; + + struct SState + { + TValue Value; + uint64_t Revision = 0; + std::array AppliedRevisions{}; + }; + + const GraphicsContext& m_Context; + std::array m_Resources; + std::unordered_map m_States; + }; +} diff --git a/Elixir/Source/Graphics/Vulkan/VulkanGraphicsContext.cpp b/Elixir/Source/Graphics/Vulkan/VulkanGraphicsContext.cpp index e1e5ec81..ddeff100 100644 --- a/Elixir/Source/Graphics/Vulkan/VulkanGraphicsContext.cpp +++ b/Elixir/Source/Graphics/Vulkan/VulkanGraphicsContext.cpp @@ -400,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); } diff --git a/Elixir/Source/Graphics/Vulkan/VulkanShader.cpp b/Elixir/Source/Graphics/Vulkan/VulkanShader.cpp index de2a7dc3..a7afdf1f 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,10 +35,11 @@ namespace Elixir::Vulkan nullptr ); - if (!m_DescriptorSets.empty()) + for (uint32_t frameIndex = 0; frameIndex < GraphicsContext::FRAMES; ++frameIndex) { - m_GraphicsContext->GetDescriptorPool()->FreeDescriptorSets(m_DescriptorSets); - m_DescriptorSets.clear(); + auto& sets = m_DescriptorSets.GetResource(frameIndex); + if (!sets.empty()) + m_GraphicsContext->GetDescriptorPool()->FreeDescriptorSets(sets); } for (const auto& layout : m_DescriptorSetLayouts) @@ -55,6 +58,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 +150,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 +173,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 +188,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 +203,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 +218,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 +228,7 @@ namespace Elixir::Vulkan std::vector VulkanShader::GetDescriptorSets() const { - std::vector sets(m_DescriptorSets); + std::vector sets(m_DescriptorSets.GetCurrentFrameResource()); if (m_BindlessSet) { const auto bindlessPool = m_GraphicsContext->GetBindlessDescriptorPool(); @@ -339,26 +343,29 @@ namespace Elixir::Vulkan if (m_DescriptorSetLayouts.empty()) return; - m_DescriptorSets.resize(m_DescriptorSetLayouts.size()); - - for (auto i = 0; i < m_DescriptorSetLayouts.size(); i++) + for (uint32_t frameIndex = 0; frameIndex < GraphicsContext::FRAMES; ++frameIndex) { - 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(); + auto& sets = m_DescriptorSets.GetResource(frameIndex); + sets.resize(m_DescriptorSetLayouts.size()); - VK_CHECK_RESULT( - vkAllocateDescriptorSets( - m_GraphicsContext->GetDevice(), - &allocInfo, - &m_DescriptorSets[i] - ) - ); + 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] + ) + ); + } } - - UpdateDescriptorSets(); } void VulkanShader::CreatePipelineLayout() @@ -390,38 +397,36 @@ namespace Elixir::Vulkan ); } - void VulkanShader::UpdateDescriptorSets() + void VulkanShader::ApplyPendingDescriptorState() { - std::vector writeDescriptorSets; - - for (const auto [binding, texture] : m_Textures) + m_DescriptorSets.ApplyPendingState([this](auto&, const auto changes) { - const auto writeSet = GetWriteDescriptorSet(binding, texture.get()); - writeDescriptorSets.push_back(writeSet); - } + std::vector writes; + writes.reserve(changes.size()); - for (const auto [binding, buffer] : m_StorageBuffers) - { - const auto writeSet = GetWriteDescriptorSet(binding, buffer); - writeDescriptorSets.push_back(writeSet); - } - - 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 +451,7 @@ namespace Elixir::Vulkan VkWriteDescriptorSet writeSet = {}; writeSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writeSet.dstSet = m_DescriptorSets[resource.GetSet()]; + writeSet.dstSet = m_DescriptorSets.GetCurrentFrameResource()[resource.GetSet()]; writeSet.dstBinding = resource.GetBinding(); writeSet.descriptorType = Converters::GetDescriptorType(resource.GetType()); writeSet.descriptorCount = m_ImageInfoCache[binding].size(); @@ -455,22 +460,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 +482,7 @@ namespace Elixir::Vulkan VkWriteDescriptorSet writeSet = {}; writeSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writeSet.dstSet = m_DescriptorSets[resource.GetSet()]; + writeSet.dstSet = m_DescriptorSets.GetCurrentFrameResource()[resource.GetSet()]; writeSet.dstBinding = resource.GetBinding(); writeSet.descriptorType = Converters::GetDescriptorType(resource.GetType()); writeSet.descriptorCount = m_ImageInfoCache[binding].size(); @@ -502,22 +491,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 +503,7 @@ namespace Elixir::Vulkan VkWriteDescriptorSet writeSet = {}; writeSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writeSet.dstSet = m_DescriptorSets[resource.GetSet()]; + writeSet.dstSet = m_DescriptorSets.GetCurrentFrameResource()[resource.GetSet()]; writeSet.dstBinding = resource.GetBinding(); writeSet.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; writeSet.descriptorCount = 1; @@ -539,22 +512,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 +524,7 @@ namespace Elixir::Vulkan VkWriteDescriptorSet writeSet = {}; writeSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writeSet.dstSet = m_DescriptorSets[resource.GetSet()]; + writeSet.dstSet = m_DescriptorSets.GetCurrentFrameResource()[resource.GetSet()]; writeSet.dstBinding = resource.GetBinding(); writeSet.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; writeSet.descriptorCount = 1; @@ -576,22 +533,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 +545,7 @@ namespace Elixir::Vulkan VkWriteDescriptorSet writeSet = {}; writeSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writeSet.dstSet = m_DescriptorSets[resource.GetSet()]; + writeSet.dstSet = m_DescriptorSets.GetCurrentFrameResource()[resource.GetSet()]; writeSet.dstBinding = resource.GetBinding(); writeSet.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; writeSet.descriptorCount = 1; @@ -612,20 +553,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..637fef05 100644 --- a/Elixir/Source/Graphics/Vulkan/VulkanShader.h +++ b/Elixir/Source/Graphics/Vulkan/VulkanShader.h @@ -1,5 +1,8 @@ #pragma once +#include + +#include #include #include @@ -45,49 +48,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 = FrameSlotState< + 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/Graphics/FrameSlotStateTest.cpp b/Elixir/Tests/Engine/Graphics/FrameSlotStateTest.cpp new file mode 100644 index 00000000..c6820821 --- /dev/null +++ b/Elixir/Tests/Engine/Graphics/FrameSlotStateTest.cpp @@ -0,0 +1,89 @@ +#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, AppliesEachRevisionOncePerFrameSlot) + { + FrameSlotStateTestContext context; + FrameSlotState 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.GetCurrentFrameResource(), 3u); + + context.SetFrameNumber(1); + state.ApplyPendingState(apply); + EXPECT_EQ(state.GetCurrentFrameResource(), 3u); + EXPECT_EQ(applyCount, 2u); + + EXPECT_TRUE(state.Set("value", 8)); + state.ApplyPendingState(apply); + EXPECT_EQ(state.GetCurrentFrameResource(), 8u); + + context.SetFrameNumber(2); + state.ApplyPendingState(apply); + EXPECT_EQ(state.GetCurrentFrameResource(), 8u); + EXPECT_EQ(applyCount, 4u); + } + + TEST(FrameSlotStateTest, DoesNotApplyAnUnchangedValue) + { + FrameSlotStateTestContext context; + FrameSlotState 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); + } +} From 952f24d1ae29438a9cdc55a5ef54e33c3feb4795 Mon Sep 17 00:00:00 2001 From: MrChampz Date: Fri, 4 Sep 2026 02:56:49 -0300 Subject: [PATCH 83/89] Integrate material frame rendering --- Elixir/Source/Engine.h | 1 + Elixir/Source/Engine/Aether/Emitter.cpp | 13 +- Elixir/Source/Engine/Aether/Emitter.h | 4 +- Elixir/Source/Engine/Aether/Manager.cpp | 8 +- Elixir/Source/Engine/Aether/Manager.h | 2 + .../Engine/Aether/Rendering/Renderer.cpp | 63 +----- .../Source/Engine/Aether/Rendering/Renderer.h | 33 +--- .../Engine/Aether/Simulation/RenderFrame.h | 6 +- Elixir/Source/Engine/Core/Application.cpp | 2 + .../Source/Engine/Graphics/FrameSlotState.h | 8 - .../Source/Engine/Graphics/Shader/Shader.cpp | 5 + Elixir/Source/Engine/Graphics/Shader/Shader.h | 11 ++ .../Engine/Materials/MaterialSystem.cpp | 181 +++++++++++++++--- .../Source/Engine/Materials/MaterialSystem.h | 52 ++++- .../Materials/Rendering/MaterialRenderScene.h | 5 +- .../Engine/Materials/Rendering/Renderer.cpp | 18 +- .../Engine/Materials/Rendering/Renderer.h | 6 +- Elixir/Source/Graphics/Vulkan/VulkanShader.h | 2 - .../Engine/Aether/Rendering/RendererTest.cpp | 14 +- Elixir/Tests/Engine/Aether/SystemTest.cpp | 24 +-- 20 files changed, 278 insertions(+), 180 deletions(-) diff --git a/Elixir/Source/Engine.h b/Elixir/Source/Engine.h index ddff6a12..3ca022df 100644 --- a/Elixir/Source/Engine.h +++ b/Elixir/Source/Engine.h @@ -45,6 +45,7 @@ #include #include #include +#include #include #include diff --git a/Elixir/Source/Engine/Aether/Emitter.cpp b/Elixir/Source/Engine/Aether/Emitter.cpp index 9b7cb13c..7d3df2d2 100644 --- a/Elixir/Source/Engine/Aether/Emitter.cpp +++ b/Elixir/Source/Engine/Aether/Emitter.cpp @@ -59,18 +59,7 @@ namespace Elixir::Aether emitter.SpawnRatePerSecond = params[spawnRateParamIndex].Value.x; if (m_Material) - { - const auto proxy = materialResolver.Resolve(m_Material); - if (!proxy || !proxy->GetCompiledMaterial()->SupportsUsage( - Effect::GetMaterialUsage(m_RenderMode) - )) - EE_CORE_ERROR( - "Aether emitter '{}' material does not support its render mode.", - m_Name - ) - else - emitter.Material = proxy; - } + emitter.Material = m_Material; for (const auto& module : m_SpawnModules) { diff --git a/Elixir/Source/Engine/Aether/Emitter.h b/Elixir/Source/Engine/Aether/Emitter.h index e823bd48..29631ee1 100644 --- a/Elixir/Source/Engine/Aether/Emitter.h +++ b/Elixir/Source/Engine/Aether/Emitter.h @@ -36,8 +36,8 @@ namespace Elixir::Aether EParticleRenderMode RenderMode = EParticleRenderMode::Sprite; EParticleSimulationSpace SimulationSpace = EParticleSimulationSpace::World; - // Immutable GPU material state published by System::Compile. - Ref Material; + // Material instance submitted to the material system with each frame. + Ref Material; float SpawnRatePerSecond = 1.0f; uint32_t BurstCount = 0u; diff --git a/Elixir/Source/Engine/Aether/Manager.cpp b/Elixir/Source/Engine/Aether/Manager.cpp index de23f770..197a6d9b 100644 --- a/Elixir/Source/Engine/Aether/Manager.cpp +++ b/Elixir/Source/Engine/Aether/Manager.cpp @@ -21,7 +21,8 @@ namespace Elixir::Aether materialSystem )), m_Simulator(CreateScope(context, shaderLoader)), - m_Renderer(CreateScope(context, materialSystem)), + m_Renderer(CreateScope(context)), + m_MaterialSystem(materialSystem), m_GraphicsContext(context) {} Manager::~Manager() = default; @@ -72,10 +73,11 @@ namespace Elixir::Aether }); const auto frame = GetSimulator().Simulate(*submission, cmd); - GetRenderer().Render(*frame, camera, cmd); - cmd->End(); m_GraphicsContext->EnqueueSecondaryCommandBuffer(cmd); + + MaterialRenderScene scene = GetRenderer().BuildRenderScene(*frame, camera); + m_MaterialSystem.Submit(scene); } const SSimulationMetrics& Manager::GetLastSimulationMetrics() const diff --git a/Elixir/Source/Engine/Aether/Manager.h b/Elixir/Source/Engine/Aether/Manager.h index 81d2ea04..015dc1c7 100644 --- a/Elixir/Source/Engine/Aether/Manager.h +++ b/Elixir/Source/Engine/Aether/Manager.h @@ -202,6 +202,8 @@ namespace Elixir::Aether Scope m_Simulator; Scope m_Renderer; + MaterialSystem& m_MaterialSystem; + SystemInstanceRetirementQueue m_PendingRetirements; const GraphicsContext* m_GraphicsContext = nullptr; diff --git a/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp b/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp index e01452e3..44a3c3ed 100644 --- a/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp +++ b/Elixir/Source/Engine/Aether/Rendering/Renderer.cpp @@ -1,8 +1,6 @@ #include "epch.h" #include "Renderer.h" -#include - namespace Elixir::Aether::Rendering { using namespace Core; @@ -37,9 +35,8 @@ namespace Elixir::Aether::Rendering }; } - Renderer::Renderer(const GraphicsContext* context, MaterialSystem& materialSystem) - : m_MaterialSystem(materialSystem), - m_GraphicsContext(context) + Renderer::Renderer(const GraphicsContext* context) + : m_GraphicsContext(context) { EE_CORE_ASSERT(context, "Aether Renderer requires a graphics context.") EE_CORE_INFO("Initializing Aether Renderer.") @@ -49,16 +46,12 @@ namespace Elixir::Aether::Rendering InitPerFrameData(); } - void Renderer::Render( + MaterialRenderScene Renderer::BuildRenderScene( const RenderFrame& frame, - const Camera& camera, - const Ref& cmd + const Camera& camera ) { - EE_CORE_ASSERT(cmd, "Aether rendering requires a command buffer.") - m_LastMetrics = { .SubmissionSerial = frame.GetSubmissionSerial() }; - m_RenderExtent = m_GraphicsContext->GetRenderTarget()->GetExtent(); m_FrameData.View = camera.GetViewMatrix(); m_FrameData.Proj = camera.GetProjectionMatrix(); @@ -67,19 +60,10 @@ namespace Elixir::Aether::Rendering m_FrameData.Time = frame.GetElapsedTimeSeconds(); m_FrameConstantBuffer->UpdateData(&m_FrameData, sizeof(m_FrameData)); - if (frame.GetItems().empty()) - return; - - const auto scene = BuildMaterialRenderScene(frame); - m_MaterialSystem.PrepareFrame(scene, frame.GetSubmissionSerial()); - - BeginRendering(cmd); - const auto result = m_MaterialSystem.Render(cmd, scene, frame.GetSubmissionSerial()); - EndRendering(cmd); + auto scene = BuildScene(frame); + m_LastMetrics.SubmittedMaterialCount = scene.GetItems().size(); - m_LastMetrics.SubmittedMaterialCount = result.MaterialCount; - m_LastMetrics.RenderBatchCount = result.BatchCount; - m_LastMetrics.SubmittedRenderItemCount = result.DrawCount; + return scene; } void Renderer::CreateCoreV1GraphicsLayout() @@ -195,37 +179,6 @@ namespace Elixir::Aether::Rendering ); } - 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.0f; - viewport.Y = 0.0f; - 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) - { - cmd->EndRendering(); - } - const Renderer::SParticleGraphicsLayout* Renderer::FindGraphicsLayout( const EParticleStateLayout key ) const @@ -246,7 +199,7 @@ namespace Elixir::Aether::Rendering return nullptr; } - MaterialRenderScene Renderer::BuildMaterialRenderScene(const RenderFrame& frame) const + MaterialRenderScene Renderer::BuildScene(const RenderFrame& frame) const { MaterialRenderScene scene; diff --git a/Elixir/Source/Engine/Aether/Rendering/Renderer.h b/Elixir/Source/Engine/Aether/Rendering/Renderer.h index cc0c837c..2f29e35c 100644 --- a/Elixir/Source/Engine/Aether/Rendering/Renderer.h +++ b/Elixir/Source/Engine/Aether/Rendering/Renderer.h @@ -2,13 +2,10 @@ #include #include -#include #include #include #include -namespace Elixir::Materials { class MaterialSystem; } - namespace Elixir::Aether::Rendering { using namespace Core; @@ -56,32 +53,24 @@ namespace Elixir::Aether::Rendering public: /** * @brief Creates the resources required to render particles. - * * @param context Graphics context that owns the rendering resources. - * @param materialSystem Material system used to render particle materials. - * * @pre context is not null and outlives the renderer. - * @pre materialSystem outlives the renderer. */ - Renderer(const GraphicsContext* context, MaterialSystem& materialSystem); + explicit Renderer(const GraphicsContext* context); /** - * @brief Records the draw commands for a simulated particle frame. + * @brief Builds material draw data for a simulated particle frame. * * The method updates the frame constants, creates a material render scene, - * and records the particle graphics passes. An empty frame records no - * graphics pass. + * 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. - * @param cmd Command buffer that receives the graphics commands. - * - * @pre cmd is not null and is recording commands. + * @return Material render scene. */ - void Render( + MaterialRenderScene BuildRenderScene( const RenderFrame& frame, - const Camera& camera, - const Ref& cmd + const Camera& camera ); /** @@ -112,12 +101,6 @@ namespace Elixir::Aether::Rendering // Initializes per-frame constant-buffer data. void InitPerFrameData(); - // Begins the graphics rendering scope for particle material passes. - void BeginRendering(const Ref& cmd) const; - - // Ends the graphics rendering scope for particle material passes. - static void EndRendering(const Ref& cmd); - // Finds the graphics layout for a particle-state layout. const SParticleGraphicsLayout* FindGraphicsLayout(EParticleStateLayout key) const; @@ -128,7 +111,7 @@ namespace Elixir::Aether::Rendering ); // Converts particle render items into material geometry and draw commands. - MaterialRenderScene BuildMaterialRenderScene(const RenderFrame& frame) const; + MaterialRenderScene BuildScene(const RenderFrame& frame) const; SFrameData m_FrameData{}; Ref m_FrameConstantBuffer; @@ -138,8 +121,6 @@ namespace Elixir::Aether::Rendering uint32_t m_MeshVertexCount = 0; Ref m_MeshVertexBuffer; - MaterialSystem& m_MaterialSystem; - SRenderingMetrics m_LastMetrics{}; Extent2D m_RenderExtent{}; diff --git a/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h b/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h index 69f79e7d..b27d3ae9 100644 --- a/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h +++ b/Elixir/Source/Engine/Aether/Simulation/RenderFrame.h @@ -1,14 +1,14 @@ #pragma once #include -#include +#include #include #include #include namespace Elixir::Aether::Simulation { - using namespace Materials::Rendering; + using namespace Materials; /** * @brief Retains the particle-state buffer for one simulation layout. @@ -35,7 +35,7 @@ namespace Elixir::Aether::Simulation Core::SSystemInstanceAllocation Allocation; Core::EParticleStateLayout ParticleStateLayout = Core::EParticleStateLayout::CoreV1; Core::EParticleRenderMode RenderMode = Core::EParticleRenderMode::Sprite; - Ref Material; + Ref Material; glm::mat4 WorldTransform{ 1.0f }; uint32_t EmitterIndex = 0; uint32_t LocalParticleOffset = 0; diff --git a/Elixir/Source/Engine/Core/Application.cpp b/Elixir/Source/Engine/Core/Application.cpp index 7362afce..4c37f1e0 100644 --- a/Elixir/Source/Engine/Core/Application.cpp +++ b/Elixir/Source/Engine/Core/Application.cpp @@ -199,7 +199,9 @@ namespace Elixir m_GraphicsContext->RenderFrame([this, frameTime]() { + m_MaterialSystem->BeginFrame(); Render(frameTime); + m_MaterialSystem->RenderFrame(); m_GUIManager->Render(); }); diff --git a/Elixir/Source/Engine/Graphics/FrameSlotState.h b/Elixir/Source/Engine/Graphics/FrameSlotState.h index 05516800..c3fc4eac 100644 --- a/Elixir/Source/Engine/Graphics/FrameSlotState.h +++ b/Elixir/Source/Engine/Graphics/FrameSlotState.h @@ -1,13 +1,5 @@ #pragma once -#include -#include -#include -#include -#include -#include -#include - #include #include 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/Materials/MaterialSystem.cpp b/Elixir/Source/Engine/Materials/MaterialSystem.cpp index d798ea40..27aa13c4 100644 --- a/Elixir/Source/Engine/Materials/MaterialSystem.cpp +++ b/Elixir/Source/Engine/Materials/MaterialSystem.cpp @@ -41,24 +41,109 @@ namespace Elixir::Materials const ShaderLoader* shaderLoader, const SMaterialSystemConfig config ) : m_MaterialCapacity(GetInitialFrameCapacity(config)), - m_FrameBuffer(DynamicStorageBuffer::Create( - context, - sizeof(SMaterialFrameData) * m_MaterialCapacity) - ), m_Textures(context), m_Renderer(CreateScope( context, - m_FrameBuffer, m_Textures, shaderLoader - )) {} + )), + m_GraphicsContext(context) + { + EE_CORE_ASSERT(context, "Material system requires a graphics context.") + + for (auto& slot : m_FrameSlots) + { + slot.Buffer = DynamicStorageBuffer::Create( + context, + sizeof(SMaterialFrameData) * m_MaterialCapacity + ); + } + } + + void MaterialSystem::BeginFrame() + { + EE_CORE_ASSERT(m_GraphicsContext, "Material system graphics context is unavailable.") + + m_CurrentFrameNumber = m_GraphicsContext->GetFrameNumber(); + m_SubmittedScenes.clear(); + m_Textures.BeginFrame(m_CurrentFrameNumber); + + auto& slot = m_FrameSlots[m_GraphicsContext->GetFrameIndex()]; + slot.Table.reset(); + slot.FrameNumber = m_CurrentFrameNumber; + m_PreparedFrame = {}; + } + + void MaterialSystem::Submit(MaterialRenderScene scene) + { + EE_CORE_ASSERT( + m_CurrentFrameNumber == m_GraphicsContext->GetFrameNumber(), + "Material scenes must be submitted after BeginFrame for the current graphics frame." + ) + m_SubmittedScenes.push_back(std::move(scene)); + } + + SMaterialRenderResult MaterialSystem::RenderFrame() + { + SMaterialRenderResult result{}; + if (m_SubmittedScenes.empty()) return result; + + EE_CORE_ASSERT( + m_CurrentFrameNumber == m_GraphicsContext->GetFrameNumber(), + "Material rendering requires BeginFrame for the current graphics frame." + ) + + const auto cmd = m_GraphicsContext->GetSecondaryCommandBuffer(); + const auto extent = m_GraphicsContext->GetRenderTarget()->GetExtent(); + + const auto renderingInfo = SRenderingInfo{ + .ColorAttachment = m_GraphicsContext->GetRenderTarget(), + .DepthStencilAttachment = m_GraphicsContext->GetDepthStencilRenderTarget(), + .RenderArea = extent + }; + cmd->Begin(renderingInfo); + cmd->BeginRendering(renderingInfo); + + cmd->SetViewports({ + Viewport{ + .Width = (float)extent.Width, + .Height = (float)extent.Height, + .MinDepth = 0.0f, + .MaxDepth = 1.0f, + } + }); + + cmd->SetScissors({ + Rect2D{ + .Offset = { 0, 0 }, + .Extent = extent + } + }); + + for (const auto& scene : m_SubmittedScenes) + { + PrepareFrame(scene, m_CurrentFrameNumber); + const auto sceneResult = Render(cmd, scene, m_CurrentFrameNumber); + + result.MaterialCount += sceneResult.MaterialCount; + result.BatchCount += sceneResult.BatchCount; + result.DrawCount += sceneResult.DrawCount; + } + + cmd->EndRendering(); + cmd->End(); + + m_GraphicsContext->EnqueueSecondaryCommandBuffer(cmd); + + return result; + } void MaterialSystem::PrepareFrame( const MaterialRenderScene& scene, const uint64_t submissionSerial ) { - m_Textures.BeginFrame(submissionSerial); + const auto& frameBuffer = GetFrameBuffer(); const auto table = CreateRef( m_MaterialCapacity, @@ -73,12 +158,15 @@ namespace Elixir::Materials { const auto& material = item.Material; if (material) - table->Add(*material); + { + const auto proxy = Resolve(material); + if (proxy) table->Add(*proxy); + } } if (!table->GetData().empty()) { - m_FrameBuffer->UpdateData( + frameBuffer->UpdateData( table->GetData().data(), table->GetData().size() * sizeof(SMaterialFrameData) ); @@ -89,12 +177,16 @@ namespace Elixir::Materials .MaterialCount = table->GetCount(), .SubmissionSerial = submissionSerial, }; + + auto& slot = m_FrameSlots[m_GraphicsContext->GetFrameIndex()]; + slot.Table = table; + slot.FrameNumber = submissionSerial; } std::optional MaterialSystem::GetProgramKey( const EMaterialPass pass, const MaterialRenderProxy& material - ) const + ) { return m_Renderer->GetProgramKey(pass, material); } @@ -110,7 +202,7 @@ namespace Elixir::Materials const Ref& cmd, const MaterialRenderScene& scene, const uint64_t submissionSerial - ) const + ) { const auto isPrepared = m_PreparedFrame.Table && m_PreparedFrame.SubmissionSerial == submissionSerial; @@ -141,11 +233,14 @@ namespace Elixir::Materials EE_CORE_ASSERT(geometry, "Material render item geometry is unavailable.") if (!geometry) continue; - const auto program = GetProgramKey(item.Pass, *item.Material); + const auto material = Resolve(item.Material); + if (!material) continue; + + const auto program = GetProgramKey(item.Pass, *material); EE_CORE_ASSERT(program, "Material render item does not support its requested pass.") if (!program) continue; - const auto materialIndex = table->Find(*item.Material); + const auto materialIndex = table->Find(*material); EE_CORE_ASSERT(materialIndex, "Prepared material frame is missing a render item material.") if (!materialIndex) continue; @@ -172,22 +267,25 @@ namespace Elixir::Materials }); } - std::ranges::stable_sort(batches, [](const SMaterialBatch& left, const SMaterialBatch& right) - { - if (left.Key.Pass != right.Key.Pass) + std::ranges::stable_sort( + batches, + [](const SMaterialBatch& left, const SMaterialBatch& right) { - return Renderer::GetPassOrder(left.Key.Pass) < - Renderer::GetPassOrder(right.Key.Pass); + if (left.Key.Pass != right.Key.Pass) + { + return Renderer::GetPassOrder(left.Key.Pass) < + Renderer::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 + ); } - - 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) { @@ -199,12 +297,13 @@ namespace Elixir::Materials const auto& first = *batch.Items.front().Item; const auto prepared = PrepareMaterialPass({ .Pass = batch.Key.Pass, - .Material = first.Material.get(), + .Material = Resolve(first.Material).get(), .Pipeline = geometry->Pipeline, .ExternalResources = { .ConstantBuffers = geometry->ConstantBuffers, .StorageBuffers = geometry->StorageBuffers, }, + .MaterialBuffer = GetFrameBuffer(), .InitialPushConstants = std::span{ first.PushConstants.Data.data(), first.PushConstants.Size, @@ -258,6 +357,30 @@ namespace Elixir::Materials const Ref& instance ) { - return m_Renderer->Resolve(instance); + if (!instance || !instance->GetParent()) return nullptr; + + const auto materialRevision = instance->GetParent()->GetRevision(); + if (const auto found = m_MaterialCache.find(instance.get()); + found != m_MaterialCache.end() && + found->second.InstanceRevision == instance->GetRevision() && + found->second.MaterialRevision == materialRevision) + { + return found->second.Proxy; + } + + const auto proxy = m_Renderer->Resolve(instance); + m_MaterialCache.insert_or_assign(instance.get(), SCachedMaterial{ + .Proxy = proxy, + .InstanceRevision = instance->GetRevision(), + .MaterialRevision = materialRevision, + }); + + return proxy; + } + + const Ref& MaterialSystem::GetFrameBuffer() const + { + EE_CORE_ASSERT(m_GraphicsContext, "Material system graphics context is unavailable.") + return m_FrameSlots[m_GraphicsContext->GetFrameIndex()].Buffer; } } diff --git a/Elixir/Source/Engine/Materials/MaterialSystem.h b/Elixir/Source/Engine/Materials/MaterialSystem.h index bb9472b0..53c0edf1 100644 --- a/Elixir/Source/Engine/Materials/MaterialSystem.h +++ b/Elixir/Source/Engine/Materials/MaterialSystem.h @@ -1,5 +1,7 @@ #pragma once +#include "Engine/Graphics/FrameSlotState.h" + #include #include #include @@ -60,6 +62,25 @@ namespace Elixir::Materials 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. + */ + SMaterialRenderResult RenderFrame(); + /** * @brief Prepares and uploads material data for a scene submission. * @param scene Scene that provides material render items. @@ -76,7 +97,7 @@ namespace Elixir::Materials std::optional GetProgramKey( EMaterialPass pass, const MaterialRenderProxy& material - ) const; + ); /** * @brief Prepares GPU state for one material pass request. @@ -97,7 +118,7 @@ namespace Elixir::Materials const Ref& cmd, const MaterialRenderScene& scene, uint64_t submissionSerial - ) const; + ); /** * @brief Resolves an instance into a render-ready material proxy. @@ -108,8 +129,8 @@ namespace Elixir::Materials const Ref& instance ) override; - /** @brief Gets the buffer that stores frame material data. */ - const Ref& GetFrameBuffer() const { return m_FrameBuffer; } + /** @brief Gets the buffer that stores material data for the current frame slot. */ + const Ref& GetFrameBuffer() const; /** @brief Gets the texture set used by material rendering. */ const Ref& GetTextureSet() const { return m_Textures.GetTextureSet(); } @@ -118,6 +139,14 @@ namespace Elixir::Materials const Ref& GetSampler() const { return m_Textures.GetSampler(); } private: + /** @brief Stores material resources that are safe to reuse with one frame slot. */ + struct SFrameSlot + { + Ref Buffer; + Ref Table; + uint64_t FrameNumber = UINT64_MAX; + }; + /** @brief Stores material data prepared for the current submission. */ struct SPreparedFrame { @@ -126,10 +155,23 @@ namespace Elixir::Materials uint64_t SubmissionSerial = 0; }; + struct SCachedMaterial + { + Ref Proxy; + uint32_t InstanceRevision = 0; + uint32_t MaterialRevision = 0; + }; + uint32_t m_MaterialCapacity = 0; - Ref m_FrameBuffer; + std::array m_FrameSlots; TextureRegistry m_Textures; Scope m_Renderer; + + std::unordered_map m_MaterialCache; + std::vector m_SubmittedScenes; SPreparedFrame m_PreparedFrame; + uint64_t m_CurrentFrameNumber = UINT64_MAX; + + const GraphicsContext* m_GraphicsContext = nullptr; }; } diff --git a/Elixir/Source/Engine/Materials/Rendering/MaterialRenderScene.h b/Elixir/Source/Engine/Materials/Rendering/MaterialRenderScene.h index 51f5dfc3..aede595d 100644 --- a/Elixir/Source/Engine/Materials/Rendering/MaterialRenderScene.h +++ b/Elixir/Source/Engine/Materials/Rendering/MaterialRenderScene.h @@ -2,6 +2,7 @@ #include #include +#include #include namespace Elixir::Materials::Rendering @@ -123,8 +124,8 @@ namespace Elixir::Materials::Rendering /** Material pass used to render the item. */ EMaterialPass Pass = EMaterialPass::ParticleSprite; - /** Resolved material data used by the pass. */ - Ref Material; + /** Material instance used by the pass. */ + Ref Material; /** Index of the geometry used by this item. */ uint32_t GeometryIndex = UINT32_MAX; diff --git a/Elixir/Source/Engine/Materials/Rendering/Renderer.cpp b/Elixir/Source/Engine/Materials/Rendering/Renderer.cpp index 0dd56e63..d21966af 100644 --- a/Elixir/Source/Engine/Materials/Rendering/Renderer.cpp +++ b/Elixir/Source/Engine/Materials/Rendering/Renderer.cpp @@ -8,17 +8,15 @@ namespace Elixir::Materials::Rendering { Renderer::Renderer( const GraphicsContext* context, - Ref frameBuffer, const TextureRegistry& textures, const ShaderLoader* shaderLoader - ) : m_FrameBuffer(std::move(frameBuffer)), - m_Textures(textures), + ) : m_Textures(textures), m_CompilationCache(shaderLoader), m_Context(context) {} std::optional Renderer::Prepare(const SPassRequest& request) { - if (!request.Material || !request.Pipeline.VertexLayout) + if (!request.Material || !request.Pipeline.VertexLayout || !request.MaterialBuffer) return std::nullopt; const auto program = GetProgramKey(request.Pass, *request.Material); @@ -203,6 +201,9 @@ namespace Elixir::Materials::Rendering return false; } + if (shader->HasBinding("materials")) + shader->BindStorageBuffer("materials", request.MaterialBuffer); + return true; } @@ -222,9 +223,12 @@ namespace Elixir::Materials::Rendering ); } - shader->BindStorageBuffer("materials", m_FrameBuffer); - shader->BindTextureSet("sprites", m_Textures.GetTextureSet()); - shader->BindSampler("spriteSampler", m_Textures.GetSampler()); + 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 index fe913b89..d88be164 100644 --- a/Elixir/Source/Engine/Materials/Rendering/Renderer.h +++ b/Elixir/Source/Engine/Materials/Rendering/Renderer.h @@ -111,6 +111,9 @@ namespace Elixir::Materials::Rendering /** 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; }; @@ -142,14 +145,12 @@ namespace Elixir::Materials::Rendering /** * @brief Creates a material renderer. * @param context Graphics context used to create pipelines. - * @param frameBuffer Buffer that stores per-frame material data. * @param textures Registry that provides material textures and a sampler. * @param shaderLoader Loader used to compile material shaders. * @pre All arguments are valid for the renderer lifetime. */ Renderer( const GraphicsContext* context, - Ref frameBuffer, const TextureRegistry& textures, const ShaderLoader* shaderLoader ); @@ -258,7 +259,6 @@ namespace Elixir::Materials::Rendering /** Binds and validates the descriptor resources for a shader. */ bool BindDescriptorResources(const Ref& shader, const SPassRequest& request); - Ref m_FrameBuffer; const TextureRegistry& m_Textures; std::unordered_map, SPipelineKeyHasher> m_Pipelines; std::unordered_map m_DescriptorBindings; diff --git a/Elixir/Source/Graphics/Vulkan/VulkanShader.h b/Elixir/Source/Graphics/Vulkan/VulkanShader.h index 637fef05..68e86a65 100644 --- a/Elixir/Source/Graphics/Vulkan/VulkanShader.h +++ b/Elixir/Source/Graphics/Vulkan/VulkanShader.h @@ -1,7 +1,5 @@ #pragma once -#include - #include #include #include diff --git a/Elixir/Tests/Engine/Aether/Rendering/RendererTest.cpp b/Elixir/Tests/Engine/Aether/Rendering/RendererTest.cpp index 83f8d657..78b90725 100644 --- a/Elixir/Tests/Engine/Aether/Rendering/RendererTest.cpp +++ b/Elixir/Tests/Engine/Aether/Rendering/RendererTest.cpp @@ -1,17 +1,14 @@ #include -#include #include #include #include -#include namespace Elixir { class ShaderLoader; } using namespace Elixir; using namespace Elixir::Aether::Rendering; -using Elixir::Materials::MaterialSystem; namespace { @@ -27,21 +24,20 @@ namespace }; } -static_assert(!RendersFrameSubmission); +static_assert(!RendersFrameSubmission); static_assert(std::is_constructible_v< - Elixir::Aether::Rendering::Renderer, - const GraphicsContext*, - MaterialSystem& + Aether::Rendering::Renderer, + const GraphicsContext* >); static_assert(!std::is_constructible_v< - Elixir::Aether::Rendering::Renderer, + Aether::Rendering::Renderer, const GraphicsContext*, const ShaderLoader* >); TEST(RendererTest, MetricsContainOnlyRenderingResults) { - const SRenderingMetrics metrics{ + constexpr SRenderingMetrics metrics{ .SubmissionSerial = 12u, .RenderBatchCount = 4u, .SubmittedRenderItemCount = 6u, diff --git a/Elixir/Tests/Engine/Aether/SystemTest.cpp b/Elixir/Tests/Engine/Aether/SystemTest.cpp index 0daa3c53..ddcacbcf 100644 --- a/Elixir/Tests/Engine/Aether/SystemTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemTest.cpp @@ -3,7 +3,6 @@ #include #include #include -#include #include "TestInstanceRegistry.h" @@ -13,10 +12,7 @@ using namespace Elixir::Aether::Core; using namespace Elixir::Materials; template -concept HasPublicCompile = requires( - const T& system, - Materials::Rendering::MaterialResolver& resolver -) +concept HasPublicCompile = requires(const T& system, MaterialResolver& resolver) { system.Compile(resolver); }; @@ -131,7 +127,7 @@ TEST(SystemTest, FindsNamedEmitterForMaterialPublication) EXPECT_EQ(system.FindEmitter("Missing"), nullptr); } -TEST(SystemTest, CompileSnapshotsParticleSpriteMaterialForRenderData) +TEST(SystemTest, CompilePublishesParticleSpriteMaterialInstance) { const auto material = CreateRef("Particle tint"); ASSERT_TRUE(material->SetUsage(EMaterialUsage::ParticleSprite, true)); @@ -152,11 +148,11 @@ TEST(SystemTest, CompileSnapshotsParticleSpriteMaterialForRenderData) ASSERT_EQ(first.Emitters.size(), 1); ASSERT_TRUE(first.Emitters[0].Material); - EXPECT_TRUE(first.Emitters[0].Material->GetCompiledMaterial()->SupportsUsage( + EXPECT_TRUE(first.Emitters[0].Material->GetParent()->SupportsUsage( EMaterialUsage::ParticleSprite )); - EXPECT_EQ(first.Emitters[0].Material->GetInstanceRevision(), instance->GetRevision()); - EXPECT_FLOAT_EQ(first.Emitters[0].Material->GetValues()[0].x, 0.25f); + 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 })); @@ -164,8 +160,8 @@ TEST(SystemTest, CompileSnapshotsParticleSpriteMaterialForRenderData) const auto second = Compile(system); ASSERT_TRUE(second.Emitters[0].Material); - EXPECT_FLOAT_EQ(first.Emitters[0].Material->GetValues()[0].x, 0.25f); - EXPECT_FLOAT_EQ(second.Emitters[0].Material->GetValues()[0].x, 0.75f); + EXPECT_EQ(first.Emitters[0].Material, instance); + EXPECT_FLOAT_EQ(second.Emitters[0].Material->GetVector("Tint").x, 0.75f); } TEST(SystemTest, CompileSnapshotsParticleRibbonMaterialForRenderData) @@ -184,7 +180,7 @@ TEST(SystemTest, CompileSnapshotsParticleRibbonMaterialForRenderData) ASSERT_EQ(compiled.Emitters.size(), 1); ASSERT_TRUE(compiled.Emitters[0].Material); - EXPECT_TRUE(compiled.Emitters[0].Material->GetCompiledMaterial()->SupportsUsage( + EXPECT_TRUE(compiled.Emitters[0].Material->GetParent()->SupportsUsage( EMaterialUsage::ParticleRibbon )); } @@ -205,7 +201,7 @@ TEST(SystemTest, CompileSnapshotsParticleMeshMaterialForRenderData) ASSERT_EQ(compiled.Emitters.size(), 1); ASSERT_TRUE(compiled.Emitters[0].Material); - EXPECT_TRUE(compiled.Emitters[0].Material->GetCompiledMaterial()->SupportsUsage( + EXPECT_TRUE(compiled.Emitters[0].Material->GetParent()->SupportsUsage( EMaterialUsage::ParticleMesh )); } @@ -219,7 +215,7 @@ TEST(SystemTest, CompileAssignsTheDefaultMaterialWhenNoneIsExplicit) ASSERT_EQ(compiled.Emitters.size(), 1); ASSERT_TRUE(compiled.Emitters[0].Material); - EXPECT_TRUE(compiled.Emitters[0].Material->GetCompiledMaterial()->SupportsUsage( + EXPECT_TRUE(compiled.Emitters[0].Material->GetParent()->SupportsUsage( EMaterialUsage::ParticleSprite )); } From 134073943d74c07644f713f95d17d3a5b532ed95 Mon Sep 17 00:00:00 2001 From: MrChampz Date: Fri, 4 Sep 2026 18:00:21 -0300 Subject: [PATCH 84/89] Split frame slot state tracking --- .../Engine/Graphics/FrameSlotPendingState.h | 143 ++++++++++++++++ .../Source/Engine/Graphics/FrameSlotState.h | 159 ++++++------------ .../Engine/Materials/MaterialSystem.cpp | 11 +- .../Source/Engine/Materials/MaterialSystem.h | 5 +- .../Source/Graphics/Vulkan/VulkanShader.cpp | 22 ++- Elixir/Source/Graphics/Vulkan/VulkanShader.h | 4 +- .../Engine/Graphics/FrameSlotStateTest.cpp | 35 +++- 7 files changed, 244 insertions(+), 135 deletions(-) create mode 100644 Elixir/Source/Engine/Graphics/FrameSlotPendingState.h 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 index c3fc4eac..0608a463 100644 --- a/Elixir/Source/Engine/Graphics/FrameSlotState.h +++ b/Elixir/Source/Engine/Graphics/FrameSlotState.h @@ -6,145 +6,94 @@ namespace Elixir { /** - * @brief Stores a resource for each frame slot and applies pending keyed state. + * @brief Stores one value for each frame slot of a graphics context. * - * The associated graphics context selects the current frame slot. Each key - * keeps its desired value and the revision applied to every frame slot. + * The current value is selected from GraphicsContext::GetFrameIndex(). * - * @tparam TResource Resource stored for each frame slot. - * @tparam TKey Key that identifies an independently tracked state value. - * @tparam TValue Desired value associated with a key. + * @tparam T Value stored for each frame slot. */ - template + template class FrameSlotState final { public: /** - * @brief Describes one value pending application to a frame resource. - * - * References remain valid only for the duration of ApplyPendingState. - */ - struct SPendingState - { - const TKey& Key; - const TValue& Value; - }; - - /** - * @brief Creates frame resources associated with a graphics context. - * @param context Context that owns the frame slots. + * @brief Creates values associated with a graphics context. + * @param context Graphics context that owns frame slots. */ explicit FrameSlotState(const GraphicsContext& context) - : m_Context(context) {} + : m_GraphicsContext(context) {} /** - * @brief Stores a desired value when it differs from the current value. - * @param key The key. - * @param value The value. - * @return True when the value changed and must be applied to frame slots. + * @brief Returns the index of the current frame slot. + * @return The index of the current frame slot. */ - bool Set(const TKey& key, TValue value) + uint32_t GetCurrentFrameIndex() const { - 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; + return m_GraphicsContext.GetFrameIndex(); } /** - * @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. + * @brief Returns the value for the current frame slot. + * @return The value stored for the currently active frame slot. */ - template - requires std::invocable> - void ApplyPendingState(TApply&& apply) + T& GetCurrent() { - const auto frameIndex = m_Context.GetFrameIndex(); - EE_CORE_ASSERT(frameIndex < FRAMES, "Frame slot index is out of range.") - - 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[frameIndex], - std::span(pendingValues) - ); - - for (auto& state : pendingStates) - state.get().AppliedRevisions[frameIndex] = state.get().Revision; + return m_Values[GetCurrentFrameIndex()]; } - /** @brief Returns the resource for the current frame slot. */ - TResource& GetCurrentFrameResource() + /** + * @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_Resources[m_Context.GetFrameIndex()]; + return m_Values[GetCurrentFrameIndex()]; } - /** @brief Returns the resource for the current frame slot. */ - const TResource& GetCurrentFrameResource() const + /** + * @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) { - return m_Resources[m_Context.GetFrameIndex()]; + ValidateFrameIndex(frameIndex); + return m_Values[frameIndex]; } - /** @brief Returns the resource for a specific frame slot. */ - TResource& GetResource(const uint32_t 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 { - EE_CORE_ASSERT(frameIndex < FRAMES, "Frame slot index is out of range.") - return m_Resources[frameIndex]; + ValidateFrameIndex(frameIndex); + return m_Values[frameIndex]; } - /** @brief Returns the resource for a specific frame slot. */ - const TResource& GetResource(const uint32_t frameIndex) const + /** + * @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) { - EE_CORE_ASSERT(frameIndex < FRAMES, "Frame slot index is out of range.") - return m_Resources[frameIndex]; + for (auto& value : m_Values) + std::invoke(std::forward(function), value); } private: - static constexpr uint32_t FRAMES = GraphicsContext::FRAMES; - - struct SState + static void ValidateFrameIndex(const uint32_t frameIndex) { - TValue Value; - uint64_t Revision = 0; - std::array AppliedRevisions{}; - }; + EE_CORE_ASSERT( + frameIndex < GraphicsContext::FRAMES, + "Frame slot index is out of range." + ) + } - const GraphicsContext& m_Context; - std::array m_Resources; - std::unordered_map m_States; + std::array m_Values; + const GraphicsContext& m_GraphicsContext; }; } diff --git a/Elixir/Source/Engine/Materials/MaterialSystem.cpp b/Elixir/Source/Engine/Materials/MaterialSystem.cpp index 27aa13c4..747a9968 100644 --- a/Elixir/Source/Engine/Materials/MaterialSystem.cpp +++ b/Elixir/Source/Engine/Materials/MaterialSystem.cpp @@ -41,6 +41,7 @@ namespace Elixir::Materials const ShaderLoader* shaderLoader, const SMaterialSystemConfig config ) : m_MaterialCapacity(GetInitialFrameCapacity(config)), + m_FrameSlots(*context), m_Textures(context), m_Renderer(CreateScope( context, @@ -51,13 +52,13 @@ namespace Elixir::Materials { EE_CORE_ASSERT(context, "Material system requires a graphics context.") - for (auto& slot : m_FrameSlots) + m_FrameSlots.ForEach([&](SFrameSlot& slot) { slot.Buffer = DynamicStorageBuffer::Create( context, sizeof(SMaterialFrameData) * m_MaterialCapacity ); - } + }); } void MaterialSystem::BeginFrame() @@ -68,7 +69,7 @@ namespace Elixir::Materials m_SubmittedScenes.clear(); m_Textures.BeginFrame(m_CurrentFrameNumber); - auto& slot = m_FrameSlots[m_GraphicsContext->GetFrameIndex()]; + auto& slot = m_FrameSlots.GetCurrent(); slot.Table.reset(); slot.FrameNumber = m_CurrentFrameNumber; m_PreparedFrame = {}; @@ -178,7 +179,7 @@ namespace Elixir::Materials .SubmissionSerial = submissionSerial, }; - auto& slot = m_FrameSlots[m_GraphicsContext->GetFrameIndex()]; + auto& slot = m_FrameSlots.GetCurrent(); slot.Table = table; slot.FrameNumber = submissionSerial; } @@ -381,6 +382,6 @@ namespace Elixir::Materials const Ref& MaterialSystem::GetFrameBuffer() const { EE_CORE_ASSERT(m_GraphicsContext, "Material system graphics context is unavailable.") - return m_FrameSlots[m_GraphicsContext->GetFrameIndex()].Buffer; + return m_FrameSlots.GetCurrent().Buffer; } } diff --git a/Elixir/Source/Engine/Materials/MaterialSystem.h b/Elixir/Source/Engine/Materials/MaterialSystem.h index 53c0edf1..dc3b2446 100644 --- a/Elixir/Source/Engine/Materials/MaterialSystem.h +++ b/Elixir/Source/Engine/Materials/MaterialSystem.h @@ -1,8 +1,7 @@ #pragma once -#include "Engine/Graphics/FrameSlotState.h" - #include +#include #include #include #include @@ -163,7 +162,7 @@ namespace Elixir::Materials }; uint32_t m_MaterialCapacity = 0; - std::array m_FrameSlots; + FrameSlotState m_FrameSlots; TextureRegistry m_Textures; Scope m_Renderer; diff --git a/Elixir/Source/Graphics/Vulkan/VulkanShader.cpp b/Elixir/Source/Graphics/Vulkan/VulkanShader.cpp index a7afdf1f..dd4e3630 100644 --- a/Elixir/Source/Graphics/Vulkan/VulkanShader.cpp +++ b/Elixir/Source/Graphics/Vulkan/VulkanShader.cpp @@ -35,12 +35,11 @@ namespace Elixir::Vulkan nullptr ); - for (uint32_t frameIndex = 0; frameIndex < GraphicsContext::FRAMES; ++frameIndex) + m_DescriptorSets.ForEach([this](auto& sets) { - auto& sets = m_DescriptorSets.GetResource(frameIndex); if (!sets.empty()) m_GraphicsContext->GetDescriptorPool()->FreeDescriptorSets(sets); - } + }); for (const auto& layout : m_DescriptorSetLayouts) { @@ -228,7 +227,7 @@ namespace Elixir::Vulkan std::vector VulkanShader::GetDescriptorSets() const { - std::vector sets(m_DescriptorSets.GetCurrentFrameResource()); + std::vector sets(m_DescriptorSets.GetCurrent()); if (m_BindlessSet) { const auto bindlessPool = m_GraphicsContext->GetBindlessDescriptorPool(); @@ -343,9 +342,8 @@ namespace Elixir::Vulkan if (m_DescriptorSetLayouts.empty()) return; - for (uint32_t frameIndex = 0; frameIndex < GraphicsContext::FRAMES; ++frameIndex) + m_DescriptorSets.ForEach([this](auto& sets) { - auto& sets = m_DescriptorSets.GetResource(frameIndex); sets.resize(m_DescriptorSetLayouts.size()); for (auto i = 0; i < m_DescriptorSetLayouts.size(); ++i) @@ -365,7 +363,7 @@ namespace Elixir::Vulkan ) ); } - } + }); } void VulkanShader::CreatePipelineLayout() @@ -451,7 +449,7 @@ namespace Elixir::Vulkan VkWriteDescriptorSet writeSet = {}; writeSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writeSet.dstSet = m_DescriptorSets.GetCurrentFrameResource()[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(); @@ -482,7 +480,7 @@ namespace Elixir::Vulkan VkWriteDescriptorSet writeSet = {}; writeSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writeSet.dstSet = m_DescriptorSets.GetCurrentFrameResource()[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(); @@ -503,7 +501,7 @@ namespace Elixir::Vulkan VkWriteDescriptorSet writeSet = {}; writeSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writeSet.dstSet = m_DescriptorSets.GetCurrentFrameResource()[resource.GetSet()]; + writeSet.dstSet = m_DescriptorSets.GetCurrent()[resource.GetSet()]; writeSet.dstBinding = resource.GetBinding(); writeSet.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; writeSet.descriptorCount = 1; @@ -524,7 +522,7 @@ namespace Elixir::Vulkan VkWriteDescriptorSet writeSet = {}; writeSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writeSet.dstSet = m_DescriptorSets.GetCurrentFrameResource()[resource.GetSet()]; + writeSet.dstSet = m_DescriptorSets.GetCurrent()[resource.GetSet()]; writeSet.dstBinding = resource.GetBinding(); writeSet.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; writeSet.descriptorCount = 1; @@ -545,7 +543,7 @@ namespace Elixir::Vulkan VkWriteDescriptorSet writeSet = {}; writeSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writeSet.dstSet = m_DescriptorSets.GetCurrentFrameResource()[resource.GetSet()]; + writeSet.dstSet = m_DescriptorSets.GetCurrent()[resource.GetSet()]; writeSet.dstBinding = resource.GetBinding(); writeSet.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; writeSet.descriptorCount = 1; diff --git a/Elixir/Source/Graphics/Vulkan/VulkanShader.h b/Elixir/Source/Graphics/Vulkan/VulkanShader.h index 68e86a65..c65928f1 100644 --- a/Elixir/Source/Graphics/Vulkan/VulkanShader.h +++ b/Elixir/Source/Graphics/Vulkan/VulkanShader.h @@ -1,6 +1,6 @@ #pragma once -#include +#include #include #include @@ -76,7 +76,7 @@ namespace Elixir::Vulkan Ref >; - using DescriptorSetState = FrameSlotState< + using DescriptorSetState = FrameSlotPendingState< std::vector, SShaderBinding, DescriptorValue diff --git a/Elixir/Tests/Engine/Graphics/FrameSlotStateTest.cpp b/Elixir/Tests/Engine/Graphics/FrameSlotStateTest.cpp index c6820821..e383967b 100644 --- a/Elixir/Tests/Engine/Graphics/FrameSlotStateTest.cpp +++ b/Elixir/Tests/Engine/Graphics/FrameSlotStateTest.cpp @@ -1,5 +1,6 @@ #include +#include #include namespace Elixir @@ -29,10 +30,28 @@ namespace Elixir void CreateRenderTargets() override {} }; - TEST(FrameSlotStateTest, AppliesEachRevisionOncePerFrameSlot) + TEST(FrameSlotStateTest, SelectsValuesForTheCurrentFrameSlot) { FrameSlotStateTestContext context; - FrameSlotState state(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) @@ -47,27 +66,27 @@ namespace Elixir context.SetFrameNumber(0); state.ApplyPendingState(apply); state.ApplyPendingState(apply); - EXPECT_EQ(state.GetCurrentFrameResource(), 3u); + EXPECT_EQ(state.GetCurrent(), 3u); context.SetFrameNumber(1); state.ApplyPendingState(apply); - EXPECT_EQ(state.GetCurrentFrameResource(), 3u); + EXPECT_EQ(state.GetCurrent(), 3u); EXPECT_EQ(applyCount, 2u); EXPECT_TRUE(state.Set("value", 8)); state.ApplyPendingState(apply); - EXPECT_EQ(state.GetCurrentFrameResource(), 8u); + EXPECT_EQ(state.GetCurrent(), 8u); context.SetFrameNumber(2); state.ApplyPendingState(apply); - EXPECT_EQ(state.GetCurrentFrameResource(), 8u); + EXPECT_EQ(state.GetCurrent(), 8u); EXPECT_EQ(applyCount, 4u); } - TEST(FrameSlotStateTest, DoesNotApplyAnUnchangedValue) + TEST(FrameSlotPendingStateTest, DoesNotApplyAnUnchangedValue) { FrameSlotStateTestContext context; - FrameSlotState state(context); + FrameSlotPendingState state(context); uint32_t applyCount = 0; EXPECT_TRUE(state.Set("value", 3)); From adb5b197a50b7618a9c35b30cdb20a70b467c14b Mon Sep 17 00:00:00 2001 From: MrChampz Date: Sat, 5 Sep 2026 02:15:57 -0300 Subject: [PATCH 85/89] Extract material proxy caching --- Elixir/Source/Engine/Aether/Emitter.cpp | 3 +- Elixir/Source/Engine/Aether/Emitter.h | 3 +- Elixir/Source/Engine/Aether/Manager.cpp | 5 +- .../Aether/Runtime/InstanceRegistry.cpp | 20 ++--- .../Engine/Aether/Runtime/InstanceRegistry.h | 11 +-- Elixir/Source/Engine/Aether/System.cpp | 5 +- Elixir/Source/Engine/Aether/System.h | 2 +- .../Engine/Materials/MaterialProxyCache.cpp | 53 ++++++++++++ .../Engine/Materials/MaterialProxyCache.h | 49 +++++++++++ .../Materials/MaterialProxyResolver.cpp | 21 +++++ .../Engine/Materials/MaterialProxyResolver.h | 40 +++++++++ .../Engine/Materials/MaterialSystem.cpp | 70 ++++----------- .../Source/Engine/Materials/MaterialSystem.h | 53 +++--------- .../Engine/Materials/Rendering/Renderer.cpp | 15 +--- .../Engine/Materials/Rendering/Renderer.h | 19 +--- Elixir/Tests/Engine/Aether/SystemTest.cpp | 4 +- .../Engine/Aether/TestInstanceRegistry.h | 5 +- .../Materials/MaterialProxyCacheTest.cpp | 86 +++++++++++++++++++ 18 files changed, 299 insertions(+), 165 deletions(-) create mode 100644 Elixir/Source/Engine/Materials/MaterialProxyCache.cpp create mode 100644 Elixir/Source/Engine/Materials/MaterialProxyCache.h create mode 100644 Elixir/Source/Engine/Materials/MaterialProxyResolver.cpp create mode 100644 Elixir/Source/Engine/Materials/MaterialProxyResolver.h create mode 100644 Elixir/Tests/Engine/Materials/MaterialProxyCacheTest.cpp diff --git a/Elixir/Source/Engine/Aether/Emitter.cpp b/Elixir/Source/Engine/Aether/Emitter.cpp index 7d3df2d2..3589190e 100644 --- a/Elixir/Source/Engine/Aether/Emitter.cpp +++ b/Elixir/Source/Engine/Aether/Emitter.cpp @@ -37,8 +37,7 @@ namespace Elixir::Aether SCompiledEmitter Emitter::Compile( const ParameterStore& paramStore, const std::vector& params, - std::vector& ops, - MaterialResolver& materialResolver + std::vector& ops ) const { SCompiledEmitter emitter; diff --git a/Elixir/Source/Engine/Aether/Emitter.h b/Elixir/Source/Engine/Aether/Emitter.h index 29631ee1..251b338d 100644 --- a/Elixir/Source/Engine/Aether/Emitter.h +++ b/Elixir/Source/Engine/Aether/Emitter.h @@ -325,8 +325,7 @@ namespace Elixir::Aether SCompiledEmitter Compile( const ParameterStore& paramStore, const std::vector& params, - std::vector& ops, - MaterialResolver& materialResolver + std::vector& ops ) const; UUID m_Id; diff --git a/Elixir/Source/Engine/Aether/Manager.cpp b/Elixir/Source/Engine/Aether/Manager.cpp index 197a6d9b..2c3f138c 100644 --- a/Elixir/Source/Engine/Aether/Manager.cpp +++ b/Elixir/Source/Engine/Aether/Manager.cpp @@ -16,10 +16,7 @@ namespace Elixir::Aether const ShaderLoader* shaderLoader, MaterialRegistry& materialRegistry, MaterialSystem& materialSystem - ) : m_Runtime(CreateScope( - materialRegistry, - materialSystem - )), + ) : m_Runtime(CreateScope(materialRegistry)), m_Simulator(CreateScope(context, shaderLoader)), m_Renderer(CreateScope(context)), m_MaterialSystem(materialSystem), diff --git a/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp index 213a504f..e1c8b92c 100644 --- a/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp +++ b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.cpp @@ -2,15 +2,11 @@ #include "InstanceRegistry.h" #include -#include namespace Elixir::Aether::Runtime { - InstanceRegistry::InstanceRegistry( - MaterialRegistry& materialRegistry, - MaterialResolver& materialResolver - ) : m_EffectMaterials(materialRegistry), - m_MaterialResolver(materialResolver) {} + InstanceRegistry::InstanceRegistry(MaterialRegistry& materialRegistry) + : m_EffectMaterials(materialRegistry) {} bool InstanceRegistry::Recompile(const Ref& system) { @@ -124,9 +120,7 @@ namespace Elixir::Aether::Runtime return m_Publisher.Acquire(); } - Ref InstanceRegistry::CompileSystem( - const System& system - ) const + Ref InstanceRegistry::CompileSystem(const System& system) const { if (!m_EffectMaterials.Resolve(system)) { @@ -137,14 +131,10 @@ namespace Elixir::Aether::Runtime return nullptr; } - return CreateRef( - system.Compile(m_MaterialResolver) - ); + return CreateRef(system.Compile()); } - bool InstanceRegistry::IsManagedInstance( - const Ref& instance - ) const + bool InstanceRegistry::IsManagedInstance(const Ref& instance) const { if (!instance) return false; diff --git a/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.h b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.h index e5d7ec30..ef685f66 100644 --- a/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.h +++ b/Elixir/Source/Engine/Aether/Runtime/InstanceRegistry.h @@ -7,7 +7,6 @@ namespace Elixir::Materials { class MaterialRegistry; - namespace Rendering { class MaterialResolver; } } namespace Elixir::Aether::Runtime @@ -26,7 +25,7 @@ namespace Elixir::Aether::Runtime * System after creating its instances. The compiled representation remains * available until this registry is destroyed. * - * @note MaterialRegistry and MaterialResolver must outlive this registry. + * @note MaterialRegistry must outlive this registry. * * @thread_safety Public methods synchronize compilation, instance ownership, * and frame publication. @@ -36,14 +35,9 @@ namespace Elixir::Aether::Runtime public: /** * @brief Creates an instance registry. - * * @param materialRegistry Stores effect-authored and default materials. - * @param materialResolver Compiles material instances into render proxies. */ - InstanceRegistry( - MaterialRegistry& materialRegistry, - MaterialResolver& materialResolver - ); + explicit InstanceRegistry(MaterialRegistry& materialRegistry); /** * @brief Recompiles a System and updates all of its registered instances. @@ -105,7 +99,6 @@ namespace Elixir::Aether::Runtime bool IsManagedInstance(const Ref& instance) const; Effect::MaterialResolver m_EffectMaterials; - MaterialResolver& m_MaterialResolver; std::unordered_map> m_CompiledSystems; std::unordered_map> m_Instances; diff --git a/Elixir/Source/Engine/Aether/System.cpp b/Elixir/Source/Engine/Aether/System.cpp index f3856372..46337f30 100644 --- a/Elixir/Source/Engine/Aether/System.cpp +++ b/Elixir/Source/Engine/Aether/System.cpp @@ -61,7 +61,7 @@ namespace Elixir::Aether return found->Value; } - SCompiledSystem System::Compile(MaterialResolver& materialResolver) const + SCompiledSystem System::Compile() const { SCompiledSystem system; system.SourceId = m_UUID; @@ -125,8 +125,7 @@ namespace Elixir::Aether auto compiled = emitter->Compile( m_Parameters, system.Parameters, - system.Ops, - materialResolver + system.Ops ); compiled.LocalParticleOffset = localParticleOffset; diff --git a/Elixir/Source/Engine/Aether/System.h b/Elixir/Source/Engine/Aether/System.h index bc4e8617..d74af16e 100644 --- a/Elixir/Source/Engine/Aether/System.h +++ b/Elixir/Source/Engine/Aether/System.h @@ -189,7 +189,7 @@ namespace Elixir::Aether std::optional GetParameterDefault(std::string_view name) const; // Compiles the authored system into immutable runtime data. - SCompiledSystem Compile(MaterialResolver& materialResolver) const; + SCompiledSystem Compile() const; UUID m_UUID; mutable uint32_t m_CompilationRevision = 0; 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/MaterialSystem.cpp b/Elixir/Source/Engine/Materials/MaterialSystem.cpp index 747a9968..9e7195a1 100644 --- a/Elixir/Source/Engine/Materials/MaterialSystem.cpp +++ b/Elixir/Source/Engine/Materials/MaterialSystem.cpp @@ -43,11 +43,9 @@ namespace Elixir::Materials ) : m_MaterialCapacity(GetInitialFrameCapacity(config)), m_FrameSlots(*context), m_Textures(context), - m_Renderer(CreateScope( - context, - m_Textures, - shaderLoader - )), + m_ProxyResolver(shaderLoader), + m_ProxyCache(m_ProxyResolver), + m_Renderer(CreateScope(context, m_Textures)), m_GraphicsContext(context) { EE_CORE_ASSERT(context, "Material system requires a graphics context.") @@ -67,6 +65,7 @@ namespace Elixir::Materials m_CurrentFrameNumber = m_GraphicsContext->GetFrameNumber(); m_SubmittedScenes.clear(); + m_ProxyCache.PruneExpired(); m_Textures.BeginFrame(m_CurrentFrameNumber); auto& slot = m_FrameSlots.GetCurrent(); @@ -123,8 +122,8 @@ namespace Elixir::Materials for (const auto& scene : m_SubmittedScenes) { - PrepareFrame(scene, m_CurrentFrameNumber); - const auto sceneResult = Render(cmd, scene, m_CurrentFrameNumber); + PrepareScene(scene, m_CurrentFrameNumber); + const auto sceneResult = RecordScene(cmd, scene, m_CurrentFrameNumber); result.MaterialCount += sceneResult.MaterialCount; result.BatchCount += sceneResult.BatchCount; @@ -139,12 +138,12 @@ namespace Elixir::Materials return result; } - void MaterialSystem::PrepareFrame( + void MaterialSystem::PrepareScene( const MaterialRenderScene& scene, const uint64_t submissionSerial ) { - const auto& frameBuffer = GetFrameBuffer(); + const auto& frameBuffer = GetActiveFrameBuffer(); const auto table = CreateRef( m_MaterialCapacity, @@ -160,7 +159,7 @@ namespace Elixir::Materials const auto& material = item.Material; if (material) { - const auto proxy = Resolve(material); + const auto proxy = ResolveMaterialProxy(material); if (proxy) table->Add(*proxy); } } @@ -184,22 +183,7 @@ namespace Elixir::Materials slot.FrameNumber = submissionSerial; } - std::optional MaterialSystem::GetProgramKey( - const EMaterialPass pass, - const MaterialRenderProxy& material - ) - { - return m_Renderer->GetProgramKey(pass, material); - } - - std::optional MaterialSystem::PrepareMaterialPass( - const SPassRequest& request - ) const - { - return m_Renderer->Prepare(request); - } - - SMaterialRenderResult MaterialSystem::Render( + SMaterialRenderResult MaterialSystem::RecordScene( const Ref& cmd, const MaterialRenderScene& scene, const uint64_t submissionSerial @@ -234,10 +218,10 @@ namespace Elixir::Materials EE_CORE_ASSERT(geometry, "Material render item geometry is unavailable.") if (!geometry) continue; - const auto material = Resolve(item.Material); + const auto material = ResolveMaterialProxy(item.Material); if (!material) continue; - const auto program = GetProgramKey(item.Pass, *material); + const auto program = m_Renderer->GetProgramKey(item.Pass, *material); EE_CORE_ASSERT(program, "Material render item does not support its requested pass.") if (!program) continue; @@ -296,15 +280,15 @@ namespace Elixir::Materials if (!geometry) continue; const auto& first = *batch.Items.front().Item; - const auto prepared = PrepareMaterialPass({ + const auto prepared = m_Renderer->Prepare({ .Pass = batch.Key.Pass, - .Material = Resolve(first.Material).get(), + .Material = ResolveMaterialProxy(first.Material).get(), .Pipeline = geometry->Pipeline, .ExternalResources = { .ConstantBuffers = geometry->ConstantBuffers, .StorageBuffers = geometry->StorageBuffers, }, - .MaterialBuffer = GetFrameBuffer(), + .MaterialBuffer = GetActiveFrameBuffer(), .InitialPushConstants = std::span{ first.PushConstants.Data.data(), first.PushConstants.Size, @@ -354,32 +338,14 @@ namespace Elixir::Materials return result; } - Ref MaterialSystem::Resolve( + Ref MaterialSystem::ResolveMaterialProxy( const Ref& instance ) { - if (!instance || !instance->GetParent()) return nullptr; - - const auto materialRevision = instance->GetParent()->GetRevision(); - if (const auto found = m_MaterialCache.find(instance.get()); - found != m_MaterialCache.end() && - found->second.InstanceRevision == instance->GetRevision() && - found->second.MaterialRevision == materialRevision) - { - return found->second.Proxy; - } - - const auto proxy = m_Renderer->Resolve(instance); - m_MaterialCache.insert_or_assign(instance.get(), SCachedMaterial{ - .Proxy = proxy, - .InstanceRevision = instance->GetRevision(), - .MaterialRevision = materialRevision, - }); - - return proxy; + return m_ProxyCache.Resolve(instance); } - const Ref& MaterialSystem::GetFrameBuffer() const + const Ref& MaterialSystem::GetActiveFrameBuffer() const { EE_CORE_ASSERT(m_GraphicsContext, "Material system graphics context is unavailable.") return m_FrameSlots.GetCurrent().Buffer; diff --git a/Elixir/Source/Engine/Materials/MaterialSystem.h b/Elixir/Source/Engine/Materials/MaterialSystem.h index dc3b2446..8eb41985 100644 --- a/Elixir/Source/Engine/Materials/MaterialSystem.h +++ b/Elixir/Source/Engine/Materials/MaterialSystem.h @@ -2,7 +2,8 @@ #include #include -#include +#include +#include #include #include #include @@ -44,7 +45,7 @@ namespace Elixir::Materials * The system owns shared material buffers, texture bindings, and render-state * preparation for a graphics context. */ - class ELIXIR_API MaterialSystem final : public MaterialResolver + class ELIXIR_API MaterialSystem final { public: /** @@ -80,30 +81,13 @@ namespace Elixir::Materials */ SMaterialRenderResult RenderFrame(); + private: /** * @brief Prepares and uploads material data for a scene submission. * @param scene Scene that provides material render items. * @param submissionSerial Serial that identifies the submission. */ - void PrepareFrame(const MaterialRenderScene& scene, uint64_t submissionSerial); - - /** - * @brief Gets the shader program key required for a material pass. - * @param pass Material pass to prepare. - * @param material Resolved material data. - * @return The program key, or no value when the pass is unsupported. - */ - std::optional GetProgramKey( - EMaterialPass pass, - const MaterialRenderProxy& material - ); - - /** - * @brief Prepares GPU state for one material pass request. - * @param request Material pass and geometry requirements. - * @return Prepared pass state, or no value when preparation fails. - */ - std::optional PrepareMaterialPass(const SPassRequest& request) const; + void PrepareScene(const MaterialRenderScene& scene, uint64_t submissionSerial); /** * @brief Records draw commands for the scene materials. @@ -111,9 +95,9 @@ namespace Elixir::Materials * @param scene Scene that provides render items. * @param submissionSerial Serial passed to @ref PreparedFrame. * @return Counts of rendered batches and recorded draw commands. - * @pre @ref PrepareFrame was called for @p submissionSerial. + * @pre @ref PrepareScene was called for @p submissionSerial. */ - SMaterialRenderResult Render( + SMaterialRenderResult RecordScene( const Ref& cmd, const MaterialRenderScene& scene, uint64_t submissionSerial @@ -124,20 +108,11 @@ namespace Elixir::Materials * @param instance Material instance to resolve. * @return The render proxy, or null when the instance cannot be resolved. */ - Ref Resolve( - const Ref& instance - ) override; + Ref ResolveMaterialProxy(const Ref& instance); /** @brief Gets the buffer that stores material data for the current frame slot. */ - const Ref& GetFrameBuffer() const; - - /** @brief Gets the texture set used by material rendering. */ - const Ref& GetTextureSet() const { return m_Textures.GetTextureSet(); } + const Ref& GetActiveFrameBuffer() const; - /** @brief Gets the sampler used by material textures. */ - const Ref& GetSampler() const { return m_Textures.GetSampler(); } - - private: /** @brief Stores material resources that are safe to reuse with one frame slot. */ struct SFrameSlot { @@ -154,19 +129,13 @@ namespace Elixir::Materials uint64_t SubmissionSerial = 0; }; - struct SCachedMaterial - { - Ref Proxy; - uint32_t InstanceRevision = 0; - uint32_t MaterialRevision = 0; - }; - uint32_t m_MaterialCapacity = 0; FrameSlotState m_FrameSlots; TextureRegistry m_Textures; + MaterialProxyResolver m_ProxyResolver; + MaterialProxyCache m_ProxyCache; Scope m_Renderer; - std::unordered_map m_MaterialCache; std::vector m_SubmittedScenes; SPreparedFrame m_PreparedFrame; uint64_t m_CurrentFrameNumber = UINT64_MAX; diff --git a/Elixir/Source/Engine/Materials/Rendering/Renderer.cpp b/Elixir/Source/Engine/Materials/Rendering/Renderer.cpp index d21966af..129b637d 100644 --- a/Elixir/Source/Engine/Materials/Rendering/Renderer.cpp +++ b/Elixir/Source/Engine/Materials/Rendering/Renderer.cpp @@ -2,16 +2,13 @@ #include "Renderer.h" #include -#include namespace Elixir::Materials::Rendering { Renderer::Renderer( const GraphicsContext* context, - const TextureRegistry& textures, - const ShaderLoader* shaderLoader + const TextureRegistry& textures ) : m_Textures(textures), - m_CompilationCache(shaderLoader), m_Context(context) {} std::optional Renderer::Prepare(const SPassRequest& request) @@ -44,16 +41,6 @@ namespace Elixir::Materials::Rendering }; } - Ref Renderer::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; - } - std::optional Renderer::GetProgramKey( const EMaterialPass pass, const MaterialRenderProxy& material diff --git a/Elixir/Source/Engine/Materials/Rendering/Renderer.h b/Elixir/Source/Engine/Materials/Rendering/Renderer.h index d88be164..0ddf6064 100644 --- a/Elixir/Source/Engine/Materials/Rendering/Renderer.h +++ b/Elixir/Source/Engine/Materials/Rendering/Renderer.h @@ -2,7 +2,6 @@ #include #include -#include #include #include @@ -134,10 +133,10 @@ namespace Elixir::Materials::Rendering }; /** - * @brief Compiles material instances and prepares their render passes. + * @brief Prepares material render passes. * - * The renderer caches compiled materials, descriptor bindings, and graphics - * pipelines for reuse across render items. + * The renderer caches descriptor bindings and graphics pipelines for reuse + * across render items. */ class ELIXIR_API Renderer final { @@ -146,13 +145,11 @@ namespace Elixir::Materials::Rendering * @brief Creates a material renderer. * @param context Graphics context used to create pipelines. * @param textures Registry that provides material textures and a sampler. - * @param shaderLoader Loader used to compile material shaders. * @pre All arguments are valid for the renderer lifetime. */ Renderer( const GraphicsContext* context, - const TextureRegistry& textures, - const ShaderLoader* shaderLoader + const TextureRegistry& textures ); /** @@ -164,13 +161,6 @@ namespace Elixir::Materials::Rendering */ std::optional Prepare(const SPassRequest& request); - /** - * @brief Resolves an instance into render-ready material data. - * @param instance Material instance to resolve. - * @return Render proxy, or null if the instance cannot be compiled. - */ - Ref Resolve(const Ref& instance); - /** * @brief Returns the program key for a material pass. * @param pass Material pass. @@ -262,7 +252,6 @@ namespace Elixir::Materials::Rendering const TextureRegistry& m_Textures; std::unordered_map, SPipelineKeyHasher> m_Pipelines; std::unordered_map m_DescriptorBindings; - CompilationCache m_CompilationCache; const GraphicsContext* m_Context = nullptr; }; diff --git a/Elixir/Tests/Engine/Aether/SystemTest.cpp b/Elixir/Tests/Engine/Aether/SystemTest.cpp index ddcacbcf..5a051060 100644 --- a/Elixir/Tests/Engine/Aether/SystemTest.cpp +++ b/Elixir/Tests/Engine/Aether/SystemTest.cpp @@ -12,9 +12,9 @@ using namespace Elixir::Aether::Core; using namespace Elixir::Materials; template -concept HasPublicCompile = requires(const T& system, MaterialResolver& resolver) +concept HasPublicCompile = requires(const T& system) { - system.Compile(resolver); + system.Compile(); }; static_assert(!HasPublicCompile); diff --git a/Elixir/Tests/Engine/Aether/TestInstanceRegistry.h b/Elixir/Tests/Engine/Aether/TestInstanceRegistry.h index dcb4545d..3b31c6dd 100644 --- a/Elixir/Tests/Engine/Aether/TestInstanceRegistry.h +++ b/Elixir/Tests/Engine/Aether/TestInstanceRegistry.h @@ -3,8 +3,6 @@ #include #include -#include "TestMaterialResolver.h" - using namespace Elixir; using namespace Elixir::Aether; using namespace Elixir::Materials; @@ -13,11 +11,10 @@ class TestInstanceRegistry final { private: MaterialRegistry m_MaterialRegistry; - TestMaterialResolver m_MaterialResolver; public: TestInstanceRegistry() - : Registry(m_MaterialRegistry, m_MaterialResolver) {} + : Registry(m_MaterialRegistry) {} Ref CreateRegisteredInstance(const Ref& system) { 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); +} From be01f94e2e6498aa5e8cf80b8aa06eabe8a2be09 Mon Sep 17 00:00:00 2001 From: MrChampz Date: Sun, 6 Sep 2026 13:50:31 -0300 Subject: [PATCH 86/89] Move material scene recording to renderer --- .../Engine/Materials/MaterialSystem.cpp | 230 +++--------------- .../Source/Engine/Materials/MaterialSystem.h | 56 +---- .../Engine/Materials/Rendering/Renderer.cpp | 129 ++++++++++ .../Engine/Materials/Rendering/Renderer.h | 80 ++++++ 4 files changed, 248 insertions(+), 247 deletions(-) diff --git a/Elixir/Source/Engine/Materials/MaterialSystem.cpp b/Elixir/Source/Engine/Materials/MaterialSystem.cpp index 9e7195a1..104d722c 100644 --- a/Elixir/Source/Engine/Materials/MaterialSystem.cpp +++ b/Elixir/Source/Engine/Materials/MaterialSystem.cpp @@ -1,31 +1,12 @@ #include "epch.h" #include "MaterialSystem.h" +#include + namespace Elixir::Materials { namespace { - struct SMaterialBatchKey - { - EMaterialPass Pass = EMaterialPass::ParticleSprite; - uint32_t GeometryIndex = UINT32_MAX; - SProgramKey Program; - - bool operator==(const SMaterialBatchKey&) const = default; - }; - - struct SMaterialBatchItem - { - const SRenderItem* Item = nullptr; - uint32_t MaterialIndex = UINT32_MAX; - }; - - struct SMaterialBatch - { - SMaterialBatchKey Key; - std::vector Items; - }; - uint32_t GetInitialFrameCapacity(const SMaterialSystemConfig config) { EE_CORE_ASSERT( @@ -67,11 +48,6 @@ namespace Elixir::Materials m_SubmittedScenes.clear(); m_ProxyCache.PruneExpired(); m_Textures.BeginFrame(m_CurrentFrameNumber); - - auto& slot = m_FrameSlots.GetCurrent(); - slot.Table.reset(); - slot.FrameNumber = m_CurrentFrameNumber; - m_PreparedFrame = {}; } void MaterialSystem::Submit(MaterialRenderScene scene) @@ -83,9 +59,9 @@ namespace Elixir::Materials m_SubmittedScenes.push_back(std::move(scene)); } - SMaterialRenderResult MaterialSystem::RenderFrame() + SRenderResult MaterialSystem::RenderFrame() { - SMaterialRenderResult result{}; + SRenderResult result{}; if (m_SubmittedScenes.empty()) return result; EE_CORE_ASSERT( @@ -122,8 +98,14 @@ namespace Elixir::Materials for (const auto& scene : m_SubmittedScenes) { - PrepareScene(scene, m_CurrentFrameNumber); - const auto sceneResult = RecordScene(cmd, scene, m_CurrentFrameNumber); + const auto prepared = PrepareScene(scene); + const auto sceneResult = m_Renderer->Record({ + .CommandBuffer = cmd, + .Scene = &scene, + .Items = std::span{ prepared.Items }, + .MaterialBuffer = GetActiveFrameBuffer(), + .MaterialCount = prepared.MaterialCount, + }); result.MaterialCount += sceneResult.MaterialCount; result.BatchCount += sceneResult.BatchCount; @@ -138,11 +120,11 @@ namespace Elixir::Materials return result; } - void MaterialSystem::PrepareScene( - const MaterialRenderScene& scene, - const uint64_t submissionSerial + MaterialSystem::SPreparedScene MaterialSystem::PrepareScene( + const MaterialRenderScene& scene ) { + SPreparedScene prepared{}; const auto& frameBuffer = GetActiveFrameBuffer(); const auto table = CreateRef( @@ -156,186 +138,30 @@ namespace Elixir::Materials for (const auto& item : scene.GetItems()) { - const auto& material = item.Material; - if (material) - { - const auto proxy = ResolveMaterialProxy(material); - if (proxy) table->Add(*proxy); - } - } - - if (!table->GetData().empty()) - { - frameBuffer->UpdateData( - table->GetData().data(), - table->GetData().size() * sizeof(SMaterialFrameData) - ); - } - - m_PreparedFrame = { - .Table = table, - .MaterialCount = table->GetCount(), - .SubmissionSerial = submissionSerial, - }; - - auto& slot = m_FrameSlots.GetCurrent(); - slot.Table = table; - slot.FrameNumber = submissionSerial; - } - - SMaterialRenderResult MaterialSystem::RecordScene( - const Ref& cmd, - const MaterialRenderScene& scene, - const uint64_t submissionSerial - ) - { - const auto isPrepared = m_PreparedFrame.Table && - m_PreparedFrame.SubmissionSerial == submissionSerial; - - EE_CORE_ASSERT(isPrepared, "Material rendering requires a prepared frame for the submission.") - - SMaterialRenderResult result{ - .MaterialCount = m_PreparedFrame.MaterialCount, - }; - - if (!cmd || !isPrepared) return result; - - const auto& table = m_PreparedFrame.Table; - - // Batching - - std::vector batches; - - for (const auto& item : scene.GetItems()) - { - if (!item.Material) - { - EE_CORE_ERROR("Material render item has no compiled material proxy.") - continue; - } - - const auto* geometry = scene.FindGeometry(item.GeometryIndex); - EE_CORE_ASSERT(geometry, "Material render item geometry is unavailable.") - if (!geometry) continue; - - const auto material = ResolveMaterialProxy(item.Material); - if (!material) continue; - - const auto program = m_Renderer->GetProgramKey(item.Pass, *material); - EE_CORE_ASSERT(program, "Material render item does not support its requested pass.") - if (!program) continue; + const auto& proxy = ResolveMaterialProxy(item.Material); + if (!proxy) continue; - const auto materialIndex = table->Find(*material); - EE_CORE_ASSERT(materialIndex, "Prepared material frame is missing a render item material.") + const auto materialIndex = table->Add(*proxy); + EE_CORE_ASSERT(materialIndex, "Material frame capacity was exceeded.") if (!materialIndex) continue; - const SMaterialBatchKey key{ - .Pass = item.Pass, - .GeometryIndex = item.GeometryIndex, - .Program = *program, - }; - - auto batch = std::ranges::find_if(batches, [&key](const SMaterialBatch& candidate) - { - return candidate.Key == key; - }); - - if (batch == batches.end()) - { - batches.push_back({ .Key = key }); - batch = std::prev(batches.end()); - } - - batch->Items.push_back({ + prepared.Items.push_back({ .Item = &item, + .Proxy = proxy, .MaterialIndex = *materialIndex, }); } - std::ranges::stable_sort( - batches, - [](const SMaterialBatch& left, const SMaterialBatch& right) - { - if (left.Key.Pass != right.Key.Pass) - { - return Renderer::GetPassOrder(left.Key.Pass) < - Renderer::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 (!table->GetData().empty()) { - if (batch.Items.empty()) continue; - - const auto* geometry = scene.FindGeometry(batch.Key.GeometryIndex); - if (!geometry) continue; - - const auto& first = *batch.Items.front().Item; - const auto prepared = m_Renderer->Prepare({ - .Pass = batch.Key.Pass, - .Material = ResolveMaterialProxy(first.Material).get(), - .Pipeline = geometry->Pipeline, - .ExternalResources = { - .ConstantBuffers = geometry->ConstantBuffers, - .StorageBuffers = geometry->StorageBuffers, - }, - .MaterialBuffer = GetActiveFrameBuffer(), - .InitialPushConstants = std::span{ - first.PushConstants.Data.data(), - first.PushConstants.Size, - }, - }); - if (!prepared) continue; - - ++result.BatchCount; - - // Drawing - - prepared->Pipeline->Bind(cmd); - - for (const auto& binding : geometry->VertexBuffers) - { - cmd->BindBuffer( - binding.Buffer, - std::span{}, - 1, - binding.Binding - ); - } - - for (const auto& batchItem : batch.Items) - { - const auto& item = *batchItem.Item; - const auto constants = item.PushConstants.Resolve(batchItem.MaterialIndex); - - prepared->Shader->SetPushConstant( - cmd, - "pc", - const_cast(constants.data()), - item.PushConstants.Size - ); - - cmd->Draw( - item.Draw.VertexCount, - item.Draw.InstanceCount, - item.Draw.FirstVertex, - item.Draw.FirstInstance - ); - - ++result.DrawCount; - } + frameBuffer->UpdateData( + table->GetData().data(), + table->GetData().size() * sizeof(SMaterialFrameData) + ); } - return result; + prepared.MaterialCount = table->GetCount(); + return prepared; } Ref MaterialSystem::ResolveMaterialProxy( diff --git a/Elixir/Source/Engine/Materials/MaterialSystem.h b/Elixir/Source/Engine/Materials/MaterialSystem.h index 8eb41985..177a71bc 100644 --- a/Elixir/Source/Engine/Materials/MaterialSystem.h +++ b/Elixir/Source/Engine/Materials/MaterialSystem.h @@ -5,7 +5,6 @@ #include #include #include -#include #include #include @@ -24,21 +23,6 @@ namespace Elixir::Materials uint32_t InitialFrameCapacity = 256; }; - /** - * @brief Reports the work recorded by a material render pass. - */ - struct SMaterialRenderResult - { - /** @brief Number of materials prepared for the rendered submission. */ - uint32_t MaterialCount = 0; - - /** @brief Number of material batches rendered. */ - uint32_t BatchCount = 0; - - /** @brief Number of draw commands recorded. */ - uint32_t DrawCount = 0; - }; - /** * @brief Prepares frame material data and records material draw commands. * @@ -79,29 +63,22 @@ namespace Elixir::Materials * @return Counts of prepared materials, batches, and draws. * @pre BeginFrame was called for the current graphics frame. */ - SMaterialRenderResult RenderFrame(); + SRenderResult RenderFrame(); private: - /** - * @brief Prepares and uploads material data for a scene submission. - * @param scene Scene that provides material render items. - * @param submissionSerial Serial that identifies the submission. - */ - void PrepareScene(const MaterialRenderScene& scene, uint64_t submissionSerial); + /** @brief Stores resolved render items for one submitted scene. */ + struct SPreparedScene + { + std::vector Items; + uint32_t MaterialCount = 0; + }; /** - * @brief Records draw commands for the scene materials. - * @param cmd Command buffer that receives the draw commands. - * @param scene Scene that provides render items. - * @param submissionSerial Serial passed to @ref PreparedFrame. - * @return Counts of rendered batches and recorded draw commands. - * @pre @ref PrepareScene was called for @p submissionSerial. + * @brief Resolves material proxies and uploads material data for one scene. + * @param scene Scene that provides material render items. + * @return Render-ready scene items with their proxies and frame-buffer indices. */ - SMaterialRenderResult RecordScene( - const Ref& cmd, - const MaterialRenderScene& scene, - uint64_t submissionSerial - ); + SPreparedScene PrepareScene(const MaterialRenderScene& scene); /** * @brief Resolves an instance into a render-ready material proxy. @@ -117,16 +94,6 @@ namespace Elixir::Materials struct SFrameSlot { Ref Buffer; - Ref Table; - uint64_t FrameNumber = UINT64_MAX; - }; - - /** @brief Stores material data prepared for the current submission. */ - struct SPreparedFrame - { - Ref Table; - uint32_t MaterialCount = 0; - uint64_t SubmissionSerial = 0; }; uint32_t m_MaterialCapacity = 0; @@ -137,7 +104,6 @@ namespace Elixir::Materials Scope m_Renderer; std::vector m_SubmittedScenes; - SPreparedFrame m_PreparedFrame; uint64_t m_CurrentFrameNumber = UINT64_MAX; const GraphicsContext* m_GraphicsContext = nullptr; diff --git a/Elixir/Source/Engine/Materials/Rendering/Renderer.cpp b/Elixir/Source/Engine/Materials/Rendering/Renderer.cpp index 129b637d..a1a56540 100644 --- a/Elixir/Source/Engine/Materials/Rendering/Renderer.cpp +++ b/Elixir/Source/Engine/Materials/Rendering/Renderer.cpp @@ -2,6 +2,7 @@ #include "Renderer.h" #include +#include namespace Elixir::Materials::Rendering { @@ -41,6 +42,134 @@ namespace Elixir::Materials::Rendering }; } + SRenderResult Renderer::Record(const SMaterialSceneRecordRequest& request) + { + SRenderResult result{ + .MaterialCount = request.MaterialCount, + }; + + if (!request.CommandBuffer || !request.Scene || !request.MaterialBuffer) + return result; + + std::vector batches; + + for (const auto& resolved : request.Items) + { + if (!resolved.Item || !resolved.Proxy) continue; + + const auto& item = *resolved.Item; + const auto* geometry = request.Scene->FindGeometry(item.GeometryIndex); + EE_CORE_ASSERT(geometry, "Material render item geometry is unavailable."); + if (!geometry) continue; + + const auto program = GetProgramKey(item.Pass, *resolved.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(&resolved); + } + + 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 = request.Scene->FindGeometry(batch.Key.GeometryIndex); + if (!geometry) continue; + + const auto& first = *batch.Items.front(); + const auto prepared = Prepare({ + .Pass = batch.Key.Pass, + .Material = first.Proxy.get(), + .Pipeline = geometry->Pipeline, + .ExternalResources = { + .ConstantBuffers = geometry->ConstantBuffers, + .StorageBuffers = geometry->StorageBuffers, + }, + .MaterialBuffer = request.MaterialBuffer, + .InitialPushConstants = std::span{ + first.Item->PushConstants.Data.data(), + first.Item->PushConstants.Size + }, + }); + if (!prepared) continue; + + ++result.BatchCount; + prepared->Pipeline->Bind(request.CommandBuffer); + + for (const auto& binding : geometry->VertexBuffers) + { + request.CommandBuffer->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( + request.CommandBuffer, + "pc", + const_cast(constants.data()), + resolved->Item->PushConstants.Size + ); + + request.CommandBuffer->Draw( + resolved->Item->Draw.VertexCount, + resolved->Item->Draw.InstanceCount, + resolved->Item->Draw.FirstVertex, + resolved->Item->Draw.FirstInstance + ); + + ++result.DrawCount; + } + } + + return result; + } + std::optional Renderer::GetProgramKey( const EMaterialPass pass, const MaterialRenderProxy& material diff --git a/Elixir/Source/Engine/Materials/Rendering/Renderer.h b/Elixir/Source/Engine/Materials/Rendering/Renderer.h index 0ddf6064..546e79f4 100644 --- a/Elixir/Source/Engine/Materials/Rendering/Renderer.h +++ b/Elixir/Source/Engine/Materials/Rendering/Renderer.h @@ -9,6 +9,9 @@ namespace Elixir { class ShaderLoader; } namespace Elixir::Materials::Rendering { + class MaterialRenderScene; + struct SRenderItem; + /** * @brief Identifies a material render pass. */ @@ -36,6 +39,57 @@ namespace Elixir::Materials::Rendering 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; + + /** Index of the material in the active frame buffer. */ + uint32_t MaterialIndex = UINT32_MAX; + }; + + /** + * @brief Describes one prepared material scene to record. + */ + struct SMaterialSceneRecordRequest + { + /** Command buffer that receives draw commands. */ + Ref CommandBuffer; + + /** Scene that owns geometry and source draw items. */ + const MaterialRenderScene* Scene = nullptr; + + /** Draw items with their already resolved material proxies. */ + std::span Items; + + /** Storage buffer containing this scene's material table. */ + Ref MaterialBuffer; + + /** Number of unique materials uploaded to MaterialBuffer. */ + uint32_t MaterialCount = 0; + }; + + /** + * @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. */ @@ -161,6 +215,17 @@ namespace Elixir::Materials::Rendering */ std::optional Prepare(const SPassRequest& request); + /** + * @brief Records the prepared scene's material draws. + * + * The renderer batches compatible draws, prepares pass state, and records + * pipeline bindings, push constants, and draw commands. + * + * @param request Render-ready scene data. + * @return Counts of materials, batches, and draw commands recorded. + */ + SRenderResult Record(const SMaterialSceneRecordRequest& request); + /** * @brief Returns the program key for a material pass. * @param pass Material pass. @@ -239,6 +304,21 @@ namespace Elixir::Materials::Rendering } }; + 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; + }; + /** Returns a cached pipeline or creates one for the request. */ Ref GetPipeline( EMaterialPass pass, From 468f220d41c742e4a7143cedc64fe7488b8ebefb Mon Sep 17 00:00:00 2001 From: MrChampz Date: Sun, 6 Sep 2026 13:56:50 -0300 Subject: [PATCH 87/89] Expose prepared material scenes --- Elixir/Source/Engine/Materials/MaterialSystem.cpp | 6 ++---- Elixir/Source/Engine/Materials/MaterialSystem.h | 14 +++++++------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/Elixir/Source/Engine/Materials/MaterialSystem.cpp b/Elixir/Source/Engine/Materials/MaterialSystem.cpp index 104d722c..55824089 100644 --- a/Elixir/Source/Engine/Materials/MaterialSystem.cpp +++ b/Elixir/Source/Engine/Materials/MaterialSystem.cpp @@ -102,7 +102,7 @@ namespace Elixir::Materials const auto sceneResult = m_Renderer->Record({ .CommandBuffer = cmd, .Scene = &scene, - .Items = std::span{ prepared.Items }, + .Items = std::span{ prepared.Items }, .MaterialBuffer = GetActiveFrameBuffer(), .MaterialCount = prepared.MaterialCount, }); @@ -120,9 +120,7 @@ namespace Elixir::Materials return result; } - MaterialSystem::SPreparedScene MaterialSystem::PrepareScene( - const MaterialRenderScene& scene - ) + SPreparedScene MaterialSystem::PrepareScene(const MaterialRenderScene& scene) { SPreparedScene prepared{}; const auto& frameBuffer = GetActiveFrameBuffer(); diff --git a/Elixir/Source/Engine/Materials/MaterialSystem.h b/Elixir/Source/Engine/Materials/MaterialSystem.h index 177a71bc..558ade80 100644 --- a/Elixir/Source/Engine/Materials/MaterialSystem.h +++ b/Elixir/Source/Engine/Materials/MaterialSystem.h @@ -23,6 +23,13 @@ namespace Elixir::Materials uint32_t InitialFrameCapacity = 256; }; + /** @brief Stores resolved render items for one submitted scene. */ + struct SPreparedScene + { + std::vector Items; + uint32_t MaterialCount = 0; + }; + /** * @brief Prepares frame material data and records material draw commands. * @@ -66,13 +73,6 @@ namespace Elixir::Materials SRenderResult RenderFrame(); private: - /** @brief Stores resolved render items for one submitted scene. */ - struct SPreparedScene - { - std::vector Items; - uint32_t MaterialCount = 0; - }; - /** * @brief Resolves material proxies and uploads material data for one scene. * @param scene Scene that provides material render items. From b56eef87735928f1e258139ce9b628329ee290cd Mon Sep 17 00:00:00 2001 From: MrChampz Date: Mon, 7 Sep 2026 00:09:10 -0300 Subject: [PATCH 88/89] Move material frame resources to renderer --- .../Engine/Materials/MaterialSystem.cpp | 136 +-------- .../Source/Engine/Materials/MaterialSystem.h | 40 +-- .../Engine/Materials/Rendering/Renderer.cpp | 258 +++++++++++++----- .../Engine/Materials/Rendering/Renderer.h | 97 ++++--- 4 files changed, 272 insertions(+), 259 deletions(-) diff --git a/Elixir/Source/Engine/Materials/MaterialSystem.cpp b/Elixir/Source/Engine/Materials/MaterialSystem.cpp index 55824089..be86e744 100644 --- a/Elixir/Source/Engine/Materials/MaterialSystem.cpp +++ b/Elixir/Source/Engine/Materials/MaterialSystem.cpp @@ -1,164 +1,64 @@ #include "epch.h" #include "MaterialSystem.h" -#include - namespace Elixir::Materials { - namespace - { - uint32_t GetInitialFrameCapacity(const SMaterialSystemConfig config) - { - EE_CORE_ASSERT( - config.InitialFrameCapacity != 0, - "Material system frame capacity must be greater than zero." - ) - return config.InitialFrameCapacity; - } - } - MaterialSystem::MaterialSystem( const GraphicsContext* context, const ShaderLoader* shaderLoader, const SMaterialSystemConfig config - ) : m_MaterialCapacity(GetInitialFrameCapacity(config)), - m_FrameSlots(*context), - m_Textures(context), - m_ProxyResolver(shaderLoader), + ) : m_ProxyResolver(shaderLoader), m_ProxyCache(m_ProxyResolver), - m_Renderer(CreateScope(context, m_Textures)), - m_GraphicsContext(context) - { - EE_CORE_ASSERT(context, "Material system requires a graphics context.") - - m_FrameSlots.ForEach([&](SFrameSlot& slot) - { - slot.Buffer = DynamicStorageBuffer::Create( - context, - sizeof(SMaterialFrameData) * m_MaterialCapacity - ); - }); - } + m_Renderer(CreateScope(context, config.InitialFrameCapacity)) {} void MaterialSystem::BeginFrame() { - EE_CORE_ASSERT(m_GraphicsContext, "Material system graphics context is unavailable.") - - m_CurrentFrameNumber = m_GraphicsContext->GetFrameNumber(); m_SubmittedScenes.clear(); m_ProxyCache.PruneExpired(); - m_Textures.BeginFrame(m_CurrentFrameNumber); + m_Renderer->BeginFrame(); + m_IsCollectingFrame = true; } void MaterialSystem::Submit(MaterialRenderScene scene) { EE_CORE_ASSERT( - m_CurrentFrameNumber == m_GraphicsContext->GetFrameNumber(), - "Material scenes must be submitted after BeginFrame for the current graphics frame." + m_IsCollectingFrame, + "Material scenes must be submitted after BeginFrame." ) m_SubmittedScenes.push_back(std::move(scene)); } SRenderResult MaterialSystem::RenderFrame() { - SRenderResult result{}; - if (m_SubmittedScenes.empty()) return result; - - EE_CORE_ASSERT( - m_CurrentFrameNumber == m_GraphicsContext->GetFrameNumber(), - "Material rendering requires BeginFrame for the current graphics frame." - ) - - const auto cmd = m_GraphicsContext->GetSecondaryCommandBuffer(); - const auto extent = m_GraphicsContext->GetRenderTarget()->GetExtent(); - - const auto renderingInfo = SRenderingInfo{ - .ColorAttachment = m_GraphicsContext->GetRenderTarget(), - .DepthStencilAttachment = m_GraphicsContext->GetDepthStencilRenderTarget(), - .RenderArea = extent - }; - cmd->Begin(renderingInfo); - cmd->BeginRendering(renderingInfo); + EE_CORE_ASSERT(m_IsCollectingFrame, "Material rendering requires BeginFrame.") - cmd->SetViewports({ - Viewport{ - .Width = (float)extent.Width, - .Height = (float)extent.Height, - .MinDepth = 0.0f, - .MaxDepth = 1.0f, - } - }); - - cmd->SetScissors({ - Rect2D{ - .Offset = { 0, 0 }, - .Extent = extent - } - }); + std::vector scenes; + scenes.reserve(m_SubmittedScenes.size()); for (const auto& scene : m_SubmittedScenes) - { - const auto prepared = PrepareScene(scene); - const auto sceneResult = m_Renderer->Record({ - .CommandBuffer = cmd, - .Scene = &scene, - .Items = std::span{ prepared.Items }, - .MaterialBuffer = GetActiveFrameBuffer(), - .MaterialCount = prepared.MaterialCount, - }); - - result.MaterialCount += sceneResult.MaterialCount; - result.BatchCount += sceneResult.BatchCount; - result.DrawCount += sceneResult.DrawCount; - } - - cmd->EndRendering(); - cmd->End(); + scenes.push_back(PrepareScene(scene)); - m_GraphicsContext->EnqueueSecondaryCommandBuffer(cmd); - - return result; + m_IsCollectingFrame = false; + return m_Renderer->RenderFrame(scenes); } SPreparedScene MaterialSystem::PrepareScene(const MaterialRenderScene& scene) { - SPreparedScene prepared{}; - const auto& frameBuffer = GetActiveFrameBuffer(); - - const auto table = CreateRef( - m_MaterialCapacity, - m_Textures.GetFallbackIndex(), - [this](const Ref& texture) - { - return m_Textures.Resolve(texture); - } - ); + SPreparedScene prepared{ + .Scene = &scene, + }; for (const auto& item : scene.GetItems()) { const auto& proxy = ResolveMaterialProxy(item.Material); if (!proxy) continue; - const auto materialIndex = table->Add(*proxy); - EE_CORE_ASSERT(materialIndex, "Material frame capacity was exceeded.") - if (!materialIndex) continue; - prepared.Items.push_back({ .Item = &item, .Proxy = proxy, - .MaterialIndex = *materialIndex, }); } - if (!table->GetData().empty()) - { - frameBuffer->UpdateData( - table->GetData().data(), - table->GetData().size() * sizeof(SMaterialFrameData) - ); - } - - prepared.MaterialCount = table->GetCount(); return prepared; } @@ -168,10 +68,4 @@ namespace Elixir::Materials { return m_ProxyCache.Resolve(instance); } - - const Ref& MaterialSystem::GetActiveFrameBuffer() const - { - EE_CORE_ASSERT(m_GraphicsContext, "Material system graphics context is unavailable.") - return m_FrameSlots.GetCurrent().Buffer; - } } diff --git a/Elixir/Source/Engine/Materials/MaterialSystem.h b/Elixir/Source/Engine/Materials/MaterialSystem.h index 558ade80..b35770da 100644 --- a/Elixir/Source/Engine/Materials/MaterialSystem.h +++ b/Elixir/Source/Engine/Materials/MaterialSystem.h @@ -1,12 +1,9 @@ #pragma once -#include -#include #include #include #include #include -#include namespace Elixir { class ShaderLoader; } @@ -23,25 +20,15 @@ namespace Elixir::Materials uint32_t InitialFrameCapacity = 256; }; - /** @brief Stores resolved render items for one submitted scene. */ - struct SPreparedScene - { - std::vector Items; - uint32_t MaterialCount = 0; - }; - /** - * @brief Prepares frame material data and records material draw commands. - * - * The system owns shared material buffers, texture bindings, and render-state - * preparation for a graphics context. + * @brief Resolves material instances for rendering. */ class ELIXIR_API MaterialSystem final { public: /** * @brief Creates a material system. - * @param context Graphics context that owns material resources. + * @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. @@ -53,9 +40,7 @@ namespace Elixir::Materials SMaterialSystemConfig config ); - /** - * @brief Starts material collection for the current graphics frame. - */ + /** @brief Starts material collection for the current graphics frame. */ void BeginFrame(); /** @@ -74,9 +59,9 @@ namespace Elixir::Materials private: /** - * @brief Resolves material proxies and uploads material data for one scene. + * @brief Resolves material proxies for one scene. * @param scene Scene that provides material render items. - * @return Render-ready scene items with their proxies and frame-buffer indices. + * @return Scene items with immutable material proxies. */ SPreparedScene PrepareScene(const MaterialRenderScene& scene); @@ -87,25 +72,12 @@ namespace Elixir::Materials */ Ref ResolveMaterialProxy(const Ref& instance); - /** @brief Gets the buffer that stores material data for the current frame slot. */ - const Ref& GetActiveFrameBuffer() const; - - /** @brief Stores material resources that are safe to reuse with one frame slot. */ - struct SFrameSlot - { - Ref Buffer; - }; - - uint32_t m_MaterialCapacity = 0; - FrameSlotState m_FrameSlots; - TextureRegistry m_Textures; MaterialProxyResolver m_ProxyResolver; MaterialProxyCache m_ProxyCache; Scope m_Renderer; std::vector m_SubmittedScenes; - uint64_t m_CurrentFrameNumber = UINT64_MAX; - const GraphicsContext* m_GraphicsContext = nullptr; + bool m_IsCollectingFrame = false; }; } diff --git a/Elixir/Source/Engine/Materials/Rendering/Renderer.cpp b/Elixir/Source/Engine/Materials/Rendering/Renderer.cpp index a1a56540..f4ec6d2d 100644 --- a/Elixir/Source/Engine/Materials/Rendering/Renderer.cpp +++ b/Elixir/Source/Engine/Materials/Rendering/Renderer.cpp @@ -2,67 +2,201 @@ #include "Renderer.h" #include +#include #include namespace Elixir::Materials::Rendering { Renderer::Renderer( const GraphicsContext* context, - const TextureRegistry& textures - ) : m_Textures(textures), - m_Context(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 + ); + }); + } - std::optional Renderer::Prepare(const SPassRequest& request) + void Renderer::BeginFrame() { - if (!request.Material || !request.Pipeline.VertexLayout || !request.MaterialBuffer) + 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 program = GetProgramKey(request.Pass, *request.Material); - if (!program) + const auto& shader = compiled->GetShader(usage); + if (!shader) return std::nullopt; - const auto compiled = request.Material->GetCompiledMaterial(); - const auto& shader = compiled->GetShader(GetUsage(request.Pass)); + return SProgramKey{ .Identity = shader.get() }; + } - if (!BindDescriptorResources(shader, request)) - return std::nullopt; + 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; + } - if (!request.InitialPushConstants.empty()) + EE_CORE_ASSERT(false, "Material pass does not have a material usage.") + return EMaterialUsage::ParticleSprite; + } + + uint32_t Renderer::GetPassOrder(const EMaterialPass pass) + { + switch (pass) { - shader->SetPushConstant( - "pc", - const_cast(static_cast(request.InitialPushConstants.data())), - request.InitialPushConstants.size() - ); + case EMaterialPass::ParticleSprite: return 2; + case EMaterialPass::ParticleRibbon: return 1; + case EMaterialPass::ParticleMesh: return 0; } - return SPreparedPass{ - .Shader = shader, - .Pipeline = GetPipeline(request.Pass, shader, request.Pipeline), + 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::Record(const SMaterialSceneRecordRequest& request) + SRenderResult Renderer::RecordScene( + const Ref& cmd, + const SPreparedRenderScene& scene + ) { SRenderResult result{ - .MaterialCount = request.MaterialCount, + .MaterialCount = scene.MaterialCount, }; - if (!request.CommandBuffer || !request.Scene || !request.MaterialBuffer) - return result; + if (!cmd || !scene.Scene) return result; std::vector batches; - for (const auto& resolved : request.Items) + for (const auto& prepared : scene.Items) { - if (!resolved.Item || !resolved.Proxy) continue; + if (!prepared.Item || !prepared.Proxy) continue; - const auto& item = *resolved.Item; - const auto* geometry = request.Scene->FindGeometry(item.GeometryIndex); + 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, *resolved.Proxy); + const auto program = GetProgramKey(item.Pass, *prepared.Proxy); EE_CORE_ASSERT(program, "Material render item does not support its requested pass.") if (!program) continue; @@ -86,7 +220,7 @@ namespace Elixir::Materials::Rendering batch = std::prev(batches.end()); } - batch->Items.push_back(&resolved); + batch->Items.push_back(&prepared); } std::ranges::stable_sort( @@ -110,11 +244,11 @@ namespace Elixir::Materials::Rendering { if (batch.Items.empty()) continue; - const auto* geometry = request.Scene->FindGeometry(batch.Key.GeometryIndex); + const auto* geometry = scene.Scene->FindGeometry(batch.Key.GeometryIndex); if (!geometry) continue; const auto& first = *batch.Items.front(); - const auto prepared = Prepare({ + const auto prepared = PreparePass({ .Pass = batch.Key.Pass, .Material = first.Proxy.get(), .Pipeline = geometry->Pipeline, @@ -122,7 +256,7 @@ namespace Elixir::Materials::Rendering .ConstantBuffers = geometry->ConstantBuffers, .StorageBuffers = geometry->StorageBuffers, }, - .MaterialBuffer = request.MaterialBuffer, + .MaterialBuffer = GetActiveMaterialBuffer(), .InitialPushConstants = std::span{ first.Item->PushConstants.Data.data(), first.Item->PushConstants.Size @@ -131,11 +265,11 @@ namespace Elixir::Materials::Rendering if (!prepared) continue; ++result.BatchCount; - prepared->Pipeline->Bind(request.CommandBuffer); + prepared->Pipeline->Bind(cmd); for (const auto& binding : geometry->VertexBuffers) { - request.CommandBuffer->BindBuffer( + cmd->BindBuffer( binding.Buffer, std::span{}, 1, @@ -150,13 +284,13 @@ namespace Elixir::Materials::Rendering ); prepared->Shader->SetPushConstant( - request.CommandBuffer, + cmd, "pc", const_cast(constants.data()), resolved->Item->PushConstants.Size ); - request.CommandBuffer->Draw( + cmd->Draw( resolved->Item->Draw.VertexCount, resolved->Item->Draw.InstanceCount, resolved->Item->Draw.FirstVertex, @@ -170,46 +304,40 @@ namespace Elixir::Materials::Rendering return result; } - std::optional Renderer::GetProgramKey( - const EMaterialPass pass, - const MaterialRenderProxy& material - ) + std::optional Renderer::PreparePass(const SPassRequest& request) { - const auto usage = GetUsage(pass); - const auto compiled = material.GetCompiledMaterial(); - if (!compiled || !compiled->SupportsUsage(usage)) + if (!request.Material || !request.Pipeline.VertexLayout || !request.MaterialBuffer) return std::nullopt; - const auto& shader = compiled->GetShader(usage); - if (!shader) + const auto program = GetProgramKey(request.Pass, *request.Material); + if (!program) return std::nullopt; - return SProgramKey{ .Identity = shader.get() }; - } + const auto compiled = request.Material->GetCompiledMaterial(); + const auto& shader = compiled->GetShader(GetUsage(request.Pass)); - EMaterialUsage Renderer::GetUsage(const EMaterialPass pass) - { - switch (pass) + if (!BindDescriptorResources(shader, request)) + return std::nullopt; + + if (!request.InitialPushConstants.empty()) { - case EMaterialPass::ParticleSprite: return EMaterialUsage::ParticleSprite; - case EMaterialPass::ParticleRibbon: return EMaterialUsage::ParticleRibbon; - case EMaterialPass::ParticleMesh: return EMaterialUsage::ParticleMesh; + shader->SetPushConstant( + "pc", + const_cast(static_cast(request.InitialPushConstants.data())), + request.InitialPushConstants.size() + ); } - EE_CORE_ASSERT(false, "Material pass does not have a material usage.") - return EMaterialUsage::ParticleSprite; + return SPreparedPass{ + .Shader = shader, + .Pipeline = GetPipeline(request.Pass, shader, request.Pipeline), + }; } - uint32_t Renderer::GetPassOrder(const EMaterialPass pass) + const Ref& Renderer::GetActiveMaterialBuffer() const { - switch (pass) - { - case EMaterialPass::ParticleSprite: return 2; - case EMaterialPass::ParticleRibbon: return 1; - case EMaterialPass::ParticleMesh: return 0; - } - - return UINT32_MAX; + EE_CORE_ASSERT(m_Context, "Material renderer graphics context is unavailable.") + return m_FrameSlots.GetCurrent().MaterialBuffer; } Ref Renderer::GetPipeline( diff --git a/Elixir/Source/Engine/Materials/Rendering/Renderer.h b/Elixir/Source/Engine/Materials/Rendering/Renderer.h index 546e79f4..490c9d94 100644 --- a/Elixir/Source/Engine/Materials/Rendering/Renderer.h +++ b/Elixir/Source/Engine/Materials/Rendering/Renderer.h @@ -1,11 +1,14 @@ #pragma once #include +#include #include +#include #include #include namespace Elixir { class ShaderLoader; } +namespace Elixir::Materials { struct SMaterialSystemConfig; } namespace Elixir::Materials::Rendering { @@ -49,30 +52,16 @@ namespace Elixir::Materials::Rendering /** Immutable material data resolved by MaterialSystem. */ Ref Proxy; - - /** Index of the material in the active frame buffer. */ - uint32_t MaterialIndex = UINT32_MAX; }; - /** - * @brief Describes one prepared material scene to record. - */ - struct SMaterialSceneRecordRequest + /** @brief Stores material proxies resolved for one render scene. */ + struct SPreparedScene { - /** Command buffer that receives draw commands. */ - Ref CommandBuffer; - /** Scene that owns geometry and source draw items. */ const MaterialRenderScene* Scene = nullptr; /** Draw items with their already resolved material proxies. */ - std::span Items; - - /** Storage buffer containing this scene's material table. */ - Ref MaterialBuffer; - - /** Number of unique materials uploaded to MaterialBuffer. */ - uint32_t MaterialCount = 0; + std::vector Items; }; /** @@ -187,10 +176,10 @@ namespace Elixir::Materials::Rendering }; /** - * @brief Prepares material render passes. + * @brief Owns frame material resources and records material draw commands. * - * The renderer caches descriptor bindings and graphics pipelines for reuse - * across render items. + * The renderer owns texture bindings, per-frame material buffers, descriptor + * bindings, graphics pipelines, and secondary command buffers. */ class ELIXIR_API Renderer final { @@ -198,33 +187,23 @@ namespace Elixir::Materials::Rendering /** * @brief Creates a material renderer. * @param context Graphics context used to create pipelines. - * @param textures Registry that provides material textures and a sampler. + * @param materialCapacity Maximum unique materials supported by one scene. * @pre All arguments are valid for the renderer lifetime. */ Renderer( const GraphicsContext* context, - const TextureRegistry& textures + uint32_t materialCapacity ); - /** - * @brief Prepares a shader and graphics pipeline for a material pass. - * @param request Pass requirements and external resources. - * @return Prepared pass, or no value if the request is invalid or unsupported. - * @pre `request.Material` is not null. - * @pre `request.Pipeline.VertexLayout` is not null. - */ - std::optional Prepare(const SPassRequest& request); + /** @brief Selects resources for the current graphics frame. */ + void BeginFrame(); /** - * @brief Records the prepared scene's material draws. - * - * The renderer batches compatible draws, prepares pass state, and records - * pipeline bindings, push constants, and draw commands. - * - * @param request Render-ready scene data. + * @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 Record(const SMaterialSceneRecordRequest& request); + SRenderResult RenderFrame(std::span scenes); /** * @brief Returns the program key for a material pass. @@ -304,6 +283,28 @@ namespace Elixir::Materials::Rendering } }; + /** 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; @@ -316,9 +317,24 @@ namespace Elixir::Materials::Rendering struct SBatch { SBatchKey Key; - std::vector Items; + 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, @@ -329,10 +345,13 @@ namespace Elixir::Materials::Rendering /** Binds and validates the descriptor resources for a shader. */ bool BindDescriptorResources(const Ref& shader, const SPassRequest& request); - const TextureRegistry& m_Textures; + 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; }; } From 55d968d2ed194081381666574fed530eb01b0cfe Mon Sep 17 00:00:00 2001 From: MrChampz Date: Mon, 7 Sep 2026 00:19:18 -0300 Subject: [PATCH 89/89] Test prepared material scenes --- .../Materials/Rendering/RendererTest.cpp | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/Elixir/Tests/Engine/Materials/Rendering/RendererTest.cpp b/Elixir/Tests/Engine/Materials/Rendering/RendererTest.cpp index 342f4f3c..0f122821 100644 --- a/Elixir/Tests/Engine/Materials/Rendering/RendererTest.cpp +++ b/Elixir/Tests/Engine/Materials/Rendering/RendererTest.cpp @@ -1,11 +1,37 @@ #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( @@ -21,3 +47,41 @@ TEST(RendererTest, MapsParticlePassesToMaterialUsages) 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); +}