Add ambient insect particles near water vegetation, fix firefly particles, and improve water foam

- Spawn dark point-sprite insects buzzing around cattails/reeds/kelp/seaweed
- Fix firefly M2 particles: exempt from alpha dampening and forced gravity
- Make water shoreline/crest foam more irregular with UV warping and bluer tint
This commit is contained in:
Kelsi 2026-02-23 07:18:44 -08:00
parent c35b40391f
commit 4db97e37b7
9 changed files with 332 additions and 20 deletions

View file

@ -0,0 +1,14 @@
#version 450
layout(location = 0) in float vAlpha;
layout(location = 0) out vec4 outColor;
void main() {
vec2 p = gl_PointCoord - vec2(0.5);
float dist = length(p);
if (dist > 0.5) discard;
float alpha = smoothstep(0.5, 0.05, dist) * vAlpha;
// Dark brown/black insect color
outColor = vec4(0.12, 0.08, 0.05, alpha);
}

Binary file not shown.

View file

@ -130,20 +130,26 @@ float fbmNoise(vec2 p, float time) {
} }
// Voronoi-like cellular noise for foam particles // Voronoi-like cellular noise for foam particles
float cellularFoam(vec2 p) { // jitter parameter controls how much cell points deviate from grid centers
// (0.0 = regular grid, 1.0 = fully random within cell)
float cellularFoam(vec2 p, float jitter) {
vec2 i = floor(p); vec2 i = floor(p);
vec2 f = fract(p); vec2 f = fract(p);
float minDist = 1.0; float minDist = 1.0;
for (int y = -1; y <= 1; y++) { for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) { for (int x = -1; x <= 1; x++) {
vec2 neighbor = vec2(float(x), float(y)); vec2 neighbor = vec2(float(x), float(y));
vec2 point = vec2(hash21(i + neighbor), hash22x(i + neighbor)); vec2 cellId = i + neighbor;
// Jittered cell point — higher jitter = more irregular placement
vec2 point = vec2(hash21(cellId), hash22x(cellId)) * jitter
+ vec2(0.5) * (1.0 - jitter);
float d = length(neighbor + point - f); float d = length(neighbor + point - f);
minDist = min(minDist, d); minDist = min(minDist, d);
} }
} }
return minDist; return minDist;
} }
float cellularFoam(vec2 p) { return cellularFoam(p, 1.0); }
void main() { void main() {
float time = fogParams.z; float time = fogParams.z;
@ -299,24 +305,32 @@ void main() {
if (basicType < 1.5 && verticalDepth > 0.01 && push.waveAmp > 0.0) { if (basicType < 1.5 && verticalDepth > 0.01 && push.waveAmp > 0.0) {
float foamDepthMask = 1.0 - smoothstep(0.0, 1.8, verticalDepth); float foamDepthMask = 1.0 - smoothstep(0.0, 1.8, verticalDepth);
// Warp UV coords with noise to break up cellular regularity
vec2 warpOffset = vec2(
noiseValue(FragPos.xy * 2.5 + time * 0.08) - 0.5,
noiseValue(FragPos.xy * 2.5 + vec2(37.0) + time * 0.06) - 0.5
) * 1.6;
vec2 foamUV = FragPos.xy + warpOffset;
// Fine scattered particles // Fine scattered particles
float cells1 = cellularFoam(FragPos.xy * 14.0 + time * vec2(0.15, 0.08)); float cells1 = cellularFoam(foamUV * 14.0 + time * vec2(0.15, 0.08));
float foam1 = (1.0 - smoothstep(0.0, 0.10, cells1)) * 0.5; float foam1 = (1.0 - smoothstep(0.0, 0.12, cells1)) * 0.45;
// Tiny spray dots // Tiny spray dots
float cells2 = cellularFoam(FragPos.xy * 30.0 + time * vec2(-0.12, 0.22)); float cells2 = cellularFoam(foamUV * 28.0 + time * vec2(-0.12, 0.22));
float foam2 = (1.0 - smoothstep(0.0, 0.06, cells2)) * 0.35; float foam2 = (1.0 - smoothstep(0.0, 0.07, cells2)) * 0.3;
// Micro specks // Micro specks
float cells3 = cellularFoam(FragPos.xy * 55.0 + time * vec2(0.25, -0.1)); float cells3 = cellularFoam(foamUV * 50.0 + time * vec2(0.25, -0.1));
float foam3 = (1.0 - smoothstep(0.0, 0.04, cells3)) * 0.2; float foam3 = (1.0 - smoothstep(0.0, 0.05, cells3)) * 0.18;
// Noise breakup for clumping // Noise breakup for clumping
float noiseMask = noiseValue(FragPos.xy * 3.0 + time * 0.15); float noiseMask = noiseValue(FragPos.xy * 3.0 + time * 0.15);
float foam = (foam1 + foam2 + foam3) * foamDepthMask * smoothstep(0.3, 0.6, noiseMask); float foam = (foam1 + foam2 + foam3) * foamDepthMask * smoothstep(0.3, 0.6, noiseMask);
foam *= smoothstep(0.0, 0.1, verticalDepth); foam *= smoothstep(0.0, 0.1, verticalDepth);
color = mix(color, vec3(0.92, 0.95, 0.98), clamp(foam, 0.0, 0.45)); // Bluer foam tint instead of near-white
color = mix(color, vec3(0.68, 0.78, 0.88), clamp(foam, 0.0, 0.40));
} }
// ============================================================ // ============================================================
@ -324,11 +338,15 @@ void main() {
// ============================================================ // ============================================================
if (basicType > 0.5 && basicType < 1.5 && push.waveAmp > 0.0) { if (basicType > 0.5 && basicType < 1.5 && push.waveAmp > 0.0) {
float crestMask = smoothstep(0.5, 1.0, WaveOffset); float crestMask = smoothstep(0.5, 1.0, WaveOffset);
float crestCells = cellularFoam(FragPos.xy * 6.0 + time * vec2(0.12, 0.08)); vec2 crestWarp = vec2(
noiseValue(FragPos.xy * 1.8 + time * 0.1) - 0.5,
noiseValue(FragPos.xy * 1.8 + vec2(53.0) + time * 0.07) - 0.5
) * 2.0;
float crestCells = cellularFoam((FragPos.xy + crestWarp) * 6.0 + time * vec2(0.12, 0.08));
float crestFoam = (1.0 - smoothstep(0.0, 0.18, crestCells)) * crestMask; float crestFoam = (1.0 - smoothstep(0.0, 0.18, crestCells)) * crestMask;
float crestNoise = noiseValue(FragPos.xy * 3.0 - time * 0.3); float crestNoise = noiseValue(FragPos.xy * 3.0 - time * 0.3);
crestFoam *= smoothstep(0.3, 0.6, crestNoise); crestFoam *= smoothstep(0.3, 0.6, crestNoise);
color = mix(color, vec3(0.92, 0.95, 0.98), crestFoam * 0.35); color = mix(color, vec3(0.68, 0.78, 0.88), crestFoam * 0.30);
} }
// ============================================================ // ============================================================

Binary file not shown.

View file

@ -76,6 +76,8 @@ struct M2ModelGPU {
bool isSmallFoliage = false; // Small foliage (bushes, grass, plants) - skip during taxi bool isSmallFoliage = false; // Small foliage (bushes, grass, plants) - skip during taxi
bool isInvisibleTrap = false; // Invisible trap objects (don't render, no collision) bool isInvisibleTrap = false; // Invisible trap objects (don't render, no collision)
bool isGroundDetail = false; // Ground clutter/detail doodads (special fallback render path) bool isGroundDetail = false; // Ground clutter/detail doodads (special fallback render path)
bool isWaterVegetation = false; // Cattails, reeds, kelp etc. near water (insect spawning)
bool isFireflyEffect = false; // Firefly/fireflies M2 (exempt from particle dampeners)
// Collision mesh with spatial grid (from M2 bounding geometry) // Collision mesh with spatial grid (from M2 bounding geometry)
struct CollisionMesh { struct CollisionMesh {
@ -307,6 +309,8 @@ public:
void setInsideInterior(bool inside) { insideInterior = inside; } void setInsideInterior(bool inside) { insideInterior = inside; }
void setOnTaxi(bool onTaxi) { onTaxi_ = onTaxi; } void setOnTaxi(bool onTaxi) { onTaxi_ = onTaxi; }
std::vector<glm::vec3> getWaterVegetationPositions(const glm::vec3& camPos, float maxDist) const;
private: private:
bool initialized_ = false; bool initialized_ = false;
bool insideInterior = false; bool insideInterior = false;

View file

@ -11,6 +11,7 @@ namespace rendering {
class Camera; class Camera;
class CameraController; class CameraController;
class WaterRenderer; class WaterRenderer;
class M2Renderer;
class VkContext; class VkContext;
class SwimEffects { class SwimEffects {
@ -25,6 +26,7 @@ public:
const WaterRenderer& water, float deltaTime); const WaterRenderer& water, float deltaTime);
void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet); void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet);
void spawnFootSplash(const glm::vec3& footPos, float waterH); void spawnFootSplash(const glm::vec3& footPos, float waterH);
void setM2Renderer(M2Renderer* renderer) { m2Renderer = renderer; }
private: private:
struct Particle { struct Particle {
@ -36,14 +38,30 @@ private:
float alpha; float alpha;
}; };
struct InsectParticle {
glm::vec3 position;
glm::vec3 orbitCenter; // vegetation position to orbit around
float lifetime;
float maxLifetime;
float size;
float alpha;
float phase; // random phase offset for erratic motion
float orbitRadius;
float orbitSpeed;
float heightOffset; // height above plant
};
static constexpr int MAX_RIPPLE_PARTICLES = 200; static constexpr int MAX_RIPPLE_PARTICLES = 200;
static constexpr int MAX_BUBBLE_PARTICLES = 150; static constexpr int MAX_BUBBLE_PARTICLES = 150;
static constexpr int MAX_INSECT_PARTICLES = 50;
std::vector<Particle> ripples; std::vector<Particle> ripples;
std::vector<Particle> bubbles; std::vector<Particle> bubbles;
std::vector<InsectParticle> insects;
// Vulkan objects // Vulkan objects
VkContext* vkCtx = nullptr; VkContext* vkCtx = nullptr;
M2Renderer* m2Renderer = nullptr;
// Ripple pipeline + dynamic buffer // Ripple pipeline + dynamic buffer
VkPipeline ripplePipeline = VK_NULL_HANDLE; VkPipeline ripplePipeline = VK_NULL_HANDLE;
@ -61,14 +79,25 @@ private:
VmaAllocationInfo bubbleDynamicVBAllocInfo{}; VmaAllocationInfo bubbleDynamicVBAllocInfo{};
VkDeviceSize bubbleDynamicVBSize = 0; VkDeviceSize bubbleDynamicVBSize = 0;
// Insect pipeline + dynamic buffer
VkPipeline insectPipeline = VK_NULL_HANDLE;
VkPipelineLayout insectPipelineLayout = VK_NULL_HANDLE;
::VkBuffer insectDynamicVB = VK_NULL_HANDLE;
VmaAllocation insectDynamicVBAlloc = VK_NULL_HANDLE;
VmaAllocationInfo insectDynamicVBAllocInfo{};
VkDeviceSize insectDynamicVBSize = 0;
std::vector<float> rippleVertexData; std::vector<float> rippleVertexData;
std::vector<float> bubbleVertexData; std::vector<float> bubbleVertexData;
std::vector<float> insectVertexData;
float rippleSpawnAccum = 0.0f; float rippleSpawnAccum = 0.0f;
float bubbleSpawnAccum = 0.0f; float bubbleSpawnAccum = 0.0f;
float insectSpawnAccum = 0.0f;
void spawnRipple(const glm::vec3& pos, const glm::vec3& moveDir, float waterH); void spawnRipple(const glm::vec3& pos, const glm::vec3& moveDir, float waterH);
void spawnBubble(const glm::vec3& pos, float waterH); void spawnBubble(const glm::vec3& pos, float waterH);
void spawnInsect(const glm::vec3& vegPos);
}; };
} // namespace rendering } // namespace rendering

View file

@ -1105,6 +1105,19 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
// Spell effect models: particle-dominated with minimal geometry (e.g. LevelUp.m2) // Spell effect models: particle-dominated with minimal geometry (e.g. LevelUp.m2)
gpuModel.isSpellEffect = hasParticles && model.vertices.size() <= 200 && gpuModel.isSpellEffect = hasParticles && model.vertices.size() <= 200 &&
model.particleEmitters.size() >= 3; model.particleEmitters.size() >= 3;
// Water vegetation: cattails, reeds, bulrushes, kelp, seaweed, lilypad near water
gpuModel.isWaterVegetation =
(lowerName.find("cattail") != std::string::npos) ||
(lowerName.find("reed") != std::string::npos) ||
(lowerName.find("bulrush") != std::string::npos) ||
(lowerName.find("seaweed") != std::string::npos) ||
(lowerName.find("kelp") != std::string::npos) ||
(lowerName.find("lilypad") != std::string::npos);
// Firefly effect models: particle-based ambient glow (exempt from dampeners)
gpuModel.isFireflyEffect =
(lowerName.find("firefly") != std::string::npos) ||
(lowerName.find("fireflies") != std::string::npos) ||
(lowerName.find("fireflys") != std::string::npos);
// Build collision mesh + spatial grid from M2 bounding geometry // Build collision mesh + spatial grid from M2 bounding geometry
gpuModel.collision.vertices = model.collisionVertices; gpuModel.collision.vertices = model.collisionVertices;
@ -2803,6 +2816,20 @@ glm::vec3 M2Renderer::interpFBlockVec3(const pipeline::M2FBlock& fb, float lifeR
return fb.vec3Values.back(); return fb.vec3Values.back();
} }
std::vector<glm::vec3> M2Renderer::getWaterVegetationPositions(const glm::vec3& camPos, float maxDist) const {
std::vector<glm::vec3> result;
float maxDistSq = maxDist * maxDist;
for (const auto& inst : instances) {
auto it = models.find(inst.modelId);
if (it == models.end() || !it->second.isWaterVegetation) continue;
glm::vec3 diff = inst.position - camPos;
if (glm::dot(diff, diff) <= maxDistSq) {
result.push_back(inst.position);
}
}
return result;
}
void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt) { void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt) {
if (inst.emitterAccumulators.size() != gpu.particleEmitters.size()) { if (inst.emitterAccumulators.size() != gpu.particleEmitters.size()) {
inst.emitterAccumulators.resize(gpu.particleEmitters.size(), 0.0f); inst.emitterAccumulators.resize(gpu.particleEmitters.size(), 0.0f);
@ -2867,11 +2894,20 @@ void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt
// particles pile up at the same position. Give them a drift so they // particles pile up at the same position. Give them a drift so they
// spread outward like a mist/spray effect instead of clustering. // spread outward like a mist/spray effect instead of clustering.
if (std::abs(speed) < 0.01f) { if (std::abs(speed) < 0.01f) {
p.velocity = rotMat * glm::vec3( if (gpu.isFireflyEffect) {
distN(particleRng_) * 1.0f, // Fireflies: gentle random drift in all directions
distN(particleRng_) * 1.0f, p.velocity = rotMat * glm::vec3(
-dist01(particleRng_) * 0.5f distN(particleRng_) * 0.6f,
); distN(particleRng_) * 0.6f,
distN(particleRng_) * 0.3f
);
} else {
p.velocity = rotMat * glm::vec3(
distN(particleRng_) * 1.0f,
distN(particleRng_) * 1.0f,
-dist01(particleRng_) * 0.5f
);
}
} }
const uint32_t tilesX = std::max<uint16_t>(em.textureCols, 1); const uint32_t tilesX = std::max<uint16_t>(em.textureCols, 1);
@ -2917,7 +2953,8 @@ void M2Renderer::updateParticles(M2Instance& inst, float dt) {
gpu.sequences, gpu.globalSequenceDurations); gpu.sequences, gpu.globalSequenceDurations);
// When M2 gravity is 0, apply default gravity so particles arc downward. // When M2 gravity is 0, apply default gravity so particles arc downward.
// Many fountain M2s rely on bone animation (.anim files) we don't load yet. // Many fountain M2s rely on bone animation (.anim files) we don't load yet.
if (grav == 0.0f) { // Firefly/ambient glow particles intentionally have zero gravity — skip fallback.
if (grav == 0.0f && !gpu.isFireflyEffect) {
float emSpeed = interpFloat(pem.emissionSpeed, float emSpeed = interpFloat(pem.emissionSpeed,
inst.animTime, inst.currentSequenceIndex, inst.animTime, inst.currentSequenceIndex,
gpu.sequences, gpu.globalSequenceDurations); gpu.sequences, gpu.globalSequenceDurations);
@ -2985,12 +3022,12 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame
float alpha = std::min(interpFBlockFloat(em.particleAlpha, lifeRatio), 1.0f); float alpha = std::min(interpFBlockFloat(em.particleAlpha, lifeRatio), 1.0f);
float rawScale = interpFBlockFloat(em.particleScale, lifeRatio); float rawScale = interpFBlockFloat(em.particleScale, lifeRatio);
if (!gpu.isSpellEffect) { if (!gpu.isSpellEffect && !gpu.isFireflyEffect) {
color = glm::mix(color, glm::vec3(1.0f), 0.7f); color = glm::mix(color, glm::vec3(1.0f), 0.7f);
if (rawScale > 2.0f) alpha *= 0.02f; if (rawScale > 2.0f) alpha *= 0.02f;
if (em.blendingType == 3 || em.blendingType == 4) alpha *= 0.05f; if (em.blendingType == 3 || em.blendingType == 4) alpha *= 0.05f;
} }
float scale = gpu.isSpellEffect ? rawScale : std::min(rawScale, 1.5f); float scale = (gpu.isSpellEffect || gpu.isFireflyEffect) ? rawScale : std::min(rawScale, 1.5f);
VkTexture* tex = whiteTexture_.get(); VkTexture* tex = whiteTexture_.get();
if (p.emitterIndex < static_cast<int>(gpu.particleTextures.size())) { if (p.emitterIndex < static_cast<int>(gpu.particleTextures.size())) {

View file

@ -3359,6 +3359,9 @@ bool Renderer::loadTestTerrain(pipeline::AssetManager* assetManager, const std::
if (!m2Renderer) { if (!m2Renderer) {
m2Renderer = std::make_unique<M2Renderer>(); m2Renderer = std::make_unique<M2Renderer>();
m2Renderer->initialize(vkCtx, perFrameSetLayout, assetManager); m2Renderer->initialize(vkCtx, perFrameSetLayout, assetManager);
if (swimEffects) {
swimEffects->setM2Renderer(m2Renderer.get());
}
} }
if (!wmoRenderer) { if (!wmoRenderer) {
wmoRenderer = std::make_unique<WMORenderer>(); wmoRenderer = std::make_unique<WMORenderer>();

View file

@ -2,6 +2,7 @@
#include "rendering/camera.hpp" #include "rendering/camera.hpp"
#include "rendering/camera_controller.hpp" #include "rendering/camera_controller.hpp"
#include "rendering/water_renderer.hpp" #include "rendering/water_renderer.hpp"
#include "rendering/m2_renderer.hpp"
#include "rendering/vk_context.hpp" #include "rendering/vk_context.hpp"
#include "rendering/vk_shader.hpp" #include "rendering/vk_shader.hpp"
#include "rendering/vk_pipeline.hpp" #include "rendering/vk_pipeline.hpp"
@ -152,6 +153,50 @@ bool SwimEffects::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayou
} }
} }
// ---- Insect pipeline (dark point sprites) ----
{
VkShaderModule vertModule;
if (!vertModule.loadFromFile(device, "assets/shaders/swim_ripple.vert.spv")) {
LOG_ERROR("Failed to load insect vertex shader");
return false;
}
VkShaderModule fragModule;
if (!fragModule.loadFromFile(device, "assets/shaders/swim_insect.frag.spv")) {
LOG_ERROR("Failed to load insect fragment shader");
return false;
}
VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT);
VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT);
insectPipelineLayout = createPipelineLayout(device, {perFrameLayout}, {});
if (insectPipelineLayout == VK_NULL_HANDLE) {
LOG_ERROR("Failed to create insect pipeline layout");
return false;
}
insectPipeline = PipelineBuilder()
.setShaders(vertStage, fragStage)
.setVertexInput({binding}, attrs)
.setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST)
.setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE)
.setDepthTest(true, false, VK_COMPARE_OP_LESS)
.setColorBlendAttachment(PipelineBuilder::blendAlpha())
.setMultisample(vkCtx->getMsaaSamples())
.setLayout(insectPipelineLayout)
.setRenderPass(vkCtx->getImGuiRenderPass())
.setDynamicStates(dynamicStates)
.build(device);
vertModule.destroy();
fragModule.destroy();
if (insectPipeline == VK_NULL_HANDLE) {
LOG_ERROR("Failed to create insect pipeline");
return false;
}
}
// ---- Create dynamic mapped vertex buffers ---- // ---- Create dynamic mapped vertex buffers ----
rippleDynamicVBSize = MAX_RIPPLE_PARTICLES * 5 * sizeof(float); rippleDynamicVBSize = MAX_RIPPLE_PARTICLES * 5 * sizeof(float);
{ {
@ -179,10 +224,25 @@ bool SwimEffects::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayou
} }
} }
insectDynamicVBSize = MAX_INSECT_PARTICLES * 5 * sizeof(float);
{
AllocatedBuffer buf = createBuffer(vkCtx->getAllocator(), insectDynamicVBSize,
VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VMA_MEMORY_USAGE_CPU_TO_GPU);
insectDynamicVB = buf.buffer;
insectDynamicVBAlloc = buf.allocation;
insectDynamicVBAllocInfo = buf.info;
if (insectDynamicVB == VK_NULL_HANDLE) {
LOG_ERROR("Failed to create insect dynamic vertex buffer");
return false;
}
}
ripples.reserve(MAX_RIPPLE_PARTICLES); ripples.reserve(MAX_RIPPLE_PARTICLES);
bubbles.reserve(MAX_BUBBLE_PARTICLES); bubbles.reserve(MAX_BUBBLE_PARTICLES);
insects.reserve(MAX_INSECT_PARTICLES);
rippleVertexData.reserve(MAX_RIPPLE_PARTICLES * 5); rippleVertexData.reserve(MAX_RIPPLE_PARTICLES * 5);
bubbleVertexData.reserve(MAX_BUBBLE_PARTICLES * 5); bubbleVertexData.reserve(MAX_BUBBLE_PARTICLES * 5);
insectVertexData.reserve(MAX_INSECT_PARTICLES * 5);
LOG_INFO("Swim effects initialized"); LOG_INFO("Swim effects initialized");
return true; return true;
@ -220,11 +280,26 @@ void SwimEffects::shutdown() {
bubbleDynamicVB = VK_NULL_HANDLE; bubbleDynamicVB = VK_NULL_HANDLE;
bubbleDynamicVBAlloc = VK_NULL_HANDLE; bubbleDynamicVBAlloc = VK_NULL_HANDLE;
} }
if (insectPipeline != VK_NULL_HANDLE) {
vkDestroyPipeline(device, insectPipeline, nullptr);
insectPipeline = VK_NULL_HANDLE;
}
if (insectPipelineLayout != VK_NULL_HANDLE) {
vkDestroyPipelineLayout(device, insectPipelineLayout, nullptr);
insectPipelineLayout = VK_NULL_HANDLE;
}
if (insectDynamicVB != VK_NULL_HANDLE) {
vmaDestroyBuffer(allocator, insectDynamicVB, insectDynamicVBAlloc);
insectDynamicVB = VK_NULL_HANDLE;
insectDynamicVBAlloc = VK_NULL_HANDLE;
}
} }
vkCtx = nullptr; vkCtx = nullptr;
ripples.clear(); ripples.clear();
bubbles.clear(); bubbles.clear();
insects.clear();
} }
void SwimEffects::recreatePipelines() { void SwimEffects::recreatePipelines() {
@ -240,6 +315,10 @@ void SwimEffects::recreatePipelines() {
vkDestroyPipeline(device, bubblePipeline, nullptr); vkDestroyPipeline(device, bubblePipeline, nullptr);
bubblePipeline = VK_NULL_HANDLE; bubblePipeline = VK_NULL_HANDLE;
} }
if (insectPipeline != VK_NULL_HANDLE) {
vkDestroyPipeline(device, insectPipeline, nullptr);
insectPipeline = VK_NULL_HANDLE;
}
// Shared vertex input: pos(vec3) + size(float) + alpha(float) = 5 floats // Shared vertex input: pos(vec3) + size(float) + alpha(float) = 5 floats
VkVertexInputBindingDescription binding{}; VkVertexInputBindingDescription binding{};
@ -319,6 +398,33 @@ void SwimEffects::recreatePipelines() {
vertModule.destroy(); vertModule.destroy();
fragModule.destroy(); fragModule.destroy();
} }
// ---- Rebuild insect pipeline ----
{
VkShaderModule vertModule;
vertModule.loadFromFile(device, "assets/shaders/swim_ripple.vert.spv");
VkShaderModule fragModule;
fragModule.loadFromFile(device, "assets/shaders/swim_insect.frag.spv");
VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT);
VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT);
insectPipeline = PipelineBuilder()
.setShaders(vertStage, fragStage)
.setVertexInput({binding}, attrs)
.setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST)
.setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE)
.setDepthTest(true, false, VK_COMPARE_OP_LESS)
.setColorBlendAttachment(PipelineBuilder::blendAlpha())
.setMultisample(vkCtx->getMsaaSamples())
.setLayout(insectPipelineLayout)
.setRenderPass(vkCtx->getImGuiRenderPass())
.setDynamicStates(dynamicStates)
.build(device);
vertModule.destroy();
fragModule.destroy();
}
} }
void SwimEffects::spawnRipple(const glm::vec3& pos, const glm::vec3& moveDir, float waterH) { void SwimEffects::spawnRipple(const glm::vec3& pos, const glm::vec3& moveDir, float waterH) {
@ -384,6 +490,31 @@ void SwimEffects::spawnBubble(const glm::vec3& pos, float /*waterH*/) {
bubbles.push_back(p); bubbles.push_back(p);
} }
void SwimEffects::spawnInsect(const glm::vec3& vegPos) {
if (static_cast<int>(insects.size()) >= MAX_INSECT_PARTICLES) return;
InsectParticle p;
p.orbitCenter = vegPos;
p.phase = randFloat(0.0f, 6.2832f);
p.orbitRadius = randFloat(0.5f, 2.0f);
p.orbitSpeed = randFloat(1.5f, 4.0f);
p.heightOffset = randFloat(0.5f, 3.0f);
p.lifetime = 0.0f;
p.maxLifetime = randFloat(3.0f, 8.0f);
p.size = randFloat(2.0f, 3.0f);
p.alpha = randFloat(0.6f, 0.9f);
// Start at orbit position
float angle = p.phase;
p.position = vegPos + glm::vec3(
std::cos(angle) * p.orbitRadius,
std::sin(angle) * p.orbitRadius,
p.heightOffset
);
insects.push_back(p);
}
void SwimEffects::update(const Camera& camera, const CameraController& cc, void SwimEffects::update(const Camera& camera, const CameraController& cc,
const WaterRenderer& water, float deltaTime) { const WaterRenderer& water, float deltaTime) {
glm::vec3 camPos = camera.getPosition(); glm::vec3 camPos = camera.getPosition();
@ -438,6 +569,23 @@ void SwimEffects::update(const Camera& camera, const CameraController& cc,
bubbles.clear(); bubbles.clear();
} }
// --- Insect spawning near water vegetation ---
if (m2Renderer) {
auto vegPositions = m2Renderer->getWaterVegetationPositions(camPos, 60.0f);
if (!vegPositions.empty()) {
// Spawn rate: ~4/sec per nearby vegetation cluster (capped by MAX_INSECT_PARTICLES)
float spawnRate = std::min(static_cast<float>(vegPositions.size()) * 4.0f, 20.0f);
insectSpawnAccum += spawnRate * deltaTime;
while (insectSpawnAccum >= 1.0f && static_cast<int>(insects.size()) < MAX_INSECT_PARTICLES) {
// Pick a random vegetation position to spawn near
int idx = static_cast<int>(randFloat(0.0f, static_cast<float>(vegPositions.size()) - 0.01f));
spawnInsect(vegPositions[idx]);
insectSpawnAccum -= 1.0f;
}
if (insectSpawnAccum > 2.0f) insectSpawnAccum = 0.0f;
}
}
// --- Update ripples (splash droplets with gravity) --- // --- Update ripples (splash droplets with gravity) ---
for (int i = static_cast<int>(ripples.size()) - 1; i >= 0; --i) { for (int i = static_cast<int>(ripples.size()) - 1; i >= 0; --i) {
auto& p = ripples[i]; auto& p = ripples[i];
@ -487,6 +635,42 @@ void SwimEffects::update(const Camera& camera, const CameraController& cc,
} }
} }
// --- Update insects (erratic orbiting flight) ---
for (int i = static_cast<int>(insects.size()) - 1; i >= 0; --i) {
auto& p = insects[i];
p.lifetime += deltaTime;
if (p.lifetime >= p.maxLifetime) {
insects[i] = insects.back();
insects.pop_back();
continue;
}
float t = p.lifetime / p.maxLifetime;
float time = p.lifetime * p.orbitSpeed + p.phase;
// Erratic looping: primary orbit + secondary wobble
float primaryAngle = time;
float wobbleAngle = std::sin(time * 2.3f) * 0.8f;
float radius = p.orbitRadius + std::sin(time * 1.7f) * 0.3f;
float heightWobble = std::sin(time * 1.1f + p.phase * 0.5f) * 0.5f;
p.position = p.orbitCenter + glm::vec3(
std::cos(primaryAngle + wobbleAngle) * radius,
std::sin(primaryAngle + wobbleAngle) * radius,
p.heightOffset + heightWobble
);
// Fade in/out
if (t < 0.1f) {
p.alpha = glm::mix(0.0f, 0.8f, t / 0.1f);
} else if (t > 0.85f) {
p.alpha = glm::mix(0.8f, 0.0f, (t - 0.85f) / 0.15f);
} else {
p.alpha = 0.8f;
}
}
// --- Build vertex data --- // --- Build vertex data ---
rippleVertexData.clear(); rippleVertexData.clear();
for (const auto& p : ripples) { for (const auto& p : ripples) {
@ -505,10 +689,19 @@ void SwimEffects::update(const Camera& camera, const CameraController& cc,
bubbleVertexData.push_back(p.size); bubbleVertexData.push_back(p.size);
bubbleVertexData.push_back(p.alpha); bubbleVertexData.push_back(p.alpha);
} }
insectVertexData.clear();
for (const auto& p : insects) {
insectVertexData.push_back(p.position.x);
insectVertexData.push_back(p.position.y);
insectVertexData.push_back(p.position.z);
insectVertexData.push_back(p.size);
insectVertexData.push_back(p.alpha);
}
} }
void SwimEffects::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { void SwimEffects::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) {
if (rippleVertexData.empty() && bubbleVertexData.empty()) return; if (rippleVertexData.empty() && bubbleVertexData.empty() && insectVertexData.empty()) return;
VkDeviceSize offset = 0; VkDeviceSize offset = 0;
@ -539,6 +732,20 @@ void SwimEffects::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) {
vkCmdBindVertexBuffers(cmd, 0, 1, &bubbleDynamicVB, &offset); vkCmdBindVertexBuffers(cmd, 0, 1, &bubbleDynamicVB, &offset);
vkCmdDraw(cmd, static_cast<uint32_t>(bubbleVertexData.size() / 5), 1, 0, 0); vkCmdDraw(cmd, static_cast<uint32_t>(bubbleVertexData.size() / 5), 1, 0, 0);
} }
// --- Render insects ---
if (!insectVertexData.empty() && insectPipeline != VK_NULL_HANDLE) {
VkDeviceSize uploadSize = insectVertexData.size() * sizeof(float);
if (insectDynamicVBAllocInfo.pMappedData) {
std::memcpy(insectDynamicVBAllocInfo.pMappedData, insectVertexData.data(), uploadSize);
}
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, insectPipeline);
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, insectPipelineLayout,
0, 1, &perFrameSet, 0, nullptr);
vkCmdBindVertexBuffers(cmd, 0, 1, &insectDynamicVB, &offset);
vkCmdDraw(cmd, static_cast<uint32_t>(insectVertexData.size() / 5), 1, 0, 0);
}
} }
} // namespace rendering } // namespace rendering