diff --git a/CMakeLists.txt b/CMakeLists.txt index 56ce5a8..50793b5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -124,6 +124,8 @@ add_library(render_renderer STATIC engine/render/lighting.hpp engine/render/lighting.cpp engine/render/texture_types.hpp + engine/render/vfx.hpp + engine/render/vfx.cpp engine/render/bgfx/renderer_bgfx.cpp ) add_library(render::renderer ALIAS render_renderer) @@ -231,6 +233,11 @@ if(RENDER_BUILD_TESTS) render_apply_project_options(render_scene_tests) render_apply_warnings(render_scene_tests) + add_executable(render_vfx_system_tests tests/render/vfx_system_tests.cpp) + target_link_libraries(render_vfx_system_tests PRIVATE render::renderer) + render_apply_project_options(render_vfx_system_tests) + render_apply_warnings(render_vfx_system_tests) + add_test(NAME unit.platform.types COMMAND render_platform_types_tests) set_tests_properties(unit.platform.types PROPERTIES LABELS "unit;platform") add_test(NAME unit.core.runtime COMMAND render_core_runtime_tests) @@ -251,12 +258,14 @@ if(RENDER_BUILD_TESTS) set_tests_properties(unit.renderer.debug_views PROPERTIES LABELS "unit;renderer;debug") add_test(NAME unit.scene.runtime COMMAND render_scene_tests) set_tests_properties(unit.scene.runtime PROPERTIES LABELS "unit;scene") + add_test(NAME unit.renderer.vfx_system COMMAND render_vfx_system_tests) + set_tests_properties(unit.renderer.vfx_system PROPERTIES LABELS "unit;renderer;vfx") add_test(NAME headless.smoke.startup COMMAND render_headless_validation) set_tests_properties(headless.smoke.startup PROPERTIES LABELS "headless;smoke") add_custom_target(render_test_unit COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure --label-regex unit - DEPENDS render_platform_types_tests render_core_runtime_tests render_serialization_tests render_filesystem_tests render_renderer_lifecycle_tests render_shader_pipeline_tests render_geometry_submission_tests render_lighting_pipeline_tests render_debug_renderer_tests render_scene_tests + DEPENDS render_platform_types_tests render_core_runtime_tests render_serialization_tests render_filesystem_tests render_renderer_lifecycle_tests render_shader_pipeline_tests render_geometry_submission_tests render_lighting_pipeline_tests render_debug_renderer_tests render_scene_tests render_vfx_system_tests WORKING_DIRECTORY "${CMAKE_BINARY_DIR}" COMMENT "Running render unit tests" ) diff --git a/docs/rendering.md b/docs/rendering.md index 595eddf..58ae773 100644 --- a/docs/rendering.md +++ b/docs/rendering.md @@ -7,6 +7,7 @@ - Statement 11: scene graph integration (camera/light/renderable extraction). - Statement 12: geometry submission + batching foundation. - Statement 13: forward-plus style lighting data path (light selection, fog/bloom/shadow/outline settings, and diagnostics). +- Statement 15: ambient VFX system for spores/dust/embers/resin drips/glow pulses/market ambience. ## Engine-owned shader architecture @@ -251,3 +252,19 @@ Tracked pass rows currently include: - composite/present placeholder This keeps the instrumentation API stable while deferring backend-specific GPU timer query plumbing to a follow-up statement. + + +## Ambient VFX layer (Statement 15) + +Statement 15 adds `engine/render/vfx.*` as a renderer-owned ambient effect subsystem with scene attachment support. + +Highlights: + +- typed effect definitions + reusable presets (spores, dust, embers, resin drips, glow pulses, market ambience) +- deterministic CPU simulation with seeded initialization +- GPU instanced sprite submission through existing renderer APIs +- scene integration via `scene::VfxAttachmentComponent` and visibility filtering +- debug counters for VFX active effects/particles/draws/uploads +- shader pipeline integration through `vfx/ambient_sprite` manifest entries and variants + +Detailed design and current limits are documented in `docs/vfx.md`. diff --git a/docs/vfx.md b/docs/vfx.md new file mode 100644 index 0000000..379514d --- /dev/null +++ b/docs/vfx.md @@ -0,0 +1,62 @@ +# Ambient VFX System (Statement 15) + +Statement 15 adds an engine-owned ambient VFX subsystem focused on stylized, lightweight atmosphere. + +## Architecture + +- Runtime module: `engine/render/vfx.hpp/.cpp`. +- Scene bridge: `scene::VfxAttachmentComponent` attaches a scene node to a VFX effect handle. +- Render integration: VFX produces instanced `DrawSubmission` payloads consumed by existing renderer submission APIs. + +## Effect model + +`EffectDefinition` defines effect templates with typed parameter groups: + +- core: `EffectType`, `PrimitiveType`, `max_particles`, `spawn_rate_per_second`, `looping` +- spawn: `EffectSpawnRegion` +- particle motion: `ParticleStyle` (size/lifetime/velocity/drift/emissive) +- resin-specific: `ResinDripStyle` +- pulse-specific: `GlowPulseStyle` +- deterministic seed: `core::Seed` + +Runtime state (`EffectState`) stores per-instance RNG, particle arrays, timers, and transform. + +## CPU vs GPU responsibilities + +Current maturity level intentionally uses a practical hybrid path: + +- CPU: spawn/update/lifetime simulation and deterministic seeded randomization. +- GPU: draws all active particles/primitives through instanced sprite submissions. + +This keeps costs low for ambient populations while avoiding premature compute complexity. + +## Supported classes + +Built-in definition helpers: + +- spores (`make_spores_definition`) +- dust (`make_dust_definition`) +- embers (`make_embers_definition`) +- resin drips (`make_resin_drips_definition`) +- glow pulses (`make_glow_pulses_definition`) +- market hall ambience preset (`make_market_ambience_preset`) + +Market ambience is composite by design (dust + warm specks + pulse accents). + +## Renderer pass placement + +`render_shell` submits VFX in an explicit `vfx-ambient-pass` after the main lit pass planning and before post placeholders. This keeps atmospheric sprites in the regular frame lifecycle and visible in renderer debug timings. + +## Performance policy + +- One instanced draw submission per active effect instance. +- Particle payload uploads are contiguous 4x4 transform arrays (`64 bytes/instance`). +- Toggle mask (`VfxToggleMask`) allows category-level disable for profiling. +- Debug counters include active effect/particle counts and VFX draw/upload totals. + +## Current limits / deferred follow-ups + +- No compute simulation yet. +- No per-instance color buffer (variant-based tinting used currently). +- Billboard facing/orientation is currently lightweight and world-oriented. +- Future statements can add richer resource bindings, textured sprites, collision, and gameplay-driven bursts. diff --git a/engine/render/README.md b/engine/render/README.md index 6486e3c..6ed99f6 100644 --- a/engine/render/README.md +++ b/engine/render/README.md @@ -76,3 +76,16 @@ Current debug view behavior: - `light-volumes`: normal shading with overlay diagnostics for light counts. - `overdraw`: additive/no-depth approximation pass override. - `gpu-timing`: overlay emphasizes per-pass timing rows; current implementation is CPU-timed with explicit `gpu=n/a` fallback. + + +## Ambient VFX system (Statement 15) + +`vfx.hpp/.cpp` adds an engine-owned atmospheric VFX layer for ambient world motion. + +- effect templates + runtime instances (spores, dust, embers, resin drips, glow pulses, market ambience) +- deterministic CPU simulation for spawn/lifetime/drift using `core::Random` +- GPU instanced sprite submission through existing `Renderer::submit_instanced` +- diagnostics for active effects/particles and VFX draw/upload counts +- category toggles (`VfxToggleMask`) for profiling and debug isolation + +Scene-level attachment is handled by `scene::VfxAttachmentComponent` (in `engine/scene`) so effects can follow node transforms without exposing backend-specific handles. diff --git a/engine/render/debug_renderer.cpp b/engine/render/debug_renderer.cpp index 32404d1..bc91cb8 100644 --- a/engine/render/debug_renderer.cpp +++ b/engine/render/debug_renderer.cpp @@ -122,6 +122,13 @@ std::vector RendererDebugState::build_overlay_lines() const { << " frame_ms=" << snapshot_.frame_time_ms; lines.push_back(counts.str()); + std::ostringstream vfx_counts; + vfx_counts << "vfx effects=" << snapshot_.counters.vfx_active_effects + << " particles=" << snapshot_.counters.vfx_active_particles + << " draws=" << snapshot_.counters.vfx_draw_calls + << " uploads=" << snapshot_.counters.vfx_instance_uploads; + lines.push_back(vfx_counts.str()); + if (!snapshot_.gpu_timing_supported) { lines.push_back("gpu timing unavailable (using cpu pass timings)"); } diff --git a/engine/render/debug_renderer.hpp b/engine/render/debug_renderer.hpp index d6d331a..659df1f 100644 --- a/engine/render/debug_renderer.hpp +++ b/engine/render/debug_renderer.hpp @@ -31,6 +31,10 @@ struct RendererDebugCounters { std::uint32_t submitted_draws{0}; std::uint32_t instanced_draws{0}; std::uint32_t submitted_instances{0}; + std::uint32_t vfx_active_effects{0}; + std::uint32_t vfx_active_particles{0}; + std::uint32_t vfx_draw_calls{0}; + std::uint32_t vfx_instance_uploads{0}; }; struct RendererPassTiming { diff --git a/engine/render/vfx.cpp b/engine/render/vfx.cpp new file mode 100644 index 0000000..4022462 --- /dev/null +++ b/engine/render/vfx.cpp @@ -0,0 +1,407 @@ +#include "engine/render/vfx.hpp" + +#include + +#include +#include +#include +#include + +namespace render::rendering::vfx { +namespace { + +struct PosColorVertex { + float x; + float y; + float z; + std::uint32_t abgr; +}; + +constexpr std::uint64_t kVfxBlendState = BGFX_STATE_WRITE_RGB | BGFX_STATE_WRITE_A | BGFX_STATE_WRITE_Z + | BGFX_STATE_DEPTH_TEST_LEQUAL | BGFX_STATE_BLEND_ALPHA; + +std::array compose_particle_transform(const core::Vec3 position, const float scale_y, const float scale_xz) { + core::Transform t{}; + t.translation = position; + t.scale = {scale_xz, scale_y, scale_xz}; + return t.to_matrix().m; +} + +bool is_particle_type(const PrimitiveType primitive) { + return primitive == PrimitiveType::Particle || primitive == PrimitiveType::Drip; +} + +} // namespace + +bool VfxToggleMask::enabled(const EffectType type) const noexcept { + switch (type) { + case EffectType::Spores: return spores; + case EffectType::Dust: return dust; + case EffectType::Embers: return embers; + case EffectType::ResinDrips: return resin_drips; + case EffectType::GlowPulses: return glow_pulses; + case EffectType::MarketAmbience: return market_ambience; + default: return true; + } +} + +VfxSystem::VfxSystem(Renderer& renderer, ShaderProgramLibrary& shader_library) + : renderer_(renderer), + shader_library_(shader_library) {} + +bool VfxSystem::initialize() { + const std::array vertices = { + PosColorVertex{-0.5F, 0.0F, 0.0F, 0xffffffff}, + PosColorVertex{0.5F, 0.0F, 0.0F, 0xffffffff}, + PosColorVertex{0.5F, 1.0F, 0.0F, 0xffffffff}, + PosColorVertex{-0.5F, 1.0F, 0.0F, 0xffffffff}, + }; + const std::array indices = {0, 1, 2, 2, 3, 0}; + + MeshBufferDescription mesh_desc{}; + mesh_desc.layout.stride = static_cast(sizeof(PosColorVertex)); + mesh_desc.layout.elements = { + {VertexAttribute::Position, 0, 3, AttributeType::Float, false, false}, + {VertexAttribute::Color0, 12, 4, AttributeType::Uint8, true, false}, + }; + mesh_desc.data = std::as_bytes(std::span{vertices}); + mesh_desc.vertex_count = static_cast(vertices.size()); + mesh_desc.usage = BufferUsage::Immutable; + + IndexBufferDescription index_desc{}; + index_desc.data = std::as_bytes(std::span{indices}); + index_desc.index_count = static_cast(indices.size()); + + resources_.quad_mesh = renderer_.create_mesh_buffer(mesh_desc); + resources_.quad_indices = renderer_.create_index_buffer(index_desc); + + resources_.spores_program = shader_library_.load_program({.category = "vfx", .name = "ambient_sprite", .variant = "spores"}); + resources_.dust_program = shader_library_.load_program({.category = "vfx", .name = "ambient_sprite", .variant = "dust"}); + resources_.embers_program = shader_library_.load_program({.category = "vfx", .name = "ambient_sprite", .variant = "embers"}); + resources_.drips_program = shader_library_.load_program({.category = "vfx", .name = "ambient_sprite", .variant = "resin_drip"}); + resources_.pulses_program = shader_library_.load_program({.category = "vfx", .name = "ambient_sprite", .variant = "glow_pulse"}); + + return resources_.quad_mesh.idx != kInvalidHandle && resources_.quad_indices.idx != kInvalidHandle; +} + +void VfxSystem::shutdown() { + for (auto& [_, effect] : effects_) { + effect.alive = false; + } + effects_.clear(); + renderer_.destroy_program(resources_.spores_program); + renderer_.destroy_program(resources_.dust_program); + renderer_.destroy_program(resources_.embers_program); + renderer_.destroy_program(resources_.drips_program); + renderer_.destroy_program(resources_.pulses_program); + renderer_.destroy_buffer(resources_.quad_mesh); + renderer_.destroy_buffer(resources_.quad_indices); + resources_ = {}; +} + +EffectHandle VfxSystem::spawn_effect(const EffectDefinition& definition, const core::Transform& transform, const EffectVisibility visibility) { + EffectState state{}; + state.definition = definition; + state.visibility = visibility; + state.transform = transform; + state.random = core::Random(definition.seed); + state.particles.resize(definition.max_particles); + seed_particles(state); + + const std::uint32_t handle = next_effect_handle_++; + effects_.emplace(handle, std::move(state)); + return EffectHandle{handle}; +} + +bool VfxSystem::destroy_effect(const EffectHandle handle) { + return effects_.erase(handle.value) > 0; +} + +bool VfxSystem::set_effect_transform(const EffectHandle handle, const core::Transform& transform) { + auto it = effects_.find(handle.value); + if (it == effects_.end()) { + return false; + } + it->second.transform = transform; + return true; +} + +bool VfxSystem::set_effect_enabled(const EffectHandle handle, const bool enabled) { + auto it = effects_.find(handle.value); + if (it == effects_.end()) { + return false; + } + it->second.visibility.enabled = enabled; + return true; +} + +void VfxSystem::update(const float delta_seconds) { + for (auto& [_, effect] : effects_) { + if (!effect.alive || !effect.visibility.enabled || !toggles_.enabled(effect.definition.type)) { + continue; + } + update_effect(effect, delta_seconds); + } +} + +std::vector VfxSystem::build_frame_submissions(const ViewId view, const std::uint32_t layer_mask) { + diagnostics_ = {}; + std::vector submissions; + + for (const auto& [_, effect] : effects_) { + if (!effect.alive || !effect.visibility.enabled || (effect.visibility.layer_mask & layer_mask) == 0U + || !toggles_.enabled(effect.definition.type)) { + continue; + } + + std::vector alive_particles; + alive_particles.reserve(effect.particles.size()); + for (const auto& particle : effect.particles) { + if (particle.active) { + alive_particles.push_back(particle); + } + } + if (alive_particles.empty()) { + continue; + } + + append_submission(submissions, view, effect, alive_particles); + diagnostics_.total_active_effects += 1; + diagnostics_.total_active_particles += static_cast(alive_particles.size()); + diagnostics_.uploaded_instances += static_cast(alive_particles.size()); + switch (effect.definition.type) { + case EffectType::Spores: diagnostics_.spores += static_cast(alive_particles.size()); break; + case EffectType::Dust: diagnostics_.dust += static_cast(alive_particles.size()); break; + case EffectType::Embers: diagnostics_.embers += static_cast(alive_particles.size()); break; + case EffectType::ResinDrips: diagnostics_.resin_drips += static_cast(alive_particles.size()); break; + case EffectType::GlowPulses: diagnostics_.glow_pulses += static_cast(alive_particles.size()); break; + case EffectType::MarketAmbience: diagnostics_.market_ambience += static_cast(alive_particles.size()); break; + default: break; + } + } + + diagnostics_.draw_calls = static_cast(submissions.size()); + return submissions; +} + +EffectDefinition VfxSystem::make_spores_definition(std::string debug_name) { + EffectDefinition d{}; + d.type = EffectType::Spores; + d.primitive = PrimitiveType::Particle; + d.debug_name = std::move(debug_name); + d.max_particles = 220; + d.spawn_rate_per_second = 48.0F; + d.spawn_region.extents = {3.0F, 2.0F, 3.0F}; + d.particle.size = {0.03F, 0.09F}; + d.particle.lifetime_seconds = {6.0F, 14.0F}; + d.particle.emissive_strength = {0.35F, 0.75F}; + d.particle.initial_velocity = {{-0.015F, -0.01F, -0.015F}, {0.015F, 0.03F, 0.015F}}; + d.particle.drift_velocity = {{-0.01F, -0.002F, -0.01F}, {0.01F, 0.003F, 0.01F}}; + return d; +} + +EffectDefinition VfxSystem::make_dust_definition(std::string debug_name) { + EffectDefinition d = make_spores_definition(std::move(debug_name)); + d.type = EffectType::Dust; + d.max_particles = 320; + d.spawn_rate_per_second = 58.0F; + d.particle.size = {0.015F, 0.04F}; + d.particle.lifetime_seconds = {8.0F, 20.0F}; + d.particle.emissive_strength = {0.02F, 0.08F}; + d.particle.initial_velocity = {{-0.007F, -0.004F, -0.007F}, {0.007F, 0.008F, 0.007F}}; + d.particle.drift_velocity = {{-0.003F, -0.001F, -0.003F}, {0.003F, 0.001F, 0.003F}}; + return d; +} + +EffectDefinition VfxSystem::make_embers_definition(std::string debug_name) { + EffectDefinition d = make_spores_definition(std::move(debug_name)); + d.type = EffectType::Embers; + d.max_particles = 128; + d.spawn_rate_per_second = 36.0F; + d.spawn_region.extents = {1.2F, 0.6F, 1.2F}; + d.particle.size = {0.025F, 0.07F}; + d.particle.lifetime_seconds = {1.2F, 3.8F}; + d.particle.emissive_strength = {0.9F, 1.8F}; + d.particle.initial_velocity = {{-0.03F, 0.12F, -0.03F}, {0.03F, 0.34F, 0.03F}}; + d.particle.drift_velocity = {{-0.025F, 0.01F, -0.025F}, {0.025F, 0.08F, 0.025F}}; + return d; +} + +EffectDefinition VfxSystem::make_resin_drips_definition(std::string debug_name) { + EffectDefinition d{}; + d.type = EffectType::ResinDrips; + d.primitive = PrimitiveType::Drip; + d.debug_name = std::move(debug_name); + d.max_particles = 48; + d.spawn_rate_per_second = 1.0F; + d.spawn_region.extents = {1.3F, 0.2F, 1.3F}; + d.particle.size = {0.03F, 0.08F}; + d.particle.lifetime_seconds = {1.2F, 3.2F}; + d.particle.emissive_strength = {0.12F, 0.25F}; + d.resin.drip_interval_seconds = 0.75F; + d.resin.drip_length = {0.24F, 0.62F}; + d.resin.drip_fall_speed = {0.1F, 0.18F}; + return d; +} + +EffectDefinition VfxSystem::make_glow_pulses_definition(std::string debug_name) { + EffectDefinition d{}; + d.type = EffectType::GlowPulses; + d.primitive = PrimitiveType::Pulse; + d.debug_name = std::move(debug_name); + d.max_particles = 16; + d.spawn_rate_per_second = 0.0F; + d.spawn_region.extents = {0.8F, 0.2F, 0.8F}; + d.particle.size = {0.2F, 0.6F}; + d.particle.lifetime_seconds = {100000.0F, 100000.0F}; + d.pulse.radius = {0.2F, 0.9F}; + d.pulse.intensity = {0.6F, 1.8F}; + d.pulse.frequency_hz = {0.2F, 1.0F}; + return d; +} + +std::vector VfxSystem::make_market_ambience_preset() { + auto dust = make_dust_definition("market_dust"); + dust.type = EffectType::MarketAmbience; + dust.max_particles = 260; + dust.spawn_region.extents = {5.0F, 1.8F, 5.0F}; + + auto embers = make_embers_definition("market_lamp_specks"); + embers.type = EffectType::MarketAmbience; + embers.max_particles = 48; + embers.spawn_region.extents = {4.0F, 1.3F, 4.0F}; + + auto pulses = make_glow_pulses_definition("market_sign_pulses"); + pulses.type = EffectType::MarketAmbience; + pulses.max_particles = 8; + pulses.spawn_region.extents = {3.2F, 0.6F, 3.2F}; + + return {dust, embers, pulses}; +} + +ProgramHandle VfxSystem::program_for_effect_type(const EffectType type) const { + switch (type) { + case EffectType::Spores: return resources_.spores_program; + case EffectType::Dust: return resources_.dust_program; + case EffectType::Embers: return resources_.embers_program; + case EffectType::ResinDrips: return resources_.drips_program; + case EffectType::GlowPulses: return resources_.pulses_program; + case EffectType::MarketAmbience: return resources_.dust_program; + default: return resources_.dust_program; + } +} + +void VfxSystem::seed_particles(EffectState& effect) { + const bool stagger = is_particle_type(effect.definition.primitive); + for (auto& particle : effect.particles) { + if (effect.definition.primitive == PrimitiveType::Pulse) { + respawn_particle(effect, particle, false); + particle.active = true; + particle.age_seconds = effect.random.range_f32(0.0F, 5.0F); + continue; + } + respawn_particle(effect, particle, stagger); + } +} + +void VfxSystem::respawn_particle(EffectState& effect, ParticleState& particle, const bool stagger_spawn) { + const auto& def = effect.definition; + particle.position = random_vec3(effect.random, { + .min = {-def.spawn_region.extents.x, -def.spawn_region.extents.y, -def.spawn_region.extents.z}, + .max = {def.spawn_region.extents.x, def.spawn_region.extents.y, def.spawn_region.extents.z}, + }); + particle.velocity = random_vec3(effect.random, def.particle.initial_velocity); + particle.lifetime_seconds = std::max(0.05F, random_range(effect.random, def.particle.lifetime_seconds)); + particle.age_seconds = stagger_spawn ? effect.random.range_f32(0.0F, particle.lifetime_seconds) : 0.0F; + particle.size = std::max(0.005F, random_range(effect.random, def.particle.size)); + particle.emissive_strength = random_range(effect.random, def.particle.emissive_strength); + particle.phase = effect.random.range_f32(0.0F, core::kTwoPi); + particle.active = def.spawn_rate_per_second > 0.0F || def.primitive == PrimitiveType::Pulse; +} + +void VfxSystem::update_effect(EffectState& effect, const float delta_seconds) { + const auto& def = effect.definition; + + effect.spawn_accumulator += def.spawn_rate_per_second * delta_seconds; + if (def.primitive == PrimitiveType::Drip) { + effect.drip_timer += delta_seconds; + } + + for (auto& particle : effect.particles) { + if (!particle.active) { + if (def.looping && effect.spawn_accumulator >= 1.0F) { + effect.spawn_accumulator -= 1.0F; + respawn_particle(effect, particle, false); + } + continue; + } + + particle.age_seconds += delta_seconds; + const float normalized_age = particle.age_seconds / particle.lifetime_seconds; + + if (def.primitive == PrimitiveType::Particle) { + particle.velocity = particle.velocity + random_vec3(effect.random, def.particle.drift_velocity) * delta_seconds; + particle.position = particle.position + particle.velocity * delta_seconds; + } else if (def.primitive == PrimitiveType::Drip) { + const float speed = random_range(effect.random, def.resin.drip_fall_speed); + particle.position.y -= speed * delta_seconds; + particle.size = random_range(effect.random, def.resin.drip_length); + } else if (def.primitive == PrimitiveType::Pulse) { + particle.phase += delta_seconds * random_range(effect.random, def.pulse.frequency_hz) * core::kTwoPi; + const float wave = (std::sin(particle.phase) * 0.5F) + 0.5F; + particle.size = core::lerp(def.pulse.radius.min, def.pulse.radius.max, wave); + particle.emissive_strength = core::lerp(def.pulse.intensity.min, def.pulse.intensity.max, wave); + } + + if (normalized_age >= 1.0F) { + particle.active = false; + if (def.looping && (def.primitive != PrimitiveType::Drip || effect.drip_timer >= def.resin.drip_interval_seconds)) { + effect.drip_timer = 0.0F; + respawn_particle(effect, particle, false); + } + } + } +} + +void VfxSystem::append_submission( + std::vector& submissions, + const ViewId view, + const EffectState& effect, + const std::span particles) { + FrameSubmission submission{}; + submission.draw.view = view; + submission.draw.mesh.vertex_buffer = resources_.quad_mesh; + submission.draw.mesh.index_buffer = resources_.quad_indices; + submission.draw.mesh.index_count = 6; + submission.draw.material.program = program_for_effect_type(effect.definition.type); + submission.draw.draw_state.state_flags = effect.definition.depth_test ? kVfxBlendState : (kVfxBlendState | BGFX_STATE_DEPTH_TEST_ALWAYS); + submission.transforms.reserve(particles.size() * 16U); + + for (const auto& particle : particles) { + const core::Vec3 world_position = effect.transform.translation + particle.position; + float scale_y = particle.size; + float scale_xz = particle.size; + if (effect.definition.primitive == PrimitiveType::Drip) { + scale_y = std::max(scale_y, particle.size * 2.4F); + scale_xz *= 0.55F; + } + const auto transform = compose_particle_transform(world_position, scale_y, scale_xz); + submission.transforms.insert(submission.transforms.end(), transform.begin(), transform.end()); + } + + submissions.push_back(std::move(submission)); +} + +core::Vec3 VfxSystem::random_vec3(core::Random& random, const Vec3Range& range) { + return { + random.range_f32(range.min.x, range.max.x), + random.range_f32(range.min.y, range.max.y), + random.range_f32(range.min.z, range.max.z), + }; +} + +float VfxSystem::random_range(core::Random& random, const FloatRange& range) { + return random.range_f32(range.min, range.max); +} + +} // namespace render::rendering::vfx diff --git a/engine/render/vfx.hpp b/engine/render/vfx.hpp new file mode 100644 index 0000000..bf4b123 --- /dev/null +++ b/engine/render/vfx.hpp @@ -0,0 +1,200 @@ +#pragma once + +#include "engine/core/math.hpp" +#include "engine/core/random.hpp" +#include "engine/render/draw_submission.hpp" +#include "engine/render/renderer.hpp" +#include "engine/render/shader_library.hpp" + +#include +#include +#include +#include +#include +#include + +namespace render::rendering::vfx { + +enum class EffectType : std::uint8_t { + Spores = 0, + Dust, + Embers, + ResinDrips, + GlowPulses, + MarketAmbience, +}; + +enum class PrimitiveType : std::uint8_t { + Particle = 0, + Drip, + Pulse, +}; + +struct EffectHandle { + std::uint32_t value{0}; + [[nodiscard]] bool valid() const noexcept { return value != 0; } + friend bool operator==(const EffectHandle&, const EffectHandle&) = default; +}; + +struct FloatRange { + float min{0.0F}; + float max{0.0F}; +}; + +struct Vec3Range { + core::Vec3 min{}; + core::Vec3 max{}; +}; + +struct EffectSpawnRegion { + core::Vec3 extents{1.0F, 1.0F, 1.0F}; +}; + +struct ParticleStyle { + FloatRange size{0.03F, 0.08F}; + FloatRange lifetime_seconds{2.0F, 6.0F}; + FloatRange emissive_strength{0.0F, 1.0F}; + Vec3Range initial_velocity{{-0.02F, -0.01F, -0.02F}, {0.02F, 0.04F, 0.02F}}; + Vec3Range drift_velocity{{-0.005F, -0.002F, -0.005F}, {0.005F, 0.002F, 0.005F}}; +}; + +struct ResinDripStyle { + float drip_interval_seconds{1.8F}; + FloatRange drip_length{0.18F, 0.45F}; + FloatRange drip_fall_speed{0.18F, 0.34F}; + FloatRange fade_seconds{0.25F, 0.45F}; +}; + +struct GlowPulseStyle { + FloatRange radius{0.3F, 0.9F}; + FloatRange intensity{0.6F, 1.6F}; + FloatRange frequency_hz{0.25F, 1.0F}; +}; + +struct EffectDefinition { + EffectType type{EffectType::Spores}; + PrimitiveType primitive{PrimitiveType::Particle}; + std::string debug_name{}; + std::uint32_t max_particles{64}; + float spawn_rate_per_second{8.0F}; + bool looping{true}; + bool depth_test{true}; + EffectSpawnRegion spawn_region{}; + ParticleStyle particle{}; + ResinDripStyle resin{}; + GlowPulseStyle pulse{}; + core::Seed seed{1}; +}; + +struct EffectVisibility { + bool enabled{true}; + std::uint32_t layer_mask{0xFFFFFFFFu}; +}; + +struct EffectDiagnostics { + std::uint32_t total_active_effects{0}; + std::uint32_t total_active_particles{0}; + std::uint32_t spores{0}; + std::uint32_t dust{0}; + std::uint32_t embers{0}; + std::uint32_t resin_drips{0}; + std::uint32_t glow_pulses{0}; + std::uint32_t market_ambience{0}; + std::uint32_t draw_calls{0}; + std::uint32_t uploaded_instances{0}; +}; + +struct FrameSubmission { + DrawSubmission draw{}; + std::vector transforms{}; +}; + +struct VfxToggleMask { + bool spores{true}; + bool dust{true}; + bool embers{true}; + bool resin_drips{true}; + bool glow_pulses{true}; + bool market_ambience{true}; + + [[nodiscard]] bool enabled(EffectType type) const noexcept; +}; + +class VfxSystem { +public: + VfxSystem(Renderer& renderer, ShaderProgramLibrary& shader_library); + + bool initialize(); + void shutdown(); + + [[nodiscard]] EffectHandle spawn_effect(const EffectDefinition& definition, const core::Transform& transform, EffectVisibility visibility = {}); + bool destroy_effect(EffectHandle handle); + bool set_effect_transform(EffectHandle handle, const core::Transform& transform); + bool set_effect_enabled(EffectHandle handle, bool enabled); + + void update(float delta_seconds); + [[nodiscard]] std::vector build_frame_submissions(ViewId view, std::uint32_t layer_mask = 0xFFFFFFFFu); + + void set_toggles(const VfxToggleMask& toggles) { toggles_ = toggles; } + [[nodiscard]] const VfxToggleMask& toggles() const noexcept { return toggles_; } + [[nodiscard]] EffectDiagnostics diagnostics() const noexcept { return diagnostics_; } + + [[nodiscard]] static EffectDefinition make_spores_definition(std::string debug_name = "spores"); + [[nodiscard]] static EffectDefinition make_dust_definition(std::string debug_name = "dust"); + [[nodiscard]] static EffectDefinition make_embers_definition(std::string debug_name = "embers"); + [[nodiscard]] static EffectDefinition make_resin_drips_definition(std::string debug_name = "resin_drips"); + [[nodiscard]] static EffectDefinition make_glow_pulses_definition(std::string debug_name = "glow_pulses"); + [[nodiscard]] static std::vector make_market_ambience_preset(); + +private: + struct ParticleState { + core::Vec3 position{}; + core::Vec3 velocity{}; + float age_seconds{0.0F}; + float lifetime_seconds{1.0F}; + float size{0.05F}; + float emissive_strength{0.0F}; + float phase{0.0F}; + bool active{false}; + }; + + struct EffectState { + EffectDefinition definition{}; + EffectVisibility visibility{}; + core::Transform transform{}; + core::Random random{core::Seed{1}}; + std::vector particles{}; + float spawn_accumulator{0.0F}; + float drip_timer{0.0F}; + bool alive{true}; + std::uint32_t generation{1}; + }; + + struct RenderResources { + MeshBufferHandle quad_mesh{}; + IndexBufferHandle quad_indices{}; + ProgramHandle spores_program{}; + ProgramHandle dust_program{}; + ProgramHandle embers_program{}; + ProgramHandle drips_program{}; + ProgramHandle pulses_program{}; + }; + + [[nodiscard]] ProgramHandle program_for_effect_type(EffectType type) const; + void seed_particles(EffectState& effect); + void respawn_particle(EffectState& effect, ParticleState& particle, bool stagger_spawn); + void update_effect(EffectState& effect, float delta_seconds); + void append_submission(std::vector& submissions, ViewId view, const EffectState& effect, std::span particles); + [[nodiscard]] core::Vec3 random_vec3(core::Random& random, const Vec3Range& range); + [[nodiscard]] float random_range(core::Random& random, const FloatRange& range); + + Renderer& renderer_; + ShaderProgramLibrary& shader_library_; + RenderResources resources_{}; + std::unordered_map effects_{}; + std::uint32_t next_effect_handle_{1}; + VfxToggleMask toggles_{}; + EffectDiagnostics diagnostics_{}; +}; + +} // namespace render::rendering::vfx diff --git a/engine/scene/scene.cpp b/engine/scene/scene.cpp index fcedb19..3bc78b5 100644 --- a/engine/scene/scene.cpp +++ b/engine/scene/scene.cpp @@ -56,6 +56,7 @@ SceneNodeId Scene::create_node(std::string debug_name) { record.camera.reset(); record.light.reset(); record.renderable.reset(); + record.vfx_attachment.reset(); const SceneNodeId node{index, record.generation}; RENDER_LOG_INFO("scene", "create node idx=", node.index, " name='", record.debug_name, "'"); @@ -298,6 +299,32 @@ const RenderableComponent* Scene::renderable(const SceneNodeId node) const { return &record->renderable.value(); } +bool Scene::set_vfx_attachment(const SceneNodeId node, const VfxAttachmentComponent& attachment_component) { + NodeRecord* record = lookup(node); + if (record == nullptr) { + return false; + } + record->vfx_attachment = attachment_component; + return true; +} + +bool Scene::clear_vfx_attachment(const SceneNodeId node) { + NodeRecord* record = lookup(node); + if (record == nullptr) { + return false; + } + record->vfx_attachment.reset(); + return true; +} + +const VfxAttachmentComponent* Scene::vfx_attachment(const SceneNodeId node) const { + const NodeRecord* record = lookup(node); + if (record == nullptr || !record->vfx_attachment.has_value()) { + return nullptr; + } + return &record->vfx_attachment.value(); +} + bool Scene::set_active_camera(const SceneNodeId node) { const NodeRecord* record = lookup(node); if (record == nullptr || !record->camera.has_value()) { @@ -462,6 +489,29 @@ std::vector Scene::collect_highlighted_renderables(const std: return out; } +std::vector Scene::collect_visible_vfx_attachments(const std::uint32_t layer_mask) const { + std::vector out; + out.reserve(nodes_.size()); + walk_depth_first([this, &out, layer_mask](const SceneNodeId node) { + const NodeRecord* record = lookup(node); + if (record == nullptr || !record->visibility.enabled || !record->vfx_attachment.has_value()) { + return; + } + + const VfxAttachmentComponent& attachment_component = record->vfx_attachment.value(); + if (!attachment_component.enabled) { + return; + } + + if ((record->visibility.layer_mask & layer_mask) == 0U || (attachment_component.layer_mask & layer_mask) == 0U) { + return; + } + + out.push_back({.node = node, .attachment = &attachment_component, .world_transform = &record->world}); + }); + return out; +} + void Scene::set_lighting_settings(const SceneLightingSettings& settings) { lighting_settings_ = settings; } @@ -618,6 +668,7 @@ void Scene::destroy_subtree(const SceneNodeId node) { record->camera.reset(); record->light.reset(); record->renderable.reset(); + record->vfx_attachment.reset(); ++record->generation; free_indices_.push_back(it->index); RENDER_LOG_INFO("scene", "destroy node idx=", it->index); diff --git a/engine/scene/scene.hpp b/engine/scene/scene.hpp index abfd783..043aa0b 100644 --- a/engine/scene/scene.hpp +++ b/engine/scene/scene.hpp @@ -76,6 +76,13 @@ struct RenderableComponent { bool highlighted{false}; }; + +struct VfxAttachmentComponent { + bool enabled{true}; + std::uint32_t layer_mask{kVisibilityAll}; + std::uint32_t effect_handle{0}; +}; + struct SceneFogSettings { bool enabled{false}; core::Vec3 color{0.07F, 0.08F, 0.10F}; @@ -113,6 +120,12 @@ struct VisibleLight { const core::Transform* world_transform{nullptr}; }; +struct VisibleVfxAttachment { + SceneNodeId node{}; + const VfxAttachmentComponent* attachment{nullptr}; + const core::Transform* world_transform{nullptr}; +}; + class Scene { public: [[nodiscard]] SceneNodeId create_node(std::string debug_name = {}); @@ -150,6 +163,10 @@ class Scene { bool clear_renderable(SceneNodeId node); [[nodiscard]] const RenderableComponent* renderable(SceneNodeId node) const; + bool set_vfx_attachment(SceneNodeId node, const VfxAttachmentComponent& attachment); + bool clear_vfx_attachment(SceneNodeId node); + [[nodiscard]] const VfxAttachmentComponent* vfx_attachment(SceneNodeId node) const; + bool set_active_camera(SceneNodeId node); void clear_active_camera(); [[nodiscard]] std::optional active_camera() const { return active_camera_; } @@ -160,6 +177,7 @@ class Scene { [[nodiscard]] std::vector collect_visible_renderables(std::uint32_t layer_mask = kVisibilityAll) const; [[nodiscard]] std::vector collect_visible_lights(std::uint32_t layer_mask = kVisibilityAll) const; [[nodiscard]] std::vector collect_highlighted_renderables(std::uint32_t layer_mask = kVisibilityAll) const; + [[nodiscard]] std::vector collect_visible_vfx_attachments(std::uint32_t layer_mask = kVisibilityAll) const; void set_lighting_settings(const SceneLightingSettings& settings); [[nodiscard]] const SceneLightingSettings& lighting_settings() const noexcept; @@ -182,6 +200,7 @@ class Scene { std::optional camera{}; std::optional light{}; std::optional renderable{}; + std::optional vfx_attachment{}; }; [[nodiscard]] NodeRecord* lookup(SceneNodeId node); diff --git a/engine/shell/main.cpp b/engine/shell/main.cpp index 2840d0a..1c471d4 100644 --- a/engine/shell/main.cpp +++ b/engine/shell/main.cpp @@ -5,6 +5,7 @@ #include "engine/render/lighting.hpp" #include "engine/render/renderer.hpp" #include "engine/render/shader_library.hpp" +#include "engine/render/vfx.hpp" #include "engine/scene/scene.hpp" #include @@ -74,7 +75,7 @@ int main(int argc, char** argv) { render::platform::RuntimeConfig platform_config{}; platform_config.app_name = "render-shell"; platform_config.org_name = "render"; - platform_config.window.title = "render :: Statement 14 renderer debug shell"; + platform_config.window.title = "render :: Statement 15 ambient vfx shell"; platform_config.window.width = 1280; platform_config.window.height = 720; platform_config.window.resizable = true; @@ -137,6 +138,9 @@ int main(int argc, char** argv) { .overdraw = debug_overdraw, }); + render::rendering::vfx::VfxSystem vfx_system{renderer, shader_library}; + vfx_system.initialize(); + render::scene::Scene scene; const auto root = scene.create_node("root"); const auto camera_node = scene.create_node("camera"); @@ -202,6 +206,44 @@ int main(int argc, char** argv) { scene.set_light(light_node, point); } + const auto spores_node = scene.create_node("vfx_spores"); + scene.set_parent(spores_node, root, render::scene::ReparentPolicy::KeepLocalTransform); + scene.set_local_transform(spores_node, render::core::Transform{.translation = {-2.2F, 1.4F, 0.5F}, .rotation = {}, .scale = {1.0F, 1.0F, 1.0F}}); + const auto spores_handle = vfx_system.spawn_effect(render::rendering::vfx::VfxSystem::make_spores_definition("hatchery_spores"), *scene.local_transform(spores_node)); + scene.set_vfx_attachment(spores_node, {.effect_handle = spores_handle.value}); + + const auto dust_node = scene.create_node("vfx_dust"); + scene.set_parent(dust_node, root, render::scene::ReparentPolicy::KeepLocalTransform); + scene.set_local_transform(dust_node, render::core::Transform{.translation = {0.0F, 1.8F, 0.0F}, .rotation = {}, .scale = {1.0F, 1.0F, 1.0F}}); + const auto dust_handle = vfx_system.spawn_effect(render::rendering::vfx::VfxSystem::make_dust_definition("market_dust"), *scene.local_transform(dust_node)); + scene.set_vfx_attachment(dust_node, {.effect_handle = dust_handle.value}); + + const auto embers_node = scene.create_node("vfx_embers"); + scene.set_parent(embers_node, root, render::scene::ReparentPolicy::KeepLocalTransform); + scene.set_local_transform(embers_node, render::core::Transform{.translation = {3.4F, -1.5F, 0.0F}, .rotation = {}, .scale = {1.0F, 1.0F, 1.0F}}); + const auto embers_handle = vfx_system.spawn_effect(render::rendering::vfx::VfxSystem::make_embers_definition("forge_embers"), *scene.local_transform(embers_node)); + scene.set_vfx_attachment(embers_node, {.effect_handle = embers_handle.value}); + + const auto resin_node = scene.create_node("vfx_resin"); + scene.set_parent(resin_node, root, render::scene::ReparentPolicy::KeepLocalTransform); + scene.set_local_transform(resin_node, render::core::Transform{.translation = {-3.6F, 2.4F, -1.0F}, .rotation = {}, .scale = {1.0F, 1.0F, 1.0F}}); + const auto resin_handle = vfx_system.spawn_effect(render::rendering::vfx::VfxSystem::make_resin_drips_definition("organic_resin_drips"), *scene.local_transform(resin_node)); + scene.set_vfx_attachment(resin_node, {.effect_handle = resin_handle.value}); + + const auto pulse_node = scene.create_node("vfx_pulses"); + scene.set_parent(pulse_node, root, render::scene::ReparentPolicy::KeepLocalTransform); + scene.set_local_transform(pulse_node, render::core::Transform{.translation = {1.2F, 0.5F, 1.8F}, .rotation = {}, .scale = {1.0F, 1.0F, 1.0F}}); + const auto pulse_handle = vfx_system.spawn_effect(render::rendering::vfx::VfxSystem::make_glow_pulses_definition("crystal_glow_pulse"), *scene.local_transform(pulse_node)); + scene.set_vfx_attachment(pulse_node, {.effect_handle = pulse_handle.value}); + + for (const auto& preset : render::rendering::vfx::VfxSystem::make_market_ambience_preset()) { + const auto market_node = scene.create_node("vfx_market"); + scene.set_parent(market_node, root, render::scene::ReparentPolicy::KeepLocalTransform); + scene.set_local_transform(market_node, render::core::Transform{.translation = {0.0F, 0.6F, -2.8F}, .rotation = {}, .scale = {1.0F, 1.0F, 1.0F}}); + const auto handle = vfx_system.spawn_effect(preset, *scene.local_transform(market_node)); + scene.set_vfx_attachment(market_node, {.effect_handle = handle.value}); + } + while (!runtime.should_quit()) { runtime.begin_frame(); runtime.pump_events(); @@ -223,6 +265,13 @@ int main(int argc, char** argv) { shader_library.reload_if_stale(shader_id, program); scene.update_world_transforms(); + for (const auto& attachment : scene.collect_visible_vfx_attachments()) { + if (attachment.attachment == nullptr || attachment.world_transform == nullptr) { + continue; + } + vfx_system.set_effect_transform(render::rendering::vfx::EffectHandle{attachment.attachment->effect_handle}, *attachment.world_transform); + } + vfx_system.update(1.0F / 60.0F); const float aspect_ratio = static_cast(window.width) / static_cast(window.height); const auto camera_view = scene.build_camera_view(aspect_ratio); @@ -341,12 +390,24 @@ int main(int argc, char** argv) { renderer.submit(kMainView, batch.unique_draws.front()); } } + const auto vfx_pass_start = std::chrono::steady_clock::now(); + auto vfx_submissions = vfx_system.build_frame_submissions(kMainView); + for (const auto& vfx : vfx_submissions) { + renderer.submit_instanced(kMainView, vfx.draw, std::span{vfx.transforms}); + } + const auto vfx_pass_end = std::chrono::steady_clock::now(); + const auto main_pass_end = std::chrono::steady_clock::now(); renderer.add_debug_pass_timing(render::rendering::RendererPassTiming{ .pass_name = "main-lit-pass", .cpu_ms = static_cast(std::chrono::duration(main_pass_end - main_pass_start).count()), .gpu_ms = std::nullopt, }); + renderer.add_debug_pass_timing(render::rendering::RendererPassTiming{ + .pass_name = "vfx-ambient-pass", + .cpu_ms = static_cast(std::chrono::duration(vfx_pass_end - vfx_pass_start).count()), + .gpu_ms = std::nullopt, + }); renderer.add_debug_pass_timing(render::rendering::RendererPassTiming{.pass_name = "bloom-pass(plan)", .cpu_ms = 0.0F, .gpu_ms = std::nullopt}); renderer.add_debug_pass_timing(render::rendering::RendererPassTiming{.pass_name = "outline-pass(plan)", .cpu_ms = 0.0F, .gpu_ms = std::nullopt}); renderer.add_debug_pass_timing(render::rendering::RendererPassTiming{.pass_name = "composite-present", .cpu_ms = 0.0F, .gpu_ms = std::nullopt}); @@ -356,12 +417,18 @@ int main(int argc, char** argv) { .submitted_draws = diagnostics.submitted_draws, .instanced_draws = diagnostics.instanced_draws, .submitted_instances = diagnostics.submitted_instances, + .vfx_active_effects = vfx_system.diagnostics().total_active_effects, + .vfx_active_particles = vfx_system.diagnostics().total_active_particles, + .vfx_draw_calls = vfx_system.diagnostics().draw_calls, + .vfx_instance_uploads = vfx_system.diagnostics().uploaded_instances, }); renderer.end_frame(); runtime.end_frame(); } + vfx_system.shutdown(); + renderer.destroy_program(program); renderer.destroy_program(debug_normals); renderer.destroy_program(debug_albedo); diff --git a/shaders/README.md b/shaders/README.md index c2bd9e7..0bcce6f 100644 --- a/shaders/README.md +++ b/shaders/README.md @@ -10,6 +10,7 @@ Statement 10 establishes the canonical, engine-owned shader workflow. - `shaders/materials//` material shader sources (`vs.sc`, `fs.sc`, optional `cs.sc`) - `shaders/post//` post-process shader sources - `shaders/debug//` debug visualization shader sources +- `shaders/vfx//` lightweight atmospheric VFX shader sources - `shaders/shaders.cmake` canonical manifest of programs, variants, and defines Generated output root (build tree): diff --git a/shaders/shaders.cmake b/shaders/shaders.cmake index 8eecfcf..949b8ed 100644 --- a/shaders/shaders.cmake +++ b/shaders/shaders.cmake @@ -8,6 +8,11 @@ set(RENDER_SHADER_PROGRAMS "renderer_debug_depth|debug|debug/renderer_debug/vs.sc|debug/renderer_debug/fs_depth.sc|default|" "renderer_debug_overdraw|debug|debug/renderer_debug/vs.sc|debug/renderer_debug/fs_overdraw.sc|default|" "material_unlit_color|materials|materials/unlit_color/vs.sc|materials/unlit_color/fs.sc|default|" + "ambient_sprite|vfx|vfx/ambient_sprite/vs.sc|vfx/ambient_sprite/fs.sc|spores|VFX_SPORES=1" + "ambient_sprite|vfx|vfx/ambient_sprite/vs.sc|vfx/ambient_sprite/fs.sc|dust|VFX_DUST=1" + "ambient_sprite|vfx|vfx/ambient_sprite/vs.sc|vfx/ambient_sprite/fs.sc|embers|VFX_EMBERS=1" + "ambient_sprite|vfx|vfx/ambient_sprite/vs.sc|vfx/ambient_sprite/fs.sc|resin_drip|VFX_RESIN_DRIP=1" + "ambient_sprite|vfx|vfx/ambient_sprite/vs.sc|vfx/ambient_sprite/fs.sc|glow_pulse|VFX_GLOW_PULSE=1" ) # Optional future compute entries: name|category|compute_source|variant|defines diff --git a/shaders/vfx/ambient_sprite/fs.sc b/shaders/vfx/ambient_sprite/fs.sc new file mode 100644 index 0000000..fb4877f --- /dev/null +++ b/shaders/vfx/ambient_sprite/fs.sc @@ -0,0 +1,19 @@ +$input v_color0 + +#include "../../includes/core/common.sh" + +void main() +{ + vec3 tint = vec3(0.75, 0.85, 1.0); +#if defined(VFX_DUST) + tint = vec3(0.55, 0.55, 0.58); +#elif defined(VFX_EMBERS) + tint = vec3(1.0, 0.52, 0.20); +#elif defined(VFX_RESIN_DRIP) + tint = vec3(0.95, 0.67, 0.28); +#elif defined(VFX_GLOW_PULSE) + tint = vec3(0.35, 0.95, 1.0); +#endif + + gl_FragColor = vec4(v_color0.rgb * tint, 0.45); +} diff --git a/shaders/vfx/ambient_sprite/vs.sc b/shaders/vfx/ambient_sprite/vs.sc new file mode 100644 index 0000000..628df63 --- /dev/null +++ b/shaders/vfx/ambient_sprite/vs.sc @@ -0,0 +1,11 @@ +$input a_position, a_color0 +$output v_color0 + +#include "../../includes/core/common.sh" +#include "../../includes/core/transform.sh" + +void main() +{ + gl_Position = render_transform_position(a_position); + v_color0 = render_pack_abgr(a_color0); +} diff --git a/tests/render/vfx_system_tests.cpp b/tests/render/vfx_system_tests.cpp new file mode 100644 index 0000000..e74301d --- /dev/null +++ b/tests/render/vfx_system_tests.cpp @@ -0,0 +1,43 @@ +#include "engine/render/vfx.hpp" + +#include + +int main() { + using namespace render::rendering::vfx; + + const auto spores = VfxSystem::make_spores_definition(); + assert(spores.type == EffectType::Spores); + assert(spores.max_particles >= 128); + assert(spores.spawn_rate_per_second > 0.0F); + + const auto dust = VfxSystem::make_dust_definition(); + assert(dust.type == EffectType::Dust); + assert(dust.particle.emissive_strength.max < spores.particle.emissive_strength.max); + + const auto embers = VfxSystem::make_embers_definition(); + assert(embers.type == EffectType::Embers); + assert(embers.particle.initial_velocity.max.y > dust.particle.initial_velocity.max.y); + + const auto resin = VfxSystem::make_resin_drips_definition(); + assert(resin.type == EffectType::ResinDrips); + assert(resin.primitive == PrimitiveType::Drip); + assert(resin.resin.drip_interval_seconds > 0.0F); + + const auto pulse = VfxSystem::make_glow_pulses_definition(); + assert(pulse.type == EffectType::GlowPulses); + assert(pulse.primitive == PrimitiveType::Pulse); + assert(pulse.pulse.frequency_hz.max >= pulse.pulse.frequency_hz.min); + + const auto market = VfxSystem::make_market_ambience_preset(); + assert(market.size() >= 3); + for (const auto& effect : market) { + assert(effect.type == EffectType::MarketAmbience); + } + + VfxToggleMask toggles{}; + toggles.embers = false; + assert(toggles.enabled(EffectType::Spores)); + assert(!toggles.enabled(EffectType::Embers)); + + return 0; +} diff --git a/tests/scene/scene_tests.cpp b/tests/scene/scene_tests.cpp index a4ce73f..722856a 100644 --- a/tests/scene/scene_tests.cpp +++ b/tests/scene/scene_tests.cpp @@ -78,6 +78,14 @@ int main() { const auto highlighted = graph.collect_highlighted_renderables(0x1u); assert(highlighted.size() == 1); + scene::VfxAttachmentComponent vfx_attachment{}; + vfx_attachment.layer_mask = 0x1u; + vfx_attachment.effect_handle = 42; + assert(graph.set_vfx_attachment(grandchild, vfx_attachment)); + const auto vfx_visible = graph.collect_visible_vfx_attachments(0x1u); + assert(vfx_visible.size() == 1); + assert(vfx_visible.front().attachment->effect_handle == 42); + scene::SceneLightingSettings lighting{}; lighting.fog.enabled = true; lighting.fog.density = 0.03F;