feat: implement M2 ribbon emitter rendering for spell trail effects

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.
This commit is contained in:
Kelsi 2026-03-13 01:17:30 -07:00
parent 022d387d95
commit 1108aa9ae6
9 changed files with 604 additions and 1 deletions

View file

@ -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);
}

Binary file not shown.

View file

@ -0,0 +1,43 @@
#version 450
// M2 ribbon emitter vertex shader.
// Ribbon geometry is generated CPU-side as a triangle strip.
// Vertex format: pos(3) + color(3) + alpha(1) + uv(2) = 9 floats.
layout(set = 0, binding = 0) uniform PerFrame {
mat4 view;
mat4 projection;
mat4 lightSpaceMatrix;
vec4 lightDir;
vec4 lightColor;
vec4 ambientColor;
vec4 viewPos;
vec4 fogColor;
vec4 fogParams;
vec4 shadowParams;
};
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aColor;
layout(location = 2) in float aAlpha;
layout(location = 3) in vec2 aUV;
layout(location = 0) out vec3 vColor;
layout(location = 1) out float vAlpha;
layout(location = 2) out vec2 vUV;
layout(location = 3) out float vFogFactor;
void main() {
vec4 worldPos = vec4(aPos, 1.0);
vec4 viewPos4 = view * worldPos;
gl_Position = projection * viewPos4;
float dist = length(viewPos4.xyz);
float fogStart = fogParams.x;
float fogEnd = fogParams.y;
vFogFactor = clamp((fogEnd - dist) / max(fogEnd - fogStart, 0.001), 0.0, 1.0);
vColor = aColor;
vAlpha = aAlpha;
vUV = aUV;
}

Binary file not shown.

View file

@ -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<M2ParticleEmitter> particleEmitters;
// Ribbon emitters
std::vector<M2RibbonEmitter> ribbonEmitters;
// Collision mesh (simplified geometry for physics)
std::vector<glm::vec3> collisionVertices;
std::vector<uint16_t> collisionIndices; // 3 per triangle

View file

@ -9,6 +9,7 @@
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include <deque>
#include <string>
#include <optional>
#include <random>
@ -130,6 +131,11 @@ struct M2ModelGPU {
std::vector<VkTexture*> particleTextures; // Resolved Vulkan textures per emitter
std::vector<VkDescriptorSet> particleTexSets; // Pre-allocated descriptor sets per emitter (stable, avoids per-frame alloc)
// Ribbon emitter data (kept from M2Model)
std::vector<pipeline::M2RibbonEmitter> ribbonEmitters;
std::vector<VkTexture*> ribbonTextures; // Resolved texture per ribbon emitter
std::vector<VkDescriptorSet> ribbonTexSets; // Descriptor sets per ribbon emitter
// Texture transform data for UV animation
std::vector<pipeline::M2TextureTransform> textureTransforms;
std::vector<uint16_t> textureTransformLookup;
@ -180,6 +186,19 @@ struct M2Instance {
std::vector<float> emitterAccumulators; // fractional particle counter per emitter
std::vector<M2Particle> 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<std::deque<RibbonEdge>> ribbonEdges;
std::vector<float> 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();

View file

@ -1258,6 +1258,125 @@ M2Model M2Loader::load(const std::vector<uint8_t>& 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<size_t>(header.ofsRibbonEmitters) +
static_cast<size_t>(header.nRibbonEmitters) * RIBBON_SIZE_WOTLK <= m2Data.size()) {
// Build sequence flags for parseAnimTrack
std::vector<uint32_t> 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<int32_t>(m2Data, base + 0x00);
rib.bone = readValue<uint32_t>(m2Data, base + 0x04);
rib.position.x = readValue<float>(m2Data, base + 0x08);
rib.position.y = readValue<float>(m2Data, base + 0x0C);
rib.position.z = readValue<float>(m2Data, base + 0x10);
// textureIndices M2Array (0x14): count + offset → first element = texture lookup index
{
uint32_t nTex = readValue<uint32_t>(m2Data, base + 0x14);
uint32_t ofsTex = readValue<uint32_t>(m2Data, base + 0x18);
if (nTex > 0 && ofsTex + sizeof(uint16_t) <= m2Data.size()) {
rib.textureIndex = readValue<uint16_t>(m2Data, ofsTex);
}
}
// materialIndices M2Array (0x1C): count + offset → first element = material index
{
uint32_t nMat = readValue<uint32_t>(m2Data, base + 0x1C);
uint32_t ofsMat = readValue<uint32_t>(m2Data, base + 0x20);
if (nMat > 0 && ofsMat + sizeof(uint16_t) <= m2Data.size()) {
rib.materialIndex = readValue<uint16_t>(m2Data, ofsMat);
}
}
// colorTrack M2TrackDisk at 0x24 (vec3 RGB 0..1)
if (base + 0x24 + sizeof(M2TrackDisk) <= m2Data.size()) {
M2TrackDisk disk = readValue<M2TrackDisk>(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<M2TrackDisk>(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<uint32_t>(m2Data, tsHdr);
uint32_t tsOfs = readValue<uint32_t>(m2Data, tsHdr + 4);
uint32_t kCount = readValue<uint32_t>(m2Data, keyHdr);
uint32_t kOfs = readValue<uint32_t>(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<uint32_t>(m2Data, tsOfs, tsCount);
auto raw = readArray<int16_t>(m2Data, kOfs, kCount);
track.sequences[s].floatValues.reserve(raw.size());
for (auto v : raw) {
track.sequences[s].floatValues.push_back(
static_cast<float>(v) / 32767.0f);
}
}
}
}
// heightAboveTrack M2TrackDisk at 0x4C (float)
if (base + 0x4C + sizeof(M2TrackDisk) <= m2Data.size()) {
M2TrackDisk disk = readValue<M2TrackDisk>(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<M2TrackDisk>(m2Data, base + 0x60);
parseAnimTrack(m2Data, disk, rib.heightBelowTrack, TrackType::FLOAT, ribSeqFlags);
}
rib.edgesPerSecond = readValue<float>(m2Data, base + 0x74);
rib.edgeLifetime = readValue<float>(m2Data, base + 0x78);
rib.gravity = readValue<float>(m2Data, base + 0x7C);
rib.textureRows = readValue<uint16_t>(m2Data, base + 0x80);
rib.textureCols = readValue<uint16_t>(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<M2TrackDisk>(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; };

View file

@ -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<VkVertexInputAttributeDescription> 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<float*>(ribbonVBMapped_);
size_t written = 0;
struct DrawCall {
VkDescriptorSet texSet;
VkPipeline pipeline;
uint32_t firstVertex;
uint32_t vertexCount;
};
std::vector<DrawCall> 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<uint32_t>(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<float>(ei) / static_cast<float>(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<uint32_t>(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<float>(ext.width);
vp.height = static_cast<float>(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<VkVertexInputAttributeDescription> 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();

View file

@ -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<double, std::milli>(
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<double, std::milli>(
std::chrono::steady_clock::now() - m2Start).count();
}