From 4fea967bfcf330f88990f261aa068b977ea56c5f Mon Sep 17 00:00:00 2001 From: hydrogendeuteride Date: Wed, 17 Dec 2025 22:17:32 +0900 Subject: [PATCH] ADD: Particle system --- CMakeLists.txt | 7 +- shaders/particles.frag | 26 ++ shaders/particles.vert | 66 ++++ shaders/particles_update.comp | 158 ++++++++++ src/CMakeLists.txt | 2 + src/core/engine.cpp | 16 +- src/core/engine_ui.cpp | 139 ++++++++ src/render/graph/graph.cpp | 6 +- src/render/passes/particles.cpp | 539 ++++++++++++++++++++++++++++++++ src/render/passes/particles.h | 105 +++++++ src/render/renderpass.cpp | 6 + 11 files changed, 1060 insertions(+), 10 deletions(-) create mode 100644 shaders/particles.frag create mode 100644 shaders/particles.vert create mode 100644 shaders/particles_update.comp create mode 100644 src/render/passes/particles.cpp create mode 100644 src/render/passes/particles.h diff --git a/CMakeLists.txt b/CMakeLists.txt index add535f..b4a8304 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,6 +44,7 @@ foreach(GLSL ${GLSL_SOURCE_FILES}) list(APPEND SPIRV_BINARY_FILES ${SPIRV}) endforeach(GLSL) -add_custom_target( - DEPENDS ${SPIRV_BINARY_FILES} - ) +add_custom_target(shaders_spirv DEPENDS ${SPIRV_BINARY_FILES}) + +# Ensure shaders are built alongside the executable. +add_dependencies(vulkan_engine shaders_spirv) diff --git a/shaders/particles.frag b/shaders/particles.frag new file mode 100644 index 0000000..4fb3ca0 --- /dev/null +++ b/shaders/particles.frag @@ -0,0 +1,26 @@ +#version 450 + +layout(location = 0) in vec4 v_color; +layout(location = 1) in vec2 v_uv; + +layout(location = 0) out vec4 outColor; + +void main() +{ + // Soft circular sprite. + vec2 p = v_uv * 2.0 - 1.0; + float r = length(p); + float mask = smoothstep(1.0, 0.0, r); + + vec4 c = v_color; + c.rgb *= mask; + c.a *= mask; + + if (c.a <= 0.001) + { + discard; + } + + outColor = c; +} + diff --git a/shaders/particles.vert b/shaders/particles.vert new file mode 100644 index 0000000..49d2b6c --- /dev/null +++ b/shaders/particles.vert @@ -0,0 +1,66 @@ +#version 450 + +layout(set = 0, binding = 0) uniform SceneData +{ + mat4 view; + mat4 proj; + mat4 viewproj; +} sceneData; + +struct Particle +{ + vec4 pos_age; + vec4 vel_life; + vec4 color; + vec4 misc; +}; + +layout(std430, set = 1, binding = 0) readonly buffer ParticlePool +{ + Particle particles[]; +} pool; + +layout(location = 0) out vec4 v_color; +layout(location = 1) out vec2 v_uv; + +vec2 quad_corner(uint vidx) +{ + // Two triangles (6 verts) in a unit quad centered at origin. + const vec2 corners[6] = vec2[6]( + vec2(-0.5, -0.5), + vec2( 0.5, -0.5), + vec2( 0.5, 0.5), + vec2(-0.5, -0.5), + vec2( 0.5, 0.5), + vec2(-0.5, 0.5) + ); + return corners[vidx % 6u]; +} + +void main() +{ + uint particle_index = gl_InstanceIndex; + Particle p = pool.particles[particle_index]; + + float life = max(p.vel_life.w, 1e-6); + float remaining = clamp(p.pos_age.w, 0.0, life); + float t = remaining / life; // remaining fraction + + float fade_out = smoothstep(0.0, 0.15, t); + float fade_in = smoothstep(0.0, 0.05, 1.0 - t); + float fade = fade_in * fade_out; + + vec2 corner = quad_corner(uint(gl_VertexIndex)); + v_uv = corner + vec2(0.5); + + // Camera right/up in world-local space from view matrix rows. + vec3 cam_right = vec3(sceneData.view[0][0], sceneData.view[1][0], sceneData.view[2][0]); + vec3 cam_up = vec3(sceneData.view[0][1], sceneData.view[1][1], sceneData.view[2][1]); + + float size = max(p.misc.x, 0.0); + vec3 pos = p.pos_age.xyz + (cam_right * corner.x + cam_up * corner.y) * size; + + v_color = vec4(p.color.rgb * fade, p.color.a * fade); + gl_Position = sceneData.viewproj * vec4(pos, 1.0); +} + diff --git a/shaders/particles_update.comp b/shaders/particles_update.comp new file mode 100644 index 0000000..5b6e0d2 --- /dev/null +++ b/shaders/particles_update.comp @@ -0,0 +1,158 @@ +#version 450 + +// v1 particle update: one SSBO, per-system dispatch. +// Particles store "remaining life" in pos_age.w and total lifetime in vel_life.w. + +layout(local_size_x = 256, local_size_y = 1, local_size_z = 1) in; + +struct Particle +{ + vec4 pos_age; // xyz = local position, w = remaining life (seconds) + vec4 vel_life; // xyz = local velocity, w = lifetime (seconds) + vec4 color; // rgba + vec4 misc; // x=size, y=seed, z=unused, w=flags +}; + +layout(std430, set = 0, binding = 0) buffer ParticlePool +{ + Particle particles[]; +} pool; + +layout(push_constant) uniform Push +{ + uvec4 header; // x=base, y=count, z=reset + vec4 sim; // x=dt, y=time, z=drag, w=gravity (m/s^2, pulls down -Y) + vec4 origin_delta; // xyz origin delta (local) + vec4 emitter_pos_radius;// xyz emitter pos (local), w=spawn radius + vec4 emitter_dir_cone; // xyz emitter dir (local), w=cone angle radians (<=0 => sphere) + vec4 ranges; // x=minSpeed, y=maxSpeed, z=minLife, w=maxLife + vec4 size_range; // x=minSize, y=maxSize + vec4 color; // rgba +} pc; + +uint hash_u32(uint x) +{ + x ^= x >> 16; + x *= 0x7feb352du; + x ^= x >> 15; + x *= 0x846ca68bu; + x ^= x >> 16; + return x; +} + +float rand01(inout uint state) +{ + state = hash_u32(state); + // 0..1 (exclusive 1) + return float(state) * (1.0 / 4294967296.0); +} + +vec3 random_unit_vector(inout uint state) +{ + float z = rand01(state) * 2.0 - 1.0; + float a = rand01(state) * 6.28318530718; + float r = sqrt(max(0.0, 1.0 - z * z)); + return vec3(r * cos(a), z, r * sin(a)); +} + +vec3 random_in_sphere(inout uint state) +{ + vec3 dir = random_unit_vector(state); + float u = rand01(state); + float radius = pow(u, 1.0 / 3.0); + return dir * radius; +} + +vec3 random_in_cone(inout uint state, vec3 axis, float cone_angle) +{ + axis = normalize(axis); + + float cos_max = clamp(cos(cone_angle), -1.0, 1.0); + float cos_t = mix(cos_max, 1.0, rand01(state)); + float sin_t = sqrt(max(0.0, 1.0 - cos_t * cos_t)); + + float phi = rand01(state) * 6.28318530718; + vec3 local_dir = vec3(cos(phi) * sin_t, sin(phi) * sin_t, cos_t); + + vec3 up = (abs(axis.y) < 0.999) ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0); + vec3 u = normalize(cross(up, axis)); + vec3 v = cross(axis, u); + + return u * local_dir.x + v * local_dir.y + axis * local_dir.z; +} + +void respawn(uint idx) +{ + uint seed = idx ^ (hash_u32(floatBitsToUint(pc.sim.y)) * 1664525u) ^ 1013904223u; + + float life = mix(pc.ranges.z, pc.ranges.w, rand01(seed)); + life = max(life, 0.01); + + float speed = mix(pc.ranges.x, pc.ranges.y, rand01(seed)); + speed = max(speed, 0.0); + + float size = mix(pc.size_range.x, pc.size_range.y, rand01(seed)); + size = max(size, 0.001); + + vec3 spawn_pos = pc.emitter_pos_radius.xyz; + float radius = max(pc.emitter_pos_radius.w, 0.0); + if (radius > 0.0) + { + spawn_pos += random_in_sphere(seed) * radius; + } + + vec3 dir; + float cone = pc.emitter_dir_cone.w; + if (cone > 0.0) + { + dir = random_in_cone(seed, pc.emitter_dir_cone.xyz, cone); + } + else + { + dir = random_unit_vector(seed); + } + + Particle p; + p.pos_age = vec4(spawn_pos, life); + p.vel_life = vec4(dir * speed, life); + p.color = pc.color; + p.misc = vec4(size, rand01(seed), 0.0, 0.0); + pool.particles[idx] = p; +} + +void main() +{ + uint i = gl_GlobalInvocationID.x; + if (i >= pc.header.y) return; + + uint idx = pc.header.x + i; + + Particle p = pool.particles[idx]; + + // Keep particles stable under floating-origin recenters. + p.pos_age.xyz -= pc.origin_delta.xyz; + + bool reset = (pc.header.z != 0u); + bool dead = (p.pos_age.w <= 0.0) || (p.vel_life.w <= 0.0); + + if (reset || dead) + { + respawn(idx); + return; + } + + float dt = pc.sim.x; + float drag = max(pc.sim.z, 0.0); + float gravity = pc.sim.w; + + vec3 vel = p.vel_life.xyz; + vel += vec3(0.0, -gravity, 0.0) * dt; + vel *= exp(-drag * dt); + + p.pos_age.xyz += vel * dt; + p.vel_life.xyz = vel; + p.pos_age.w -= dt; + + pool.particles[idx] = p; +} + diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b8397ab..6f1d1bd 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -83,6 +83,8 @@ add_executable (vulkan_engine render/passes/fxaa.cpp render/passes/ssr.h render/passes/ssr.cpp + render/passes/particles.h + render/passes/particles.cpp render/passes/transparent.h render/passes/transparent.cpp render/passes/imgui_pass.h diff --git a/src/core/engine.cpp b/src/core/engine.cpp index fc7bddc..1071220 100644 --- a/src/core/engine.cpp +++ b/src/core/engine.cpp @@ -45,6 +45,7 @@ #include "render/passes/geometry.h" #include "render/passes/imgui_pass.h" #include "render/passes/lighting.h" +#include "render/passes/particles.h" #include "render/passes/transparent.h" #include "render/passes/fxaa.h" #include "render/passes/tonemap.h" @@ -1187,10 +1188,16 @@ void VulkanEngine::draw() hSSR); } + // Downstream passes draw on top of either the SSR output or the raw HDR draw. + RGImageHandle hdrTarget = (ssrEnabled && hSSR.valid()) ? hSSR : hDraw; + + if (auto *particles = _renderPassManager->getPass()) + { + particles->register_graph(_renderGraph.get(), hdrTarget, hDepth); + } + if (auto *transparent = _renderPassManager->getPass()) { - // Transparent objects draw on top of either the SSR output or the raw HDR draw. - RGImageHandle hdrTarget = (ssrEnabled && hSSR.valid()) ? hSSR : hDraw; transparent->register_graph(_renderGraph.get(), hdrTarget, hDepth); } imguiPass = _renderPassManager->getImGuiPass(); @@ -1198,8 +1205,7 @@ void VulkanEngine::draw() // Optional Tonemap pass: sample HDR draw -> LDR intermediate if (auto *tonemap = _renderPassManager->getPass()) { - RGImageHandle hdrInput = (ssrEnabled && hSSR.valid()) ? hSSR : hDraw; - finalColor = tonemap->register_graph(_renderGraph.get(), hdrInput); + finalColor = tonemap->register_graph(_renderGraph.get(), hdrTarget); // Optional FXAA pass: runs on LDR tonemapped output. if (auto *fxaa = _renderPassManager->getPass()) @@ -1210,7 +1216,7 @@ void VulkanEngine::draw() else { // If tonemapping is disabled, present whichever HDR buffer we ended up with. - finalColor = (ssrEnabled && hSSR.valid()) ? hSSR : hDraw; + finalColor = hdrTarget; } } diff --git a/src/core/engine_ui.cpp b/src/core/engine_ui.cpp index 1021b6e..69997fb 100644 --- a/src/core/engine_ui.cpp +++ b/src/core/engine_ui.cpp @@ -18,6 +18,7 @@ #include "render/passes/tonemap.h" #include "render/passes/fxaa.h" #include "render/passes/background.h" +#include "render/passes/particles.h" #include #include #include "render/graph/graph.h" @@ -28,6 +29,7 @@ #include "context.h" #include #include +#include #include #include "mesh_bvh.h" @@ -258,6 +260,138 @@ namespace } } + static void ui_particles(VulkanEngine *eng) + { + if (!eng || !eng->_renderPassManager) return; + auto *pass = eng->_renderPassManager->getPass(); + if (!pass) + { + ImGui::TextUnformatted("Particle pass not available"); + return; + } + + const uint32_t freeCount = pass->free_particles(); + const uint32_t allocCount = pass->allocated_particles(); + ImGui::Text("Pool: %u allocated / %u free (max %u)", allocCount, freeCount, ParticlePass::k_max_particles); + + ImGui::Separator(); + + static int newCount = 32768; + newCount = std::max(newCount, 1); + ImGui::InputInt("New System Particles", &newCount); + ImGui::SameLine(); + if (ImGui::Button("Create")) + { + const uint32_t want = static_cast(std::max(1, newCount)); + pass->create_system(want); + } + ImGui::SameLine(); + if (ImGui::Button("Create 32k")) + { + pass->create_system(32768); + } + ImGui::SameLine(); + if (ImGui::Button("Create 128k")) + { + pass->create_system(ParticlePass::k_max_particles); + } + + ImGui::Separator(); + + auto &systems = pass->systems(); + if (systems.empty()) + { + ImGui::TextUnformatted("No particle systems. Create one above."); + return; + } + + static int selected = 0; + selected = std::clamp(selected, 0, (int)systems.size() - 1); + + if (ImGui::BeginListBox("Systems")) + { + for (int i = 0; i < (int)systems.size(); ++i) + { + const auto &s = systems[i]; + char label[128]; + std::snprintf(label, sizeof(label), "#%u base=%u count=%u %s", + s.id, s.base, s.count, s.enabled ? "on" : "off"); + const bool isSelected = (selected == i); + if (ImGui::Selectable(label, isSelected)) + { + selected = i; + } + if (isSelected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndListBox(); + } + + selected = std::clamp(selected, 0, (int)systems.size() - 1); + auto &s = systems[(size_t)selected]; + + ImGui::Separator(); + + ImGui::Text("Selected: id=%u base=%u count=%u", s.id, s.base, s.count); + ImGui::Checkbox("Enabled", &s.enabled); + ImGui::SameLine(); + if (ImGui::Button("Reset (Respawn)")) + { + s.reset = true; + } + ImGui::SameLine(); + if (ImGui::Button("Destroy")) + { + const uint32_t id = s.id; + pass->destroy_system(id); + selected = 0; + return; + } + + const char *blendItems[] = {"Additive", "Alpha (unsorted)"}; + int blend = (s.blend == ParticlePass::BlendMode::Alpha) ? 1 : 0; + if (ImGui::Combo("Blend", &blend, blendItems, 2)) + { + s.blend = (blend == 1) ? ParticlePass::BlendMode::Alpha : ParticlePass::BlendMode::Additive; + } + + ImGui::Separator(); + + static int pendingResizeCount = 0; + if (!ImGui::IsAnyItemActive()) + { + pendingResizeCount = (int)s.count; + } + ImGui::InputInt("Resize Count", &pendingResizeCount); + ImGui::SameLine(); + if (ImGui::Button("Apply Resize")) + { + const uint32_t want = static_cast(std::max(0, pendingResizeCount)); + pass->resize_system(s.id, want); + } + + ImGui::Separator(); + ImGui::TextUnformatted("Emitter"); + ImGui::InputFloat3("Position (local)", reinterpret_cast(&s.params.emitter_pos_local)); + ImGui::SliderFloat("Spawn Radius", &s.params.spawn_radius, 0.0f, 10.0f, "%.3f"); + ImGui::InputFloat3("Direction (local)", reinterpret_cast(&s.params.emitter_dir_local)); + ImGui::SliderFloat("Cone Angle (deg)", &s.params.cone_angle_degrees, 0.0f, 89.0f, "%.1f"); + + ImGui::Separator(); + ImGui::TextUnformatted("Motion"); + ImGui::InputFloat("Min Speed", &s.params.min_speed); + ImGui::InputFloat("Max Speed", &s.params.max_speed); + ImGui::InputFloat("Min Life (s)", &s.params.min_life); + ImGui::InputFloat("Max Life (s)", &s.params.max_life); + ImGui::InputFloat("Min Size", &s.params.min_size); + ImGui::InputFloat("Max Size", &s.params.max_size); + ImGui::SliderFloat("Drag", &s.params.drag, 0.0f, 10.0f, "%.3f"); + ImGui::SliderFloat("Gravity (m/s^2)", &s.params.gravity, 0.0f, 30.0f, "%.2f"); + + ImGui::Separator(); + ImGui::TextUnformatted("Color"); + ImGui::ColorEdit4("Tint", reinterpret_cast(&s.params.color), ImGuiColorEditFlags_Float); + } + // IBL test grid spawner (spheres varying metallic/roughness) static void spawn_ibl_test(VulkanEngine *eng) { @@ -1533,6 +1667,11 @@ void vk_engine_draw_debug_ui(VulkanEngine *eng) ui_background(eng); ImGui::EndTabItem(); } + if (ImGui::BeginTabItem("Particles")) + { + ui_particles(eng); + ImGui::EndTabItem(); + } if (ImGui::BeginTabItem("Shadows")) { ui_shadows(eng); diff --git a/src/render/graph/graph.cpp b/src/render/graph/graph.cpp index 449cdf8..a169dfd 100644 --- a/src/render/graph/graph.cpp +++ b/src/render/graph/graph.cpp @@ -374,11 +374,13 @@ bool RenderGraph::compile() info.access = VK_ACCESS_2_UNIFORM_READ_BIT; break; case RGBufferUsage::StorageRead: - info.stage = VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT | VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT; + // Storage buffers can be read from compute and any graphics stage (including vertex). + info.stage = VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT | VK_PIPELINE_STAGE_2_ALL_GRAPHICS_BIT; info.access = VK_ACCESS_2_SHADER_STORAGE_READ_BIT; break; case RGBufferUsage::StorageReadWrite: - info.stage = VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT | VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT; + // Storage buffers can be read/write from compute and read in graphics stages. + info.stage = VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT | VK_PIPELINE_STAGE_2_ALL_GRAPHICS_BIT; info.access = VK_ACCESS_2_SHADER_STORAGE_READ_BIT | VK_ACCESS_2_SHADER_STORAGE_WRITE_BIT; break; case RGBufferUsage::IndirectArgs: diff --git a/src/render/passes/particles.cpp b/src/render/passes/particles.cpp new file mode 100644 index 0000000..c71d15c --- /dev/null +++ b/src/render/passes/particles.cpp @@ -0,0 +1,539 @@ +#include "particles.h" + +#include "compute/vk_compute.h" +#include "core/assets/manager.h" +#include "core/context.h" +#include "core/descriptor/descriptors.h" +#include "core/descriptor/manager.h" +#include "core/device/device.h" +#include "core/device/resource.h" +#include "core/device/swapchain.h" +#include "core/frame/resources.h" +#include "core/pipeline/manager.h" +#include "render/graph/graph.h" +#include "render/pipelines.h" +#include "scene/vk_scene.h" + +#include +#include +#include + +namespace +{ + struct ParticleGPU + { + glm::vec4 pos_age; + glm::vec4 vel_life; + glm::vec4 color; + glm::vec4 misc; + }; + static_assert(sizeof(ParticleGPU) == 64); + + constexpr uint32_t k_local_size_x = 256; + + struct ParticleUpdatePushConstants + { + glm::uvec4 header; // x=base, y=count, z=reset, w=unused + glm::vec4 sim; // x=dt, y=time, z=drag, w=gravity + glm::vec4 origin_delta; // xyz origin delta local + glm::vec4 emitter_pos_radius; // xyz emitter pos local, w=spawn radius + glm::vec4 emitter_dir_cone; // xyz emitter dir local, w=cone angle (radians) + glm::vec4 ranges; // x=minSpeed, y=maxSpeed, z=minLife, w=maxLife + glm::vec4 size_range; // x=minSize, y=maxSize, z/w unused + glm::vec4 color; // rgba + }; + static_assert(sizeof(ParticleUpdatePushConstants) == 128); + + static glm::vec3 safe_normalize(const glm::vec3 &v, const glm::vec3 &fallback) + { + const float len2 = glm::dot(v, v); + if (len2 <= 1e-10f || !std::isfinite(len2)) + { + return fallback; + } + return v * (1.0f / std::sqrt(len2)); + } + + static float clamp_nonnegative(float v) + { + if (!std::isfinite(v)) return 0.0f; + return std::max(0.0f, v); + } +} + +void ParticlePass::init(EngineContext *context) +{ + _context = context; + if (!_context || !_context->getDevice() || !_context->getResources() || !_context->getAssets() || + !_context->pipelines || !_context->getDescriptorLayouts()) + { + return; + } + + _free_ranges.clear(); + _free_ranges.push_back(FreeRange{0u, k_max_particles}); + + _particle_pool_size = VkDeviceSize(sizeof(ParticleGPU)) * VkDeviceSize(k_max_particles); + _particle_pool = _context->getResources()->create_buffer( + _particle_pool_size, + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT, + VMA_MEMORY_USAGE_GPU_ONLY); + + // Zero the pool once so all particles start "dead" and get respawned deterministically by the compute update. + if (_particle_pool.buffer != VK_NULL_HANDLE) + { + VkBuffer buf = _particle_pool.buffer; + VkDeviceSize size = _particle_pool_size; + _context->getResources()->immediate_submit([buf, size](VkCommandBuffer cmd) { + vkCmdFillBuffer(cmd, buf, 0, size, 0u); + }); + } + + VkDevice device = _context->getDevice()->device(); + + // Set=1 layout for graphics: particle pool SSBO. + { + DescriptorLayoutBuilder builder; + builder.add_binding(0, VK_DESCRIPTOR_TYPE_STORAGE_BUFFER); + _particle_set_layout = builder.build(device, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT); + } + + // Compute update pipeline + instance. + { + ComputePipelineCreateInfo ci{}; + ci.shaderPath = _context->getAssets()->shaderPath("particles_update.comp.spv"); + ci.descriptorTypes = {VK_DESCRIPTOR_TYPE_STORAGE_BUFFER}; + ci.pushConstantSize = sizeof(ParticleUpdatePushConstants); + ci.pushConstantStages = VK_SHADER_STAGE_COMPUTE_BIT; + _context->pipelines->createComputePipeline("particles.update", ci); + + _context->pipelines->createComputeInstance("particles.update", "particles.update"); + _context->pipelines->setComputeInstanceBuffer("particles.update", 0, _particle_pool.buffer, _particle_pool_size, + VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 0); + } + + // Graphics pipelines for render (additive + optional alpha). + { + const std::string vert = _context->getAssets()->shaderPath("particles.vert.spv"); + const std::string frag = _context->getAssets()->shaderPath("particles.frag.spv"); + + GraphicsPipelineCreateInfo base{}; + base.vertexShaderPath = vert; + base.fragmentShaderPath = frag; + base.setLayouts = { + _context->getDescriptorLayouts()->gpuSceneDataLayout(), // set = 0 + _particle_set_layout, // set = 1 + }; + + base.configure = [this](PipelineBuilder &b) { + b.set_input_topology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST); + b.set_polygon_mode(VK_POLYGON_MODE_FILL); + b.set_cull_mode(VK_CULL_MODE_NONE, VK_FRONT_FACE_CLOCKWISE); + b.set_multisampling_none(); + b.enable_depthtest(false, VK_COMPARE_OP_GREATER_OR_EQUAL); + if (_context && _context->getSwapchain()) + { + b.set_color_attachment_format(_context->getSwapchain()->drawImage().imageFormat); + b.set_depth_format(_context->getSwapchain()->depthImage().imageFormat); + } + }; + + GraphicsPipelineCreateInfo additive = base; + additive.configure = [baseCfg = base.configure](PipelineBuilder &b) { + baseCfg(b); + b.enable_blending_additive(); + }; + _context->pipelines->createGraphicsPipeline("particles.additive", additive); + + GraphicsPipelineCreateInfo alpha = base; + alpha.configure = [baseCfg = base.configure](PipelineBuilder &b) { + baseCfg(b); + b.enable_blending_alphablend(); + }; + _context->pipelines->createGraphicsPipeline("particles.alpha", alpha); + } +} + +void ParticlePass::cleanup() +{ + if (_context && _context->getDevice()) + { + if (_particle_set_layout != VK_NULL_HANDLE) + { + vkDestroyDescriptorSetLayout(_context->getDevice()->device(), _particle_set_layout, nullptr); + _particle_set_layout = VK_NULL_HANDLE; + } + } + + if (_context && _context->getResources()) + { + if (_particle_pool.buffer != VK_NULL_HANDLE) + { + _context->getResources()->destroy_buffer(_particle_pool); + _particle_pool = {}; + } + } + + _systems.clear(); + _free_ranges.clear(); + _context = nullptr; +} + +void ParticlePass::execute(VkCommandBuffer) +{ + // Executed via RenderGraph. +} + +uint32_t ParticlePass::free_particles() const +{ + uint64_t sum = 0; + for (const auto &r : _free_ranges) sum += r.count; + if (sum > k_max_particles) sum = k_max_particles; + return static_cast(sum); +} + +uint32_t ParticlePass::allocated_particles() const +{ + return k_max_particles - free_particles(); +} + +ParticlePass::System *ParticlePass::find_system(uint32_t id) +{ + for (auto &s : _systems) + { + if (s.id == id) return &s; + } + return nullptr; +} + +bool ParticlePass::allocate_range(uint32_t count, uint32_t &out_base) +{ + if (count == 0) return false; + for (auto it = _free_ranges.begin(); it != _free_ranges.end(); ++it) + { + if (it->count < count) continue; + + out_base = it->base; + it->base += count; + it->count -= count; + if (it->count == 0) _free_ranges.erase(it); + return true; + } + return false; +} + +void ParticlePass::merge_free_ranges() +{ + if (_free_ranges.size() < 2) return; + + std::sort(_free_ranges.begin(), _free_ranges.end(), [](const FreeRange &a, const FreeRange &b) { + return a.base < b.base; + }); + + std::vector merged; + merged.reserve(_free_ranges.size()); + + FreeRange cur = _free_ranges.front(); + for (size_t i = 1; i < _free_ranges.size(); ++i) + { + const FreeRange &n = _free_ranges[i]; + const uint32_t cur_end = cur.base + cur.count; + if (n.base <= cur_end) + { + const uint32_t n_end = n.base + n.count; + cur.count = std::max(cur_end, n_end) - cur.base; + } + else + { + merged.push_back(cur); + cur = n; + } + } + merged.push_back(cur); + _free_ranges = std::move(merged); +} + +void ParticlePass::free_range(uint32_t base, uint32_t count) +{ + if (count == 0) return; + _free_ranges.push_back(FreeRange{base, count}); + merge_free_ranges(); +} + +uint32_t ParticlePass::create_system(uint32_t count) +{ + if (count == 0) return 0; + count = std::min(count, free_particles()); + + uint32_t base = 0; + if (!allocate_range(count, base)) return 0; + + System s{}; + s.id = _next_system_id++; + s.base = base; + s.count = count; + s.enabled = true; + s.reset = true; + s.blend = BlendMode::Additive; + s.params = Params{}; + + _systems.push_back(s); + return s.id; +} + +bool ParticlePass::destroy_system(uint32_t id) +{ + for (size_t i = 0; i < _systems.size(); ++i) + { + if (_systems[i].id != id) continue; + free_range(_systems[i].base, _systems[i].count); + _systems.erase(_systems.begin() + static_cast(i)); + return true; + } + return false; +} + +bool ParticlePass::resize_system(uint32_t id, uint32_t new_count) +{ + System *s = find_system(id); + if (!s) return false; + if (new_count == s->count) return true; + if (new_count == 0) return destroy_system(id); + + const uint32_t old_base = s->base; + const uint32_t old_count = s->count; + + // Shrink in place: keep base and return the tail to the freelist. + if (new_count < old_count) + { + const uint32_t tail_base = old_base + new_count; + const uint32_t tail_count = old_count - new_count; + s->count = new_count; + free_range(tail_base, tail_count); + return true; + } + + // Try to grow in place if the next range is free. + const uint32_t extra = new_count - old_count; + const uint32_t want_base = old_base + old_count; + for (auto it = _free_ranges.begin(); it != _free_ranges.end(); ++it) + { + if (it->base != want_base) continue; + if (it->count < extra) break; + + it->base += extra; + it->count -= extra; + if (it->count == 0) _free_ranges.erase(it); + + s->count = new_count; + s->reset = true; + return true; + } + + // Fallback: allocate a new range and recycle the old one. + uint32_t base = 0; + if (!allocate_range(new_count, base)) return false; + + s->base = base; + s->count = new_count; + s->reset = true; + + free_range(old_base, old_count); + return true; +} + +void ParticlePass::register_graph(RenderGraph *graph, RGImageHandle hdrTarget, RGImageHandle depthHandle) +{ + if (!graph || !_context || !_context->pipelines || _particle_pool.buffer == VK_NULL_HANDLE || + !hdrTarget.valid() || !depthHandle.valid()) + { + return; + } + + // Per-frame dt/time and floating-origin delta + _dt_sec = 0.0f; + if (_context->scene) + { + _dt_sec = _context->scene->getDeltaTime(); + } + if (!std::isfinite(_dt_sec)) _dt_sec = 0.0f; + _dt_sec = std::clamp(_dt_sec, 0.0f, 0.1f); + _time_sec += _dt_sec; + + _origin_delta_local = glm::vec3(0.0f); + if (_context->scene) + { + WorldVec3 origin_world = _context->scene->get_world_origin(); + if (_has_prev_origin) + { + const WorldVec3 delta_world = origin_world - _prev_origin_world; + _origin_delta_local = glm::vec3(delta_world); + } + _prev_origin_world = origin_world; + _has_prev_origin = true; + } + + bool any_active = false; + for (const auto &s : _systems) + { + if (s.enabled && s.count > 0) + { + any_active = true; + break; + } + } + if (!any_active) return; + + VkBuffer pool = _particle_pool.buffer; + VkDeviceSize poolSize = _particle_pool_size; + + graph->add_pass( + "Particles.Update", + RGPassType::Compute, + [pool, poolSize](RGPassBuilder &builder, EngineContext *) { + builder.write_buffer(pool, RGBufferUsage::StorageReadWrite, poolSize, "particles.pool"); + }, + [this](VkCommandBuffer cmd, const RGPassResources &, EngineContext *ctx) { + EngineContext *ctxLocal = ctx ? ctx : _context; + if (!ctxLocal || !ctxLocal->pipelines) return; + + for (auto &sys : _systems) + { + if (!sys.enabled || sys.count == 0) continue; + + ParticleUpdatePushConstants pc{}; + pc.header = glm::uvec4(sys.base, sys.count, sys.reset ? 1u : 0u, 0u); + + float minSpeed = sys.params.min_speed; + float maxSpeed = sys.params.max_speed; + if (!std::isfinite(minSpeed)) minSpeed = 0.0f; + if (!std::isfinite(maxSpeed)) maxSpeed = 0.0f; + if (maxSpeed < minSpeed) std::swap(minSpeed, maxSpeed); + + float minLife = sys.params.min_life; + float maxLife = sys.params.max_life; + if (!std::isfinite(minLife)) minLife = 0.1f; + if (!std::isfinite(maxLife)) maxLife = 0.1f; + if (maxLife < minLife) std::swap(minLife, maxLife); + + float minSize = sys.params.min_size; + float maxSize = sys.params.max_size; + if (!std::isfinite(minSize)) minSize = 0.01f; + if (!std::isfinite(maxSize)) maxSize = 0.01f; + if (maxSize < minSize) std::swap(minSize, maxSize); + + const float radius = clamp_nonnegative(sys.params.spawn_radius); + const float drag = clamp_nonnegative(sys.params.drag); + const float gravity = sys.params.gravity; + + pc.sim = glm::vec4(_dt_sec, _time_sec, drag, gravity); + pc.origin_delta = glm::vec4(_origin_delta_local, 0.0f); + pc.emitter_pos_radius = glm::vec4(sys.params.emitter_pos_local, radius); + + glm::vec3 dir = safe_normalize(sys.params.emitter_dir_local, glm::vec3(0.0f, 1.0f, 0.0f)); + const float coneRad = glm::radians(sys.params.cone_angle_degrees); + pc.emitter_dir_cone = glm::vec4(dir, coneRad); + + pc.ranges = glm::vec4(minSpeed, maxSpeed, minLife, maxLife); + pc.size_range = glm::vec4(minSize, maxSize, 0.0f, 0.0f); + pc.color = sys.params.color; + + ComputeDispatchInfo di{}; + di.groupCountX = ComputeManager::calculateGroupCount(sys.count, k_local_size_x); + di.groupCountY = 1; + di.groupCountZ = 1; + di.pushConstants = &pc; + di.pushConstantSize = sizeof(pc); + + ctxLocal->pipelines->dispatchComputeInstance(cmd, "particles.update", di); + + sys.reset = false; + } + }); + + graph->add_pass( + "Particles.Render", + RGPassType::Graphics, + [pool, poolSize, hdrTarget, depthHandle](RGPassBuilder &builder, EngineContext *) { + builder.read_buffer(pool, RGBufferUsage::StorageRead, poolSize, "particles.pool"); + builder.write_color(hdrTarget); + builder.write_depth(depthHandle, false /*load existing depth*/); + }, + [this](VkCommandBuffer cmd, const RGPassResources &, EngineContext *ctx) { + EngineContext *ctxLocal = ctx ? ctx : _context; + if (!ctxLocal || !ctxLocal->currentFrame) return; + + ResourceManager *rm = ctxLocal->getResources(); + DeviceManager *dev = ctxLocal->getDevice(); + DescriptorManager *layouts = ctxLocal->getDescriptorLayouts(); + PipelineManager *pipes = ctxLocal->pipelines; + if (!rm || !dev || !layouts || !pipes || _particle_set_layout == VK_NULL_HANDLE) return; + + // Per-frame SceneData UBO (set=0 binding=0) + AllocatedBuffer sceneBuf = rm->create_buffer(sizeof(GPUSceneData), + VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, + VMA_MEMORY_USAGE_CPU_TO_GPU); + ctxLocal->currentFrame->_deletionQueue.push_function([rm, sceneBuf]() { rm->destroy_buffer(sceneBuf); }); + + VmaAllocationInfo ai{}; + vmaGetAllocationInfo(dev->allocator(), sceneBuf.allocation, &ai); + *reinterpret_cast(ai.pMappedData) = ctxLocal->getSceneData(); + vmaFlushAllocation(dev->allocator(), sceneBuf.allocation, 0, sizeof(GPUSceneData)); + + VkDescriptorSet globalSet = ctxLocal->currentFrame->_frameDescriptors.allocate( + dev->device(), layouts->gpuSceneDataLayout()); + { + DescriptorWriter w; + w.write_buffer(0, sceneBuf.buffer, sizeof(GPUSceneData), 0, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER); + w.update_set(dev->device(), globalSet); + } + + // Particle pool descriptor (set=1 binding=0) + VkDescriptorSet particleSet = ctxLocal->currentFrame->_frameDescriptors.allocate( + dev->device(), _particle_set_layout); + { + DescriptorWriter w; + w.write_buffer(0, _particle_pool.buffer, _particle_pool_size, 0, VK_DESCRIPTOR_TYPE_STORAGE_BUFFER); + w.update_set(dev->device(), particleSet); + } + + VkExtent2D extent = ctxLocal->getDrawExtent(); + VkViewport vp{0.0f, 0.0f, float(extent.width), float(extent.height), 0.0f, 1.0f}; + VkRect2D sc{{0, 0}, extent}; + vkCmdSetViewport(cmd, 0, 1, &vp); + vkCmdSetScissor(cmd, 0, 1, &sc); + + VkPipelineLayout layout = VK_NULL_HANDLE; + VkPipeline pipeline = VK_NULL_HANDLE; + BlendMode boundBlend = BlendMode::Additive; + bool hasBound = false; + + auto bind_pipeline = [&](BlendMode blend) { + const char *name = (blend == BlendMode::Alpha) ? "particles.alpha" : "particles.additive"; + if (!pipes->getGraphics(name, pipeline, layout)) return false; + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, layout, 0, 1, &globalSet, 0, nullptr); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, layout, 1, 1, &particleSet, 0, nullptr); + boundBlend = blend; + hasBound = true; + return true; + }; + + for (const auto &sys : _systems) + { + if (!sys.enabled || sys.count == 0) continue; + if (!hasBound || sys.blend != boundBlend) + { + if (!bind_pipeline(sys.blend)) continue; + } + + // Instanced quad draw. gl_InstanceIndex includes firstInstance, so it becomes the particle index. + vkCmdDraw(cmd, 6, sys.count, 0, sys.base); + if (ctxLocal->stats) + { + ctxLocal->stats->drawcall_count += 1; + ctxLocal->stats->triangle_count += static_cast(sys.count) * 2; + } + } + }); +} diff --git a/src/render/passes/particles.h b/src/render/passes/particles.h new file mode 100644 index 0000000..b432711 --- /dev/null +++ b/src/render/passes/particles.h @@ -0,0 +1,105 @@ +#pragma once + +#include "core/world.h" +#include "render/graph/types.h" +#include "render/renderpass.h" + +#include +#include + +class RenderGraph; + +class ParticlePass : public IRenderPass +{ +public: + static constexpr uint32_t k_max_particles = 128u * 1024u; + + enum class BlendMode : uint32_t + { + Additive = 0, + Alpha = 1, + }; + + struct Params + { + glm::vec3 emitter_pos_local{0.0f, 0.0f, 0.0f}; + float spawn_radius{0.1f}; + + glm::vec3 emitter_dir_local{0.0f, 1.0f, 0.0f}; + float cone_angle_degrees{20.0f}; + + float min_speed{2.0f}; + float max_speed{8.0f}; + + float min_life{0.5f}; + float max_life{1.5f}; + + float min_size{0.05f}; + float max_size{0.15f}; + + float drag{1.0f}; + float gravity{0.0f}; // positive pulls down -Y in local space + + glm::vec4 color{1.0f, 0.5f, 0.1f, 1.0f}; + }; + + struct System + { + uint32_t id{0}; + uint32_t base{0}; + uint32_t count{0}; + bool enabled{true}; + bool reset{true}; + BlendMode blend{BlendMode::Additive}; + Params params{}; + }; + + void init(EngineContext *context) override; + void cleanup() override; + void execute(VkCommandBuffer cmd) override; + const char *getName() const override { return "Particles"; } + + void register_graph(RenderGraph *graph, RGImageHandle hdrTarget, RGImageHandle depthHandle); + + uint32_t create_system(uint32_t count); + bool destroy_system(uint32_t id); + bool resize_system(uint32_t id, uint32_t new_count); + + std::vector &systems() { return _systems; } + const std::vector &systems() const { return _systems; } + + uint32_t allocated_particles() const; + uint32_t free_particles() const; + +private: + struct FreeRange + { + uint32_t base{0}; + uint32_t count{0}; + }; + + bool allocate_range(uint32_t count, uint32_t &out_base); + void free_range(uint32_t base, uint32_t count); + void merge_free_ranges(); + + System *find_system(uint32_t id); + + EngineContext *_context = nullptr; + + AllocatedBuffer _particle_pool{}; + VkDeviceSize _particle_pool_size = 0; + + VkDescriptorSetLayout _particle_set_layout = VK_NULL_HANDLE; + + uint32_t _next_system_id = 1; + std::vector _systems; + std::vector _free_ranges; + + float _time_sec = 0.0f; + float _dt_sec = 0.0f; + glm::vec3 _origin_delta_local{0.0f}; + + WorldVec3 _prev_origin_world{0.0, 0.0, 0.0}; + bool _has_prev_origin = false; +}; + diff --git a/src/render/renderpass.cpp b/src/render/renderpass.cpp index b99b81c..fbf1360 100644 --- a/src/render/renderpass.cpp +++ b/src/render/renderpass.cpp @@ -5,6 +5,7 @@ #include "passes/imgui_pass.h" #include "passes/lighting.h" #include "passes/ssr.h" +#include "passes/particles.h" #include "passes/fxaa.h" #include "passes/transparent.h" #include "passes/tonemap.h" @@ -36,6 +37,11 @@ void RenderPassManager::init(EngineContext *context) ssrPass->init(context); addPass(std::move(ssrPass)); + // GPU particle system (compute update + render) + auto particlePass = std::make_unique(); + particlePass->init(context); + addPass(std::move(particlePass)); + // Post-process AA (FXAA-like) after tonemapping. auto fxaaPass = std::make_unique(); fxaaPass->init(context);