feat(rendering): implement spell visual effects with bone-tracked ribbons and particles

Add complete spell visual pipeline resolving the DBC chain
(Spell → SpellVisual → SpellVisualKit → SpellVisualEffectName → M2)
with precast/cast/impact phases, bone-attached positioning, and
automatic dual-hand mirroring.

Ribbon rendering fixes:
- Parse visibility track as uint8 (was read as float, suppressing
  all ribbon edges due to ~1.4e-45 failing the >0.5 check)
- Filter garbage emitters with bone=UINT_MAX unconditionally
- Guard against NaN spine positions from corrupt bone data
- Resolve ribbon textures via direct index, not textureLookup table
- Fall back to bone 0 when ribbon bone index is out of range

Particle rendering fixes:
- Reduce spell particle scale from 5x to 1.5x (was oversized)
- Exempt spell effect instances from position-based deduplication

Spell handler integration:
- Trigger precast visuals on SMSG_SPELL_START with server castTimeMs
- Trigger cast/impact visuals on SMSG_SPELL_GO
- Cancel precast visuals on cast interrupt/failure/movement

M2 classifier expansion:
- Add AmbientEmitterType enum for sound system integration
- Add 20+ foliage tokens, 4 spell effect tokens, isSmallFoliage flag
- Add markModelAsSpellEffect() to override disableAnimation

DBC layouts:
- Add SpellVisualID field to Spell.dbc for all expansion configs

Signed-off-by: Pavel Okhlopkov <pavel.okhlopkov@flant.com>
This commit is contained in:
Pavel Okhlopkov 2026-04-07 11:27:59 +03:00
parent 0a33e3081c
commit b79d9b8fea
18 changed files with 803 additions and 90 deletions

View file

@ -189,6 +189,15 @@ void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt
}
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) {
@ -258,14 +267,24 @@ void M2Renderer::updateRibbons(M2Instance& inst, const M2ModelGPU& gpu, float dt
// Determine bone world position for spine
glm::vec3 spineWorld = inst.position;
if (em.bone < inst.boneMatrices.size()) {
// 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[em.bone] * local);
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) {
@ -311,6 +330,16 @@ void M2Renderer::updateRibbons(M2Instance& inst, const M2ModelGPU& gpu, float dt
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();
}
@ -359,7 +388,17 @@ void M2Renderer::renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSe
// Descriptor set for texture
VkDescriptorSet texSet = (ri < gpu.ribbonTexSets.size())
? gpu.ribbonTexSets[ri] : VK_NULL_HANDLE;
if (!texSet) continue;
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<uint32_t>(written);
@ -409,6 +448,29 @@ void M2Renderer::renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSe
}
}
// 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();
@ -471,7 +533,13 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame
if (rawScale > 2.0f) alpha *= 0.02f;
if (em.blendingType == 3 || em.blendingType == 4) alpha *= 0.05f;
}
float scale = (gpu.isSpellEffect || gpu.isFireflyEffect) ? rawScale : std::min(rawScale, 1.5f);
// 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<int>(gpu.particleTextures.size())) {
@ -517,6 +585,22 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame
}
}
// 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