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

@ -89,6 +89,56 @@ void SpellHandler::playSpellImpactSound(uint32_t spellId) {
audio::SpellSoundManager::SpellPower::MEDIUM);
}
// ---- Spell visual effect helpers ----
uint32_t SpellHandler::resolveSpellVisualId(uint32_t spellId) {
owner_.loadSpellNameCache();
auto it = owner_.spellNameCacheRef().find(spellId);
return (it != owner_.spellNameCacheRef().end()) ? it->second.spellVisualId : 0;
}
bool SpellHandler::resolveUnitPosition(uint64_t guid, glm::vec3& outPos) {
auto* renderer = owner_.services().renderer;
if (!renderer) return false;
if (guid == owner_.getPlayerGuid()) {
outPos = renderer->getCharacterPosition();
return true;
}
auto entity = owner_.getEntityManager().getEntity(guid);
if (!entity) return false;
glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ());
outPos = core::coords::canonicalToRender(canonical);
return true;
}
void SpellHandler::triggerCastVisual(uint32_t spellId, uint64_t casterGuid, uint32_t castTimeMs) {
LOG_INFO("SpellVisual: triggerCastVisual spellId=", spellId, " casterGuid=0x", std::hex, casterGuid, std::dec);
auto* renderer = owner_.services().renderer;
if (!renderer) { LOG_WARNING("SpellVisual: triggerCastVisual — no renderer"); return; }
auto* svs = renderer->getSpellVisualSystem();
if (!svs) { LOG_WARNING("SpellVisual: triggerCastVisual — no SpellVisualSystem"); return; }
uint32_t visualId = resolveSpellVisualId(spellId);
if (visualId == 0) { LOG_WARNING("SpellVisual: triggerCastVisual — visualId=0 for spellId=", spellId); return; }
glm::vec3 casterPos;
if (!resolveUnitPosition(casterGuid, casterPos)) { LOG_WARNING("SpellVisual: triggerCastVisual — cannot resolve caster position"); return; }
LOG_INFO("SpellVisual: triggerCastVisual visualId=", visualId, " pos=(", casterPos.x, ",", casterPos.y, ",", casterPos.z, ") castTimeMs=", castTimeMs);
svs->playSpellVisualPrecast(visualId, casterPos, castTimeMs);
}
void SpellHandler::triggerImpactVisual(uint32_t spellId, uint64_t targetGuid) {
LOG_INFO("SpellVisual: triggerImpactVisual spellId=", spellId, " targetGuid=0x", std::hex, targetGuid, std::dec);
auto* renderer = owner_.services().renderer;
if (!renderer) return;
auto* svs = renderer->getSpellVisualSystem();
if (!svs) return;
uint32_t visualId = resolveSpellVisualId(spellId);
if (visualId == 0) { LOG_WARNING("SpellVisual: triggerImpactVisual — visualId=0 for spellId=", spellId); return; }
glm::vec3 targetPos;
if (!resolveUnitPosition(targetGuid, targetPos)) return;
LOG_INFO("SpellVisual: triggerImpactVisual visualId=", visualId, " pos=(", targetPos.x, ",", targetPos.y, ",", targetPos.z, ")");
svs->playSpellVisual(visualId, targetPos, /*useImpactKit=*/true);
}
static std::string displaySpellName(GameHandler& handler, uint32_t spellId) {
if (spellId == 0) return {};
@ -387,6 +437,11 @@ void SpellHandler::cancelCast() {
queuedSpellTarget_ = 0;
if (owner_.addonEventCallbackRef())
owner_.addonEventCallbackRef()("UNIT_SPELLCAST_STOP", {"player"});
// Remove lingering precast visual effects
if (auto* renderer = owner_.services().renderer) {
if (auto* svs = renderer->getSpellVisualSystem())
svs->cancelAllPrecastVisuals();
}
}
void SpellHandler::startCraftQueue(uint32_t spellId, int count) {
@ -828,6 +883,11 @@ void SpellHandler::handleCastFailed(network::Packet& packet) {
owner_.pendingGameObjectInteractGuidRef() = 0;
craftQueueSpellId_ = 0;
craftQueueRemaining_ = 0;
// Remove lingering precast visual effects
if (auto* renderer = owner_.services().renderer) {
if (auto* svs = renderer->getSpellVisualSystem())
svs->cancelAllPrecastVisuals();
}
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
@ -948,6 +1008,12 @@ void SpellHandler::handleSpellStart(network::Packet& packet) {
if (!unitId.empty())
owner_.addonEventCallbackRef()("UNIT_SPELLCAST_START", {unitId, std::to_string(data.spellId)});
}
// Trigger cast visual effect (precast/cast kit M2) at the caster's position.
// Skip profession spells (crafting has no flashy cast effects).
if (!owner_.isProfessionSpell(data.spellId)) {
triggerCastVisual(data.spellId, data.casterUnit, data.castTime);
}
}
void SpellHandler::handleSpellGo(network::Packet& packet) {
@ -1094,6 +1160,29 @@ void SpellHandler::handleSpellGo(network::Packet& packet) {
if (playerIsHit || playerHitEnemy)
playSpellImpactSound(data.spellId);
// Trigger spell visual effects: cast kit at caster + impact kit at each hit target.
// Skip profession spells and melee (schoolMask == 1) abilities.
if (!owner_.isProfessionSpell(data.spellId)) {
uint32_t visualId = resolveSpellVisualId(data.spellId);
if (visualId != 0) {
// Cast-complete visual at caster (for instant spells that skip SPELL_START)
glm::vec3 casterPos;
if (resolveUnitPosition(data.casterUnit, casterPos)) {
if (auto* renderer = owner_.services().renderer) {
if (auto* svs = renderer->getSpellVisualSystem()) {
svs->playSpellVisual(visualId, casterPos, /*useImpactKit=*/false);
}
}
}
// Impact visual at each hit target
for (const auto& tgt : data.hitTargets) {
if (tgt != 0) {
triggerImpactVisual(data.spellId, tgt);
}
}
}
}
}
void SpellHandler::handleSpellCooldown(network::Packet& packet) {
@ -1798,6 +1887,7 @@ void SpellHandler::loadSpellNameCache() const {
const uint32_t ebp1Field = spellL ? spellL->field("EffectBasePoints1") : 0xFFFFFFFF;
const uint32_t ebp2Field = spellL ? spellL->field("EffectBasePoints2") : 0xFFFFFFFF;
const uint32_t durIdxField = spellL ? spellL->field("DurationIndex") : 0xFFFFFFFF;
const uint32_t spellVisualIdField = spellL ? spellL->field("SpellVisualID") : 0xFFFFFFFF;
uint32_t count = dbc->getRecordCount();
for (uint32_t i = 0; i < count; ++i) {
@ -1806,7 +1896,7 @@ void SpellHandler::loadSpellNameCache() const {
std::string name = dbc->getString(i, nameField);
std::string rank = dbc->getString(i, rankField);
if (!name.empty()) {
GameHandler::SpellNameEntry entry{std::move(name), std::move(rank), {}, 0, 0, 0};
GameHandler::SpellNameEntry entry{std::move(name), std::move(rank), {}, 0, 0, 0, {0, 0, 0}, 0.0f, 0};
if (tooltipField != 0xFFFFFFFF) {
entry.description = dbc->getString(i, tooltipField);
}
@ -1830,6 +1920,9 @@ void SpellHandler::loadSpellNameCache() const {
// Duration: read DurationIndex and resolve via SpellDuration.dbc later
if (durIdxField != 0xFFFFFFFF)
entry.durationSec = static_cast<float>(dbc->getUInt32(i, durIdxField)); // store index temporarily
// SpellVisualID: references SpellVisual.dbc for cast/impact M2 effects
if (spellVisualIdField != 0xFFFFFFFF && spellVisualIdField < dbc->getFieldCount())
entry.spellVisualId = dbc->getUInt32(i, spellVisualIdField);
owner_.spellNameCacheRef()[id] = std::move(entry);
}
}
@ -2417,6 +2510,11 @@ void SpellHandler::handleSpellFailure(network::Packet& packet) {
craftQueueRemaining_ = 0;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
// Remove lingering precast visual effects
if (auto* renderer = owner_.services().renderer) {
if (auto* svs = renderer->getSpellVisualSystem())
svs->cancelAllPrecastVisuals();
}
if (auto* ac = owner_.services().audioCoordinator) {
if (auto* ssm = ac->getSpellSoundManager()) {
ssm->stopPrecast();