From 1108aa9ae643194bb6abe4f61cab7dff589f2a98 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 01:17:30 -0700 Subject: [PATCH] feat: implement M2 ribbon emitter rendering for spell trail effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse M2RibbonEmitter data (WotLK format) from M2 files — bone index, position, color/alpha/height tracks, edgesPerSecond, edgeLifetime, gravity. Add CPU-side trail simulation per instance (edge birth at bone world position, lifetime expiry, gravity droop). New m2_ribbon.vert/frag shaders render a triangle-strip quad per emitter using the existing particleTexLayout_ descriptor set. Supports both alpha-blend and additive pipeline variants based on material blend mode. Fixes invisible spell trail effects (~5-10%% of spell visuals) that were silently skipped. --- assets/shaders/m2_ribbon.frag.glsl | 25 ++ assets/shaders/m2_ribbon.frag.spv | Bin 0 -> 1484 bytes assets/shaders/m2_ribbon.vert.glsl | 43 ++++ assets/shaders/m2_ribbon.vert.spv | Bin 0 -> 3032 bytes include/pipeline/m2_loader.hpp | 26 +++ include/rendering/m2_renderer.hpp | 36 +++ src/pipeline/m2_loader.cpp | 119 ++++++++++ src/rendering/m2_renderer.cpp | 354 ++++++++++++++++++++++++++++- src/rendering/renderer.cpp | 2 + 9 files changed, 604 insertions(+), 1 deletion(-) create mode 100644 assets/shaders/m2_ribbon.frag.glsl create mode 100644 assets/shaders/m2_ribbon.frag.spv create mode 100644 assets/shaders/m2_ribbon.vert.glsl create mode 100644 assets/shaders/m2_ribbon.vert.spv diff --git a/assets/shaders/m2_ribbon.frag.glsl b/assets/shaders/m2_ribbon.frag.glsl new file mode 100644 index 00000000..4e0e483e --- /dev/null +++ b/assets/shaders/m2_ribbon.frag.glsl @@ -0,0 +1,25 @@ +#version 450 + +// M2 ribbon emitter fragment shader. +// Samples the ribbon texture, multiplied by vertex color and alpha. +// Uses additive blending (pipeline-level) for magic/spell trails. + +layout(set = 1, binding = 0) uniform sampler2D uTexture; + +layout(location = 0) in vec3 vColor; +layout(location = 1) in float vAlpha; +layout(location = 2) in vec2 vUV; +layout(location = 3) in float vFogFactor; + +layout(location = 0) out vec4 outColor; + +void main() { + vec4 tex = texture(uTexture, vUV); + // For additive ribbons alpha comes from texture luminance; multiply by vertex alpha. + float a = tex.a * vAlpha; + if (a < 0.01) discard; + vec3 rgb = tex.rgb * vColor; + // Ribbons fade slightly with fog (additive blend attenuated toward black = invisible in fog). + rgb *= vFogFactor; + outColor = vec4(rgb, a); +} diff --git a/assets/shaders/m2_ribbon.frag.spv b/assets/shaders/m2_ribbon.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..9b0a3fe900055969d69e1e665c1b5e31b1210568 GIT binary patch literal 1484 zcmYk6+fGwa5QZ1DMFcs?NyI}7QB)L9fOt%Zk=`^x06Fd*pB!QH~Qr_)dd7`^%MTd4IoLc~Pw& z9aWE$&#gwa)oeGa2hDDyo;3Rnd7}foX1m_%9Sjl*Fa~ERaK>rQNE#=hz~2OM?@i+* z>2>KAKyvAv{kQMZcpj{l;`wy;{%-5&BY?a$@b2LU;(U6i{_{?&)3sJe@7Le!9PZWX zNyNNwr?&ph(g(%fZTUCn(7bI*+RS-RM#MAf?S z0?`P*h%LhR9n96N$nPj#Oj|;^F}!upA6zkCrr$UPdG0@xk;EQnxp#JX-W1+`&LV#r zZ!dBA=4v2jeGVUIoF`vMaei3qS-wc@{p6p=yVo=9XRd}~^2IM>V?S1(%rQlg*txFY zt;If9i9J``zBlmpFt@L{Zy^@X@h!~d-^1I>9@fo$8?kM^7w@m;F0o%A1-x9}z;8D0 zRRh1vm^<5AmOq8B){~ezo2%tnOda3T)qS&=9PwwIXn(O?>h?c}iK(SM)M5z}kNE{+ zwfw>!>lZOO;`Ve#vB>v36#E9hh%aGc>i-k|D&F;*^Q^64VrpUh2Q`hw?CCwOVa~aa zO1{PQ3=?+^VQMSi`c2Gta%S^en0v&XSslgV%(sbs$GG2rbaK9C`|lEq$&35NB7c)u xe(bkJtOl<#&9;ft0supRnms)F8(nbcZ zQk5HSc_WG^;1PHPF1ewK|J&JC78m1cPyc@(rl+U1rRk+(&P}N#W-;@ z*udjvLo4*v?V3|O>6&M zNy_eot2a9JX0zVd>~sf%&M5sU4m)ww4?DeR7-g zVe$iYChEY9>Li}~#nIk=+8zYma3@HI(T~MAx|rEFqG6He4jI3m#EC33?pT(uB0f0y zE(-f;Zh2kTjBNQGggBVjP&bmlBJEdmZQNqW}2)CNV!|6Yh@!M{c-33mo$Z z_g8@SSobdY@uYX$9Ynb>;!VB81 zKl9QSg{ch=8$#YC?eqjYb%8JE9C@kZieyTH|0lVPT;NZ&TVC_OE{^_NT{on&@L$o+ zJ$!9tcUL;+gE=3}Y{3x^|45RYeRbFHrZ9_qb?x}tJA#=-ID1!SaVp2;XBM#&-_VZD z&IdD_aLi3Jw^1|xE$zg@lON3NTE1_j6Ki%bvkZ4jzcunOLo8zc7Q;7#T=b0JJN+~~ zSHSd|Z@k&TeB+Cl?>qRu^3ju1lB#4^r*Oy9Ij3#H&E_06n%|k6qu!P>lJl&Dc>J;R z`*@&RP&2kQoxz;GD`B>oU+Q3HFG=9RoO^IOLUo}Anp&T@Vrot(^~<@``O zyyg5zI&qfsW9j(7lZV;8Dq+5i>yjH1&LZ}f1RF8DBiLf@NVk}pbc^{?IyPc%YX_U{E9r2C?@Ona zSNYpNkd6<$`8Svj?b#3s+XefbqRg&ef7W3U-C0H#B&ZlRrL`6EeZbg y8auyVn;m}Z*r|tpe=T_|;eOjWwpyPG@8mXq!(ePp?cYk;65@7q{Etq(Bl#CtSltEy literal 0 HcmV?d00001 diff --git a/include/pipeline/m2_loader.hpp b/include/pipeline/m2_loader.hpp index d3949f88..185ca653 100644 --- a/include/pipeline/m2_loader.hpp +++ b/include/pipeline/m2_loader.hpp @@ -165,6 +165,29 @@ struct M2ParticleEmitter { bool enabled = true; }; +// Ribbon emitter definition parsed from M2 (WotLK format) +struct M2RibbonEmitter { + int32_t ribbonId = 0; + uint32_t bone = 0; // Bone that drives the ribbon spine + glm::vec3 position{0.0f}; // Offset from bone pivot + + uint16_t textureIndex = 0; // First texture lookup index + uint16_t materialIndex = 0; // First material lookup index (blend mode) + + // Animated tracks + M2AnimationTrack colorTrack; // RGB 0..1 + M2AnimationTrack alphaTrack; // float 0..1 (stored as fixed16 on disk) + M2AnimationTrack heightAboveTrack; // Half-width above bone + M2AnimationTrack heightBelowTrack; // Half-width below bone + M2AnimationTrack visibilityTrack; // 0=hidden, 1=visible + + float edgesPerSecond = 15.0f; // How many edge points are generated per second + float edgeLifetime = 0.5f; // Seconds before edges expire + float gravity = 0.0f; // Downward pull on edges per s² + uint16_t textureRows = 1; + uint16_t textureCols = 1; +}; + // Complete M2 model structure struct M2Model { // Model metadata @@ -213,6 +236,9 @@ struct M2Model { // Particle emitters std::vector particleEmitters; + // Ribbon emitters + std::vector ribbonEmitters; + // Collision mesh (simplified geometry for physics) std::vector collisionVertices; std::vector collisionIndices; // 3 per triangle diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 3d79379f..4ddea931 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -130,6 +131,11 @@ struct M2ModelGPU { std::vector particleTextures; // Resolved Vulkan textures per emitter std::vector particleTexSets; // Pre-allocated descriptor sets per emitter (stable, avoids per-frame alloc) + // Ribbon emitter data (kept from M2Model) + std::vector ribbonEmitters; + std::vector ribbonTextures; // Resolved texture per ribbon emitter + std::vector ribbonTexSets; // Descriptor sets per ribbon emitter + // Texture transform data for UV animation std::vector textureTransforms; std::vector textureTransformLookup; @@ -180,6 +186,19 @@ struct M2Instance { std::vector emitterAccumulators; // fractional particle counter per emitter std::vector particles; + // Ribbon emitter state + struct RibbonEdge { + glm::vec3 worldPos; // Spine world position when this edge was born + glm::vec3 color; // Interpolated color at birth + float alpha; // Interpolated alpha at birth + float heightAbove;// Half-width above spine + float heightBelow;// Half-width below spine + float age; // Seconds since spawned + }; + // One deque of edges per ribbon emitter on this instance + std::vector> ribbonEdges; + std::vector ribbonEdgeAccumulators; // fractional edge counter per emitter + // Cached model flags (set at creation to avoid per-frame hash lookups) bool cachedHasAnimation = false; bool cachedDisableAnimation = false; @@ -295,6 +314,11 @@ public: */ void renderSmokeParticles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet); + /** + * Render M2 ribbon emitters (spell trails / wing effects) + */ + void renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSet); + void setInstancePosition(uint32_t instanceId, const glm::vec3& position); void setInstanceTransform(uint32_t instanceId, const glm::mat4& transform); void setInstanceAnimationFrozen(uint32_t instanceId, bool frozen); @@ -374,6 +398,11 @@ private: VkPipeline smokePipeline_ = VK_NULL_HANDLE; // Smoke particles VkPipelineLayout smokePipelineLayout_ = VK_NULL_HANDLE; + // Ribbon pipelines (additive + alpha-blend) + VkPipeline ribbonPipeline_ = VK_NULL_HANDLE; // Alpha-blend ribbons + VkPipeline ribbonAdditivePipeline_ = VK_NULL_HANDLE; // Additive ribbons + VkPipelineLayout ribbonPipelineLayout_ = VK_NULL_HANDLE; + // Descriptor set layouts VkDescriptorSetLayout materialSetLayout_ = VK_NULL_HANDLE; // set 1 VkDescriptorSetLayout boneSetLayout_ = VK_NULL_HANDLE; // set 2 @@ -385,6 +414,12 @@ private: static constexpr uint32_t MAX_MATERIAL_SETS = 8192; static constexpr uint32_t MAX_BONE_SETS = 8192; + // Dynamic ribbon vertex buffer (CPU-written triangle strip) + static constexpr size_t MAX_RIBBON_VERTS = 2048; // 9 floats each + ::VkBuffer ribbonVB_ = VK_NULL_HANDLE; + VmaAllocation ribbonVBAlloc_ = VK_NULL_HANDLE; + void* ribbonVBMapped_ = nullptr; + // Dynamic particle buffers ::VkBuffer smokeVB_ = VK_NULL_HANDLE; VmaAllocation smokeVBAlloc_ = VK_NULL_HANDLE; @@ -535,6 +570,7 @@ private: glm::vec3 interpFBlockVec3(const pipeline::M2FBlock& fb, float lifeRatio); void emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt); void updateParticles(M2Instance& inst, float dt); + void updateRibbons(M2Instance& inst, const M2ModelGPU& gpu, float dt); // Helper to allocate descriptor sets VkDescriptorSet allocateMaterialSet(); diff --git a/src/pipeline/m2_loader.cpp b/src/pipeline/m2_loader.cpp index b3d057d6..b1f82973 100644 --- a/src/pipeline/m2_loader.cpp +++ b/src/pipeline/m2_loader.cpp @@ -1258,6 +1258,125 @@ M2Model M2Loader::load(const std::vector& m2Data) { } // end size check } + // Parse ribbon emitters (WotLK only; vanilla format TBD). + // WotLK M2RibbonEmitter = 0xAC (172) bytes per entry. + static constexpr uint32_t RIBBON_SIZE_WOTLK = 0xAC; + if (header.nRibbonEmitters > 0 && header.ofsRibbonEmitters > 0 && + header.nRibbonEmitters < 64 && header.version >= 264) { + + if (static_cast(header.ofsRibbonEmitters) + + static_cast(header.nRibbonEmitters) * RIBBON_SIZE_WOTLK <= m2Data.size()) { + + // Build sequence flags for parseAnimTrack + std::vector ribSeqFlags; + ribSeqFlags.reserve(model.sequences.size()); + for (const auto& seq : model.sequences) { + ribSeqFlags.push_back(seq.flags); + } + + for (uint32_t ri = 0; ri < header.nRibbonEmitters; ri++) { + uint32_t base = header.ofsRibbonEmitters + ri * RIBBON_SIZE_WOTLK; + + M2RibbonEmitter rib; + rib.ribbonId = readValue(m2Data, base + 0x00); + rib.bone = readValue(m2Data, base + 0x04); + rib.position.x = readValue(m2Data, base + 0x08); + rib.position.y = readValue(m2Data, base + 0x0C); + rib.position.z = readValue(m2Data, base + 0x10); + + // textureIndices M2Array (0x14): count + offset → first element = texture lookup index + { + uint32_t nTex = readValue(m2Data, base + 0x14); + uint32_t ofsTex = readValue(m2Data, base + 0x18); + if (nTex > 0 && ofsTex + sizeof(uint16_t) <= m2Data.size()) { + rib.textureIndex = readValue(m2Data, ofsTex); + } + } + + // materialIndices M2Array (0x1C): count + offset → first element = material index + { + uint32_t nMat = readValue(m2Data, base + 0x1C); + uint32_t ofsMat = readValue(m2Data, base + 0x20); + if (nMat > 0 && ofsMat + sizeof(uint16_t) <= m2Data.size()) { + rib.materialIndex = readValue(m2Data, ofsMat); + } + } + + // colorTrack M2TrackDisk at 0x24 (vec3 RGB 0..1) + if (base + 0x24 + sizeof(M2TrackDisk) <= m2Data.size()) { + M2TrackDisk disk = readValue(m2Data, base + 0x24); + parseAnimTrack(m2Data, disk, rib.colorTrack, TrackType::VEC3, ribSeqFlags); + } + + // alphaTrack M2TrackDisk at 0x38 (fixed16: int16/32767) + // Same nested-array layout as parseAnimTrack but keys are int16. + if (base + 0x38 + sizeof(M2TrackDisk) <= m2Data.size()) { + M2TrackDisk disk = readValue(m2Data, base + 0x38); + auto& track = rib.alphaTrack; + track.interpolationType = disk.interpolationType; + track.globalSequence = disk.globalSequence; + uint32_t nSeqs = disk.nTimestamps; + if (nSeqs > 0 && nSeqs <= 4096) { + track.sequences.resize(nSeqs); + for (uint32_t s = 0; s < nSeqs; s++) { + if (s < ribSeqFlags.size() && !(ribSeqFlags[s] & 0x20)) continue; + uint32_t tsHdr = disk.ofsTimestamps + s * 8; + uint32_t keyHdr = disk.ofsKeys + s * 8; + if (tsHdr + 8 > m2Data.size() || keyHdr + 8 > m2Data.size()) continue; + uint32_t tsCount = readValue(m2Data, tsHdr); + uint32_t tsOfs = readValue(m2Data, tsHdr + 4); + uint32_t kCount = readValue(m2Data, keyHdr); + uint32_t kOfs = readValue(m2Data, keyHdr + 4); + if (tsCount == 0 || kCount == 0) continue; + if (tsOfs + tsCount * 4 > m2Data.size()) continue; + if (kOfs + kCount * sizeof(int16_t) > m2Data.size()) continue; + track.sequences[s].timestamps = readArray(m2Data, tsOfs, tsCount); + auto raw = readArray(m2Data, kOfs, kCount); + track.sequences[s].floatValues.reserve(raw.size()); + for (auto v : raw) { + track.sequences[s].floatValues.push_back( + static_cast(v) / 32767.0f); + } + } + } + } + + // heightAboveTrack M2TrackDisk at 0x4C (float) + if (base + 0x4C + sizeof(M2TrackDisk) <= m2Data.size()) { + M2TrackDisk disk = readValue(m2Data, base + 0x4C); + parseAnimTrack(m2Data, disk, rib.heightAboveTrack, TrackType::FLOAT, ribSeqFlags); + } + + // heightBelowTrack M2TrackDisk at 0x60 (float) + if (base + 0x60 + sizeof(M2TrackDisk) <= m2Data.size()) { + M2TrackDisk disk = readValue(m2Data, base + 0x60); + parseAnimTrack(m2Data, disk, rib.heightBelowTrack, TrackType::FLOAT, ribSeqFlags); + } + + rib.edgesPerSecond = readValue(m2Data, base + 0x74); + rib.edgeLifetime = readValue(m2Data, base + 0x78); + rib.gravity = readValue(m2Data, base + 0x7C); + rib.textureRows = readValue(m2Data, base + 0x80); + rib.textureCols = readValue(m2Data, base + 0x82); + if (rib.textureRows == 0) rib.textureRows = 1; + if (rib.textureCols == 0) rib.textureCols = 1; + + // Clamp to sane values + if (rib.edgesPerSecond < 1.0f || rib.edgesPerSecond > 200.0f) rib.edgesPerSecond = 15.0f; + if (rib.edgeLifetime < 0.05f || rib.edgeLifetime > 10.0f) rib.edgeLifetime = 0.5f; + + // visibilityTrack M2TrackDisk at 0x98 (uint8, treat as float 0/1) + if (base + 0x98 + sizeof(M2TrackDisk) <= m2Data.size()) { + M2TrackDisk disk = readValue(m2Data, base + 0x98); + parseAnimTrack(m2Data, disk, rib.visibilityTrack, TrackType::FLOAT, ribSeqFlags); + } + + model.ribbonEmitters.push_back(std::move(rib)); + } + core::Logger::getInstance().debug(" Ribbon emitters: ", model.ribbonEmitters.size()); + } + } + // Read collision mesh (bounding triangles/vertices/normals) if (header.nBoundingVertices > 0 && header.ofsBoundingVertices > 0) { struct Vec3Disk { float x, y, z; }; diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 96659828..6ef65e5f 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -540,6 +540,54 @@ bool M2Renderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout .build(device); } + // --- Build ribbon pipelines --- + // Vertex format: pos(3) + color(3) + alpha(1) + uv(2) = 9 floats = 36 bytes + { + rendering::VkShaderModule ribVert, ribFrag; + ribVert.loadFromFile(device, "assets/shaders/m2_ribbon.vert.spv"); + ribFrag.loadFromFile(device, "assets/shaders/m2_ribbon.frag.spv"); + if (ribVert.isValid() && ribFrag.isValid()) { + // Reuse particleTexLayout_ for set 1 (single texture sampler) + VkDescriptorSetLayout ribLayouts[] = {perFrameLayout, particleTexLayout_}; + VkPipelineLayoutCreateInfo lci{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO}; + lci.setLayoutCount = 2; + lci.pSetLayouts = ribLayouts; + vkCreatePipelineLayout(device, &lci, nullptr, &ribbonPipelineLayout_); + + VkVertexInputBindingDescription rBind{}; + rBind.binding = 0; + rBind.stride = 9 * sizeof(float); + rBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector rAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // pos + {1, 0, VK_FORMAT_R32G32B32_SFLOAT, 3 * sizeof(float)}, // color + {2, 0, VK_FORMAT_R32_SFLOAT, 6 * sizeof(float)}, // alpha + {3, 0, VK_FORMAT_R32G32_SFLOAT, 7 * sizeof(float)}, // uv + }; + + auto buildRibbonPipeline = [&](VkPipelineColorBlendAttachmentState blend) -> VkPipeline { + return PipelineBuilder() + .setShaders(ribVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + ribFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({rBind}, rAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(blend) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(ribbonPipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + }; + + ribbonPipeline_ = buildRibbonPipeline(PipelineBuilder::blendAlpha()); + ribbonAdditivePipeline_ = buildRibbonPipeline(PipelineBuilder::blendAdditive()); + } + ribVert.destroy(); ribFrag.destroy(); + } + // Clean up shader modules m2Vert.destroy(); m2Frag.destroy(); particleVert.destroy(); particleFrag.destroy(); @@ -570,6 +618,11 @@ bool M2Renderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout bci.size = MAX_GLOW_SPRITES * 9 * sizeof(float); vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, &glowVB_, &glowVBAlloc_, &allocInfo); glowVBMapped_ = allocInfo.pMappedData; + + // Ribbon vertex buffer — triangle strip: pos(3)+color(3)+alpha(1)+uv(2)=9 floats/vert + bci.size = MAX_RIBBON_VERTS * 9 * sizeof(float); + vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, &ribbonVB_, &ribbonVBAlloc_, &allocInfo); + ribbonVBMapped_ = allocInfo.pMappedData; } // --- Create white fallback texture --- @@ -666,10 +719,11 @@ void M2Renderer::shutdown() { whiteTexture_.reset(); glowTexture_.reset(); - // Clean up particle buffers + // Clean up particle/ribbon buffers if (smokeVB_) { vmaDestroyBuffer(alloc, smokeVB_, smokeVBAlloc_); smokeVB_ = VK_NULL_HANDLE; } if (m2ParticleVB_) { vmaDestroyBuffer(alloc, m2ParticleVB_, m2ParticleVBAlloc_); m2ParticleVB_ = VK_NULL_HANDLE; } if (glowVB_) { vmaDestroyBuffer(alloc, glowVB_, glowVBAlloc_); glowVB_ = VK_NULL_HANDLE; } + if (ribbonVB_) { vmaDestroyBuffer(alloc, ribbonVB_, ribbonVBAlloc_); ribbonVB_ = VK_NULL_HANDLE; } smokeParticles.clear(); // Destroy pipelines @@ -681,10 +735,13 @@ void M2Renderer::shutdown() { destroyPipeline(particlePipeline_); destroyPipeline(particleAdditivePipeline_); destroyPipeline(smokePipeline_); + destroyPipeline(ribbonPipeline_); + destroyPipeline(ribbonAdditivePipeline_); if (pipelineLayout_) { vkDestroyPipelineLayout(device, pipelineLayout_, nullptr); pipelineLayout_ = VK_NULL_HANDLE; } if (particlePipelineLayout_) { vkDestroyPipelineLayout(device, particlePipelineLayout_, nullptr); particlePipelineLayout_ = VK_NULL_HANDLE; } if (smokePipelineLayout_) { vkDestroyPipelineLayout(device, smokePipelineLayout_, nullptr); smokePipelineLayout_ = VK_NULL_HANDLE; } + if (ribbonPipelineLayout_) { vkDestroyPipelineLayout(device, ribbonPipelineLayout_, nullptr); ribbonPipelineLayout_ = VK_NULL_HANDLE; } // Destroy descriptor pools and layouts if (materialDescPool_) { vkDestroyDescriptorPool(device, materialDescPool_, nullptr); materialDescPool_ = VK_NULL_HANDLE; } @@ -719,6 +776,11 @@ void M2Renderer::destroyModelGPU(M2ModelGPU& model) { if (pSet) { vkFreeDescriptorSets(device, materialDescPool_, 1, &pSet); pSet = VK_NULL_HANDLE; } } model.particleTexSets.clear(); + // Free ribbon texture descriptor sets + for (auto& rSet : model.ribbonTexSets) { + if (rSet) { vkFreeDescriptorSets(device, materialDescPool_, 1, &rSet); rSet = VK_NULL_HANDLE; } + } + model.ribbonTexSets.clear(); } void M2Renderer::destroyInstanceBones(M2Instance& inst) { @@ -1345,6 +1407,43 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { } } + // Copy ribbon emitter data and resolve textures + gpuModel.ribbonEmitters = model.ribbonEmitters; + if (!model.ribbonEmitters.empty()) { + VkDevice device = vkCtx_->getDevice(); + gpuModel.ribbonTextures.resize(model.ribbonEmitters.size(), whiteTexture_.get()); + gpuModel.ribbonTexSets.resize(model.ribbonEmitters.size(), VK_NULL_HANDLE); + for (size_t ri = 0; ri < model.ribbonEmitters.size(); ri++) { + // Resolve texture via textureLookup table + uint16_t texLookupIdx = model.ribbonEmitters[ri].textureIndex; + uint32_t texIdx = (texLookupIdx < model.textureLookup.size()) + ? model.textureLookup[texLookupIdx] : UINT32_MAX; + if (texIdx < allTextures.size() && allTextures[texIdx] != nullptr) { + gpuModel.ribbonTextures[ri] = allTextures[texIdx]; + } + // Allocate descriptor set (reuse particleTexLayout_ = single sampler) + if (particleTexLayout_ && materialDescPool_) { + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = materialDescPool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &particleTexLayout_; + if (vkAllocateDescriptorSets(device, &ai, &gpuModel.ribbonTexSets[ri]) == VK_SUCCESS) { + VkTexture* tex = gpuModel.ribbonTextures[ri]; + VkDescriptorImageInfo imgInfo = tex->descriptorInfo(); + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = gpuModel.ribbonTexSets[ri]; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + } + } + } + LOG_DEBUG(" Ribbon emitters loaded: ", model.ribbonEmitters.size()); + } + // Copy texture transform data for UV animation gpuModel.textureTransforms = model.textureTransforms; gpuModel.textureTransformLookup = model.textureTransformLookup; @@ -2241,6 +2340,9 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm:: if (!instance.cachedModel) continue; emitParticles(instance, *instance.cachedModel, deltaTime); updateParticles(instance, deltaTime); + if (!instance.cachedModel->ribbonEmitters.empty()) { + updateRibbons(instance, *instance.cachedModel, deltaTime); + } } } @@ -3375,6 +3477,214 @@ void M2Renderer::updateParticles(M2Instance& inst, float dt) { } } +// --------------------------------------------------------------------------- +// 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; + if (em.bone < inst.boneMatrices.size()) { + glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f); + spineWorld = glm::vec3(inst.modelMatrix * inst.boneMatrices[em.bone] * local); + } else { + glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f); + spineWorld = glm::vec3(inst.modelMatrix * local); + } + + // 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); + // 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_ || !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; + + struct DrawCall { + VkDescriptorSet texSet; + VkPipeline pipeline; + uint32_t firstVertex; + uint32_t vertexCount; + }; + std::vector draws; + + 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) 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; + } + } + } + + 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; @@ -4505,6 +4815,8 @@ void M2Renderer::recreatePipelines() { if (particlePipeline_) { vkDestroyPipeline(device, particlePipeline_, nullptr); particlePipeline_ = VK_NULL_HANDLE; } if (particleAdditivePipeline_) { vkDestroyPipeline(device, particleAdditivePipeline_, nullptr); particleAdditivePipeline_ = VK_NULL_HANDLE; } if (smokePipeline_) { vkDestroyPipeline(device, smokePipeline_, nullptr); smokePipeline_ = VK_NULL_HANDLE; } + if (ribbonPipeline_) { vkDestroyPipeline(device, ribbonPipeline_, nullptr); ribbonPipeline_ = VK_NULL_HANDLE; } + if (ribbonAdditivePipeline_) { vkDestroyPipeline(device, ribbonAdditivePipeline_, nullptr); ribbonAdditivePipeline_ = VK_NULL_HANDLE; } // --- Load shaders --- rendering::VkShaderModule m2Vert, m2Frag; @@ -4624,6 +4936,46 @@ void M2Renderer::recreatePipelines() { .build(device); } + // --- Ribbon pipelines --- + { + rendering::VkShaderModule ribVert, ribFrag; + ribVert.loadFromFile(device, "assets/shaders/m2_ribbon.vert.spv"); + ribFrag.loadFromFile(device, "assets/shaders/m2_ribbon.frag.spv"); + if (ribVert.isValid() && ribFrag.isValid()) { + VkVertexInputBindingDescription rBind{}; + rBind.binding = 0; + rBind.stride = 9 * sizeof(float); + rBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector rAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, + {1, 0, VK_FORMAT_R32G32B32_SFLOAT, 3 * sizeof(float)}, + {2, 0, VK_FORMAT_R32_SFLOAT, 6 * sizeof(float)}, + {3, 0, VK_FORMAT_R32G32_SFLOAT, 7 * sizeof(float)}, + }; + + auto buildRibbonPipeline = [&](VkPipelineColorBlendAttachmentState blend) -> VkPipeline { + return PipelineBuilder() + .setShaders(ribVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + ribFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({rBind}, rAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(blend) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(ribbonPipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + }; + + ribbonPipeline_ = buildRibbonPipeline(PipelineBuilder::blendAlpha()); + ribbonAdditivePipeline_ = buildRibbonPipeline(PipelineBuilder::blendAdditive()); + } + ribVert.destroy(); ribFrag.destroy(); + } + m2Vert.destroy(); m2Frag.destroy(); particleVert.destroy(); particleFrag.destroy(); smokeVert.destroy(); smokeFrag.destroy(); diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 67426ff3..05305cc2 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -5159,6 +5159,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { m2Renderer->render(cmd, perFrameSet, *camera); m2Renderer->renderSmokeParticles(cmd, perFrameSet); m2Renderer->renderM2Particles(cmd, perFrameSet); + m2Renderer->renderM2Ribbons(cmd, perFrameSet); vkEndCommandBuffer(cmd); return std::chrono::duration( std::chrono::steady_clock::now() - t0).count(); @@ -5344,6 +5345,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { m2Renderer->render(currentCmd, perFrameSet, *camera); m2Renderer->renderSmokeParticles(currentCmd, perFrameSet); m2Renderer->renderM2Particles(currentCmd, perFrameSet); + m2Renderer->renderM2Ribbons(currentCmd, perFrameSet); lastM2RenderMs = std::chrono::duration( std::chrono::steady_clock::now() - m2Start).count(); }