#include "rendering/m2_renderer.hpp" #include "rendering/m2_renderer_internal.h" #include "rendering/vk_context.hpp" #include "rendering/vk_buffer.hpp" #include "rendering/vk_texture.hpp" #include "rendering/vk_pipeline.hpp" #include "rendering/vk_shader.hpp" #include "rendering/vk_utils.hpp" #include "rendering/camera.hpp" #include "pipeline/asset_manager.hpp" #include "core/logger.hpp" #include #include #include #include #include #include namespace wowee { namespace rendering { // --- M2 Particle Emitter Helpers --- float M2Renderer::interpFloat(const pipeline::M2AnimationTrack& track, float animTime, int seqIdx, const std::vector& /*seqs*/, const std::vector& globalSeqDurations) { if (!track.hasData()) return 0.0f; int si; float t; resolveTrackTime(track, seqIdx, animTime, globalSeqDurations, si, t); if (si < 0 || si >= static_cast(track.sequences.size())) return 0.0f; const auto& keys = track.sequences[si]; if (keys.timestamps.empty() || keys.floatValues.empty()) return 0.0f; if (keys.floatValues.size() == 1) return keys.floatValues[0]; int idx = findKeyframeIndex(keys.timestamps, t); if (idx < 0) return 0.0f; size_t i0 = static_cast(idx); size_t i1 = std::min(i0 + 1, keys.floatValues.size() - 1); if (i0 == i1) return keys.floatValues[i0]; float t0 = static_cast(keys.timestamps[i0]); float t1 = static_cast(keys.timestamps[i1]); float dur = t1 - t0; float frac = (dur > 0.0f) ? glm::clamp((t - t0) / dur, 0.0f, 1.0f) : 0.0f; return glm::mix(keys.floatValues[i0], keys.floatValues[i1], frac); } // Interpolate an M2 FBlock (particle lifetime curve) at a given life ratio [0..1]. // FBlocks store per-lifetime keyframes for particle color, alpha, and scale. // NOTE: interpFBlockFloat and interpFBlockVec3 share identical interpolation logic — // if you fix a bug in one, update the other to match. float M2Renderer::interpFBlockFloat(const pipeline::M2FBlock& fb, float lifeRatio) { if (fb.floatValues.empty()) return 1.0f; if (fb.floatValues.size() == 1 || fb.timestamps.empty()) return fb.floatValues[0]; lifeRatio = glm::clamp(lifeRatio, 0.0f, 1.0f); for (size_t i = 0; i < fb.timestamps.size() - 1; i++) { if (lifeRatio <= fb.timestamps[i + 1]) { float t0 = fb.timestamps[i]; float t1 = fb.timestamps[i + 1]; float dur = t1 - t0; float frac = (dur > 0.0f) ? (lifeRatio - t0) / dur : 0.0f; size_t v0 = std::min(i, fb.floatValues.size() - 1); size_t v1 = std::min(i + 1, fb.floatValues.size() - 1); return glm::mix(fb.floatValues[v0], fb.floatValues[v1], frac); } } return fb.floatValues.back(); } glm::vec3 M2Renderer::interpFBlockVec3(const pipeline::M2FBlock& fb, float lifeRatio) { if (fb.vec3Values.empty()) return glm::vec3(1.0f); if (fb.vec3Values.size() == 1 || fb.timestamps.empty()) return fb.vec3Values[0]; lifeRatio = glm::clamp(lifeRatio, 0.0f, 1.0f); for (size_t i = 0; i < fb.timestamps.size() - 1; i++) { if (lifeRatio <= fb.timestamps[i + 1]) { float t0 = fb.timestamps[i]; float t1 = fb.timestamps[i + 1]; float dur = t1 - t0; float frac = (dur > 0.0f) ? (lifeRatio - t0) / dur : 0.0f; size_t v0 = std::min(i, fb.vec3Values.size() - 1); size_t v1 = std::min(i + 1, fb.vec3Values.size() - 1); return glm::mix(fb.vec3Values[v0], fb.vec3Values[v1], frac); } } return fb.vec3Values.back(); } std::vector M2Renderer::getWaterVegetationPositions(const glm::vec3& camPos, float maxDist) const { std::vector result; float maxDistSq = maxDist * maxDist; for (const auto& inst : instances) { if (!inst.cachedModel || !inst.cachedModel->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) { if (inst.emitterAccumulators.size() != gpu.particleEmitters.size()) { inst.emitterAccumulators.resize(gpu.particleEmitters.size(), 0.0f); } std::uniform_real_distribution dist01(0.0f, 1.0f); std::uniform_real_distribution distN(-1.0f, 1.0f); std::uniform_int_distribution distTile; for (size_t ei = 0; ei < gpu.particleEmitters.size(); ei++) { const auto& em = gpu.particleEmitters[ei]; if (!em.enabled) continue; float rate = interpFloat(em.emissionRate, inst.animTime, inst.currentSequenceIndex, gpu.sequences, gpu.globalSequenceDurations); float life = interpFloat(em.lifespan, inst.animTime, inst.currentSequenceIndex, gpu.sequences, gpu.globalSequenceDurations); if (rate <= 0.0f || life <= 0.0f) continue; inst.emitterAccumulators[ei] += rate * dt; while (inst.emitterAccumulators[ei] >= 1.0f && inst.particles.size() < MAX_M2_PARTICLES) { inst.emitterAccumulators[ei] -= 1.0f; M2Particle p; p.emitterIndex = static_cast(ei); p.life = 0.0f; p.maxLife = life; p.tileIndex = 0.0f; // Position: emitter position transformed by bone matrix glm::vec3 localPos = em.position; glm::mat4 boneXform = glm::mat4(1.0f); if (em.bone < inst.boneMatrices.size()) { boneXform = inst.boneMatrices[em.bone]; } glm::vec3 worldPos = glm::vec3(inst.modelMatrix * boneXform * glm::vec4(localPos, 1.0f)); p.position = worldPos; // Velocity: emission speed in upward direction + random spread float speed = interpFloat(em.emissionSpeed, inst.animTime, inst.currentSequenceIndex, gpu.sequences, gpu.globalSequenceDurations); float vRange = interpFloat(em.verticalRange, inst.animTime, inst.currentSequenceIndex, gpu.sequences, gpu.globalSequenceDurations); float hRange = interpFloat(em.horizontalRange, inst.animTime, inst.currentSequenceIndex, gpu.sequences, gpu.globalSequenceDurations); // Base direction: up in model space, transformed to world glm::vec3 dir(0.0f, 0.0f, 1.0f); // Add random spread dir.x += distN(particleRng_) * hRange; dir.y += distN(particleRng_) * hRange; dir.z += distN(particleRng_) * vRange; float lenSq = glm::dot(dir, dir); if (lenSq > 0.001f * 0.001f) dir *= glm::inversesqrt(lenSq); // Transform direction by bone + model orientation (rotation only) glm::mat3 rotMat = glm::mat3(inst.modelMatrix * boneXform); p.velocity = rotMat * dir * speed; // When emission speed is ~0 and bone animation isn't loaded (.anim files), // particles pile up at the same position. Give them a drift so they // spread outward like a mist/spray effect instead of clustering. if (std::abs(speed) < 0.01f) { if (gpu.isFireflyEffect) { // Fireflies: gentle random drift in all directions p.velocity = rotMat * glm::vec3( 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(em.textureCols, 1); const uint32_t tilesY = std::max(em.textureRows, 1); const uint32_t totalTiles = tilesX * tilesY; if ((em.flags & kParticleFlagTiled) && totalTiles > 1) { if (em.flags & kParticleFlagRandomized) { distTile = std::uniform_int_distribution(0, static_cast(totalTiles - 1)); p.tileIndex = static_cast(distTile(particleRng_)); } else { p.tileIndex = 0.0f; } } inst.particles.push_back(p); // Diagnostic: log first particle birth per spell effect instance if (gpu.isSpellEffect && inst.particles.size() == 1) { LOG_INFO("SpellEffect: first particle for '", gpu.name, "' pos=(", p.position.x, ",", p.position.y, ",", p.position.z, ") rate=", rate, " life=", life, " bone=", em.bone, " boneCount=", inst.boneMatrices.size(), " globalSeqs=", gpu.globalSequenceDurations.size()); } } // Cap accumulator to avoid bursts after lag if (inst.emitterAccumulators[ei] > 2.0f) { inst.emitterAccumulators[ei] = 0.0f; } } } void M2Renderer::updateParticles(M2Instance& inst, float dt) { if (!inst.cachedModel) return; const auto& gpu = *inst.cachedModel; for (size_t i = 0; i < inst.particles.size(); ) { auto& p = inst.particles[i]; p.life += dt; if (p.life >= p.maxLife) { // Swap-and-pop removal inst.particles[i] = inst.particles.back(); inst.particles.pop_back(); continue; } // Apply gravity if (p.emitterIndex >= 0 && p.emitterIndex < static_cast(gpu.particleEmitters.size())) { const auto& pem = gpu.particleEmitters[p.emitterIndex]; float grav = interpFloat(pem.gravity, inst.animTime, inst.currentSequenceIndex, gpu.sequences, gpu.globalSequenceDurations); // 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. // Firefly/ambient glow particles intentionally have zero gravity — skip fallback. if (grav == 0.0f && !gpu.isFireflyEffect) { float emSpeed = interpFloat(pem.emissionSpeed, inst.animTime, inst.currentSequenceIndex, gpu.sequences, gpu.globalSequenceDurations); if (std::abs(emSpeed) > 0.1f) { grav = 4.0f; // spray particles } else { grav = 1.5f; // mist/drift particles - gentler fall } } p.velocity.z -= grav * dt; } p.position += p.velocity * dt; i++; } } // --------------------------------------------------------------------------- // Ribbon emitter simulation // --------------------------------------------------------------------------- void M2Renderer::updateRibbons(M2Instance& inst, const M2ModelGPU& gpu, float dt) { const auto& emitters = gpu.ribbonEmitters; if (emitters.empty()) return; // Grow per-instance state arrays if needed if (inst.ribbonEdges.size() != emitters.size()) { inst.ribbonEdges.resize(emitters.size()); } if (inst.ribbonEdgeAccumulators.size() != emitters.size()) { inst.ribbonEdgeAccumulators.resize(emitters.size(), 0.0f); } for (size_t ri = 0; ri < emitters.size(); ri++) { const auto& em = emitters[ri]; auto& edges = inst.ribbonEdges[ri]; auto& accum = inst.ribbonEdgeAccumulators[ri]; // Determine bone world position for spine glm::vec3 spineWorld = inst.position; // Use referenced bone; fall back to bone 0 if out of range (common for spell effects // where ribbon bone fields may be unset/garbage, e.g. bone=4294967295) uint32_t boneIdx = em.bone; if (boneIdx >= inst.boneMatrices.size() && !inst.boneMatrices.empty()) { boneIdx = 0; } if (boneIdx < inst.boneMatrices.size()) { glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f); spineWorld = glm::vec3(inst.modelMatrix * inst.boneMatrices[boneIdx] * local); } else { glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f); spineWorld = glm::vec3(inst.modelMatrix * local); } // Skip emitters that produce NaN positions (garbage bone/position data) if (std::isnan(spineWorld.x) || std::isnan(spineWorld.y) || std::isnan(spineWorld.z)) continue; // Evaluate animated tracks (use first available sequence key, or fallback value) auto getFloatVal = [&](const pipeline::M2AnimationTrack& track, float fallback) -> float { for (const auto& seq : track.sequences) { if (!seq.floatValues.empty()) return seq.floatValues[0]; } return fallback; }; auto getVec3Val = [&](const pipeline::M2AnimationTrack& track, glm::vec3 fallback) -> glm::vec3 { for (const auto& seq : track.sequences) { if (!seq.vec3Values.empty()) return seq.vec3Values[0]; } return fallback; }; float visibility = getFloatVal(em.visibilityTrack, 1.0f); float heightAbove = getFloatVal(em.heightAboveTrack, 0.5f); float heightBelow = getFloatVal(em.heightBelowTrack, 0.5f); glm::vec3 color = getVec3Val(em.colorTrack, glm::vec3(1.0f)); float alpha = getFloatVal(em.alphaTrack, 1.0f); // Age existing edges and remove expired ones for (auto& e : edges) { e.age += dt; // Apply gravity if (em.gravity != 0.0f) { e.worldPos.z -= em.gravity * dt * dt * 0.5f; } } while (!edges.empty() && edges.front().age >= em.edgeLifetime) { edges.pop_front(); } // Emit new edges based on edgesPerSecond if (visibility > 0.5f) { accum += em.edgesPerSecond * dt; while (accum >= 1.0f) { accum -= 1.0f; M2Instance::RibbonEdge e; e.worldPos = spineWorld; e.color = color; e.alpha = alpha; e.heightAbove = heightAbove; e.heightBelow = heightBelow; e.age = 0.0f; edges.push_back(e); // Diagnostic: log first ribbon edge per spell effect instance+emitter if (gpu.isSpellEffect && edges.size() == 1) { LOG_INFO("SpellEffect: ribbon edge[0] for '", gpu.name, "' emitter=", ri, " pos=(", spineWorld.x, ",", spineWorld.y, ",", spineWorld.z, ") hA=", heightAbove, " hB=", heightBelow, " vis=", visibility, " eps=", em.edgesPerSecond, " edgeLife=", em.edgeLifetime, " bone=", em.bone); } // Cap trail length if (edges.size() > 128) edges.pop_front(); } } else { accum = 0.0f; } } } // --------------------------------------------------------------------------- // Ribbon rendering // --------------------------------------------------------------------------- void M2Renderer::renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { if (!ribbonPipeline_ || !ribbonAdditivePipeline_ || !ribbonVB_ || !ribbonVBMapped_) return; // Build camera right vector for billboard orientation // For ribbons we orient the quad strip along the spine with screen-space up. // Simple approach: use world-space Z=up for the ribbon cross direction. const glm::vec3 upWorld(0.0f, 0.0f, 1.0f); float* dst = static_cast(ribbonVBMapped_); size_t written = 0; ribbonDraws_.clear(); auto& draws = ribbonDraws_; for (const auto& inst : instances) { if (!inst.cachedModel) continue; const auto& gpu = *inst.cachedModel; if (gpu.ribbonEmitters.empty()) continue; for (size_t ri = 0; ri < gpu.ribbonEmitters.size(); ri++) { if (ri >= inst.ribbonEdges.size()) continue; const auto& edges = inst.ribbonEdges[ri]; if (edges.size() < 2) continue; const auto& em = gpu.ribbonEmitters[ri]; // Select blend pipeline based on material blend mode bool additive = false; if (em.materialIndex < gpu.batches.size()) { additive = (gpu.batches[em.materialIndex].blendMode >= 3); } VkPipeline pipe = additive ? ribbonAdditivePipeline_ : ribbonPipeline_; // Descriptor set for texture VkDescriptorSet texSet = (ri < gpu.ribbonTexSets.size()) ? gpu.ribbonTexSets[ri] : VK_NULL_HANDLE; if (!texSet) { if (gpu.isSpellEffect) { static bool ribbonTexWarn = false; if (!ribbonTexWarn) { LOG_WARNING("SpellEffect: ribbon[", ri, "] for '", gpu.name, "' has null texSet — descriptor pool may be exhausted"); ribbonTexWarn = true; } } continue; } uint32_t firstVert = static_cast(written); // Emit triangle strip: 2 verts per edge (top + bottom) for (size_t ei = 0; ei < edges.size(); ei++) { if (written + 2 > MAX_RIBBON_VERTS) break; const auto& e = edges[ei]; float t = (em.edgeLifetime > 0.0f) ? 1.0f - (e.age / em.edgeLifetime) : 1.0f; float a = e.alpha * t; float u = static_cast(ei) / static_cast(edges.size() - 1); // Top vertex (above spine along upWorld) glm::vec3 top = e.worldPos + upWorld * e.heightAbove; dst[written * 9 + 0] = top.x; dst[written * 9 + 1] = top.y; dst[written * 9 + 2] = top.z; dst[written * 9 + 3] = e.color.r; dst[written * 9 + 4] = e.color.g; dst[written * 9 + 5] = e.color.b; dst[written * 9 + 6] = a; dst[written * 9 + 7] = u; dst[written * 9 + 8] = 0.0f; // v = top written++; // Bottom vertex (below spine) glm::vec3 bot = e.worldPos - upWorld * e.heightBelow; dst[written * 9 + 0] = bot.x; dst[written * 9 + 1] = bot.y; dst[written * 9 + 2] = bot.z; dst[written * 9 + 3] = e.color.r; dst[written * 9 + 4] = e.color.g; dst[written * 9 + 5] = e.color.b; dst[written * 9 + 6] = a; dst[written * 9 + 7] = u; dst[written * 9 + 8] = 1.0f; // v = bottom written++; } uint32_t vertCount = static_cast(written) - firstVert; if (vertCount >= 4) { draws.push_back({texSet, pipe, firstVert, vertCount}); } else { // Rollback if too few verts written = firstVert; } } } // Periodic diagnostic: spell ribbon draw count { static uint32_t ribbonDiagFrame_ = 0; if (++ribbonDiagFrame_ % 300 == 1) { size_t spellRibbonDraws = 0; size_t spellRibbonVerts = 0; for (const auto& inst : instances) { if (!inst.cachedModel || !inst.cachedModel->isSpellEffect) continue; for (size_t ri = 0; ri < inst.ribbonEdges.size(); ri++) { if (inst.ribbonEdges[ri].size() >= 2) { spellRibbonDraws++; spellRibbonVerts += inst.ribbonEdges[ri].size() * 2; } } } if (spellRibbonDraws > 0 || !draws.empty()) { LOG_INFO("SpellEffect: ", spellRibbonDraws, " spell ribbon strips (", spellRibbonVerts, " verts), total draws=", draws.size(), " written=", written); } } } if (draws.empty() || written == 0) return; VkExtent2D ext = vkCtx_->getSwapchainExtent(); VkViewport vp{}; vp.x = 0; vp.y = 0; vp.width = static_cast(ext.width); vp.height = static_cast(ext.height); vp.minDepth = 0.0f; vp.maxDepth = 1.0f; VkRect2D sc{}; sc.offset = {0, 0}; sc.extent = ext; vkCmdSetViewport(cmd, 0, 1, &vp); vkCmdSetScissor(cmd, 0, 1, &sc); VkPipeline lastPipe = VK_NULL_HANDLE; for (const auto& dc : draws) { if (dc.pipeline != lastPipe) { vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, dc.pipeline); vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, ribbonPipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); lastPipe = dc.pipeline; } vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, ribbonPipelineLayout_, 1, 1, &dc.texSet, 0, nullptr); VkDeviceSize offset = 0; vkCmdBindVertexBuffers(cmd, 0, 1, &ribbonVB_, &offset); vkCmdDraw(cmd, dc.vertexCount, 1, dc.firstVertex, 0); } } void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { if (!particlePipeline_ || !m2ParticleVB_) return; // Collect all particles from all instances, grouped by texture+blend // Reuse persistent map — clear each group's vertex data but keep bucket structure. for (auto& [k, g] : particleGroups_) { g.vertexData.clear(); g.preAllocSet = VK_NULL_HANDLE; } auto& groups = particleGroups_; size_t totalParticles = 0; for (auto& inst : instances) { if (inst.particles.empty()) continue; if (!inst.cachedModel) continue; const auto& gpu = *inst.cachedModel; for (const auto& p : inst.particles) { if (p.emitterIndex < 0 || p.emitterIndex >= static_cast(gpu.particleEmitters.size())) continue; const auto& em = gpu.particleEmitters[p.emitterIndex]; float lifeRatio = p.life / std::max(p.maxLife, 0.001f); glm::vec3 color = interpFBlockVec3(em.particleColor, lifeRatio); float alpha = std::min(interpFBlockFloat(em.particleAlpha, lifeRatio), 1.0f); float rawScale = interpFBlockFloat(em.particleScale, lifeRatio); if (!gpu.isSpellEffect && !gpu.isFireflyEffect) { color = glm::mix(color, glm::vec3(1.0f), 0.7f); if (rawScale > 2.0f) alpha *= 0.02f; if (em.blendingType == 3 || em.blendingType == 4) alpha *= 0.05f; } // Spell effect particles: mild boost so tiny M2 scales stay visible float scale = rawScale; if (gpu.isSpellEffect) { scale = std::max(rawScale * 1.5f, 0.15f); } else if (!gpu.isFireflyEffect) { scale = std::min(rawScale, 1.5f); } VkTexture* tex = whiteTexture_.get(); if (p.emitterIndex < static_cast(gpu.particleTextures.size())) { tex = gpu.particleTextures[p.emitterIndex]; } uint16_t tilesX = std::max(em.textureCols, 1); uint16_t tilesY = std::max(em.textureRows, 1); uint32_t totalTiles = static_cast(tilesX) * static_cast(tilesY); ParticleGroupKey key{tex, em.blendingType, tilesX, tilesY}; auto& group = groups[key]; group.texture = tex; group.blendType = em.blendingType; group.tilesX = tilesX; group.tilesY = tilesY; // Capture pre-allocated descriptor set on first insertion for this key if (group.preAllocSet == VK_NULL_HANDLE && p.emitterIndex < static_cast(gpu.particleTexSets.size())) { group.preAllocSet = gpu.particleTexSets[p.emitterIndex]; } group.vertexData.push_back(p.position.x); group.vertexData.push_back(p.position.y); group.vertexData.push_back(p.position.z); group.vertexData.push_back(color.r); group.vertexData.push_back(color.g); group.vertexData.push_back(color.b); group.vertexData.push_back(alpha); group.vertexData.push_back(scale); float tileIndex = p.tileIndex; if ((em.flags & kParticleFlagTiled) && totalTiles > 1) { float animSeconds = inst.animTime / 1000.0f; uint32_t animFrame = static_cast(std::floor(animSeconds * totalTiles)) % totalTiles; tileIndex = p.tileIndex + static_cast(animFrame); float tilesFloat = static_cast(totalTiles); // Wrap tile index within totalTiles range while (tileIndex >= tilesFloat) { tileIndex -= tilesFloat; } } group.vertexData.push_back(tileIndex); totalParticles++; } } // Periodic diagnostic: spell effect particle count { static uint32_t spellParticleDiagFrame_ = 0; if (++spellParticleDiagFrame_ % 300 == 1) { size_t spellPtc = 0; for (const auto& inst : instances) { if (inst.cachedModel && inst.cachedModel->isSpellEffect) spellPtc += inst.particles.size(); } if (spellPtc > 0) { LOG_INFO("SpellEffect: rendering ", spellPtc, " spell particles (", totalParticles, " total)"); } } } if (totalParticles == 0) return; // Bind per-frame set (set 0) for particle pipeline vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, particlePipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); VkDeviceSize vbOffset = 0; vkCmdBindVertexBuffers(cmd, 0, 1, &m2ParticleVB_, &vbOffset); VkPipeline currentPipeline = VK_NULL_HANDLE; for (auto& [key, group] : groups) { if (group.vertexData.empty()) continue; uint8_t blendType = group.blendType; VkPipeline desiredPipeline = (blendType == 3 || blendType == 4) ? particleAdditivePipeline_ : particlePipeline_; if (desiredPipeline != currentPipeline) { vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, desiredPipeline); currentPipeline = desiredPipeline; } // Use pre-allocated stable descriptor set; fall back to per-frame alloc only if unavailable VkDescriptorSet texSet = group.preAllocSet; if (texSet == VK_NULL_HANDLE) { // Fallback: allocate per-frame (pool exhaustion risk — should not happen in practice) VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; ai.descriptorPool = materialDescPool_; ai.descriptorSetCount = 1; ai.pSetLayouts = &particleTexLayout_; if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &texSet) == VK_SUCCESS) { VkTexture* tex = group.texture ? group.texture : whiteTexture_.get(); VkDescriptorImageInfo imgInfo = tex->descriptorInfo(); VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; write.dstSet = texSet; write.dstBinding = 0; write.descriptorCount = 1; write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; write.pImageInfo = &imgInfo; vkUpdateDescriptorSets(vkCtx_->getDevice(), 1, &write, 0, nullptr); } } if (texSet != VK_NULL_HANDLE) { vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, particlePipelineLayout_, 1, 1, &texSet, 0, nullptr); } // Push constants: tileCount + alphaKey struct { float tileX, tileY; int alphaKey; } pc = { static_cast(group.tilesX), static_cast(group.tilesY), (blendType == 1) ? 1 : 0 }; vkCmdPushConstants(cmd, particlePipelineLayout_, VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(pc), &pc); // Upload and draw in chunks size_t count = group.vertexData.size() / 9; size_t offset = 0; while (offset < count) { size_t batch = std::min(count - offset, MAX_M2_PARTICLES); memcpy(m2ParticleVBMapped_, &group.vertexData[offset * 9], batch * 9 * sizeof(float)); vkCmdDraw(cmd, static_cast(batch), 1, 0, 0); offset += batch; } } } void M2Renderer::renderSmokeParticles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { if (smokeParticles.empty() || !smokePipeline_ || !smokeVB_) return; // Build vertex data: pos(3) + lifeRatio(1) + size(1) + isSpark(1) per particle size_t count = std::min(smokeParticles.size(), static_cast(MAX_SMOKE_PARTICLES)); float* dst = static_cast(smokeVBMapped_); for (size_t i = 0; i < count; i++) { const auto& p = smokeParticles[i]; *dst++ = p.position.x; *dst++ = p.position.y; *dst++ = p.position.z; *dst++ = p.life / p.maxLife; *dst++ = p.size; *dst++ = p.isSpark; } vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, smokePipeline_); vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, smokePipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); // Push constant: screenHeight float screenHeight = static_cast(vkCtx_->getSwapchainExtent().height); vkCmdPushConstants(cmd, smokePipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(float), &screenHeight); VkDeviceSize offset = 0; vkCmdBindVertexBuffers(cmd, 0, 1, &smokeVB_, &offset); vkCmdDraw(cmd, static_cast(count), 1, 0, 0); } } // namespace rendering } // namespace wowee