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

@ -56,19 +56,31 @@ M2ClassificationResult classifyM2Model(
r.isInvisibleTrap = has(n, "invisibletrap");
r.isGroundDetail = has(n, "\\nodxt\\detail\\") || has(n, "\\detail\\");
r.isSmoke = has(n, "smoke");
r.isLavaModel = has(n, "forgelava") || has(n, "lavapot") || has(n, "lavaflow");
r.isLavaModel = has(n, "forgelava") || has(n, "lavapot") || has(n, "lavaflow")
|| has(n, "lavapool");
r.isInstancePortal = has(n, "instanceportal") || has(n, "instancenewportal")
|| has(n, "portalfx") || has(n, "spellportal");
r.isWaterVegetation = has(n, "cattail") || has(n, "reed") || has(n, "bulrush")
|| has(n, "seaweed") || has(n, "kelp") || has(n, "lilypad");
|| has(n, "seaweed") || has(n, "kelp") || has(n, "lilypad")
|| has(n, "waterlily");
r.isWaterfall = has(n, "waterfall");
r.isElvenLike = has(n, "elf") || has(n, "elven") || has(n, "quel");
r.isLanternLike = has(n, "lantern") || has(n, "lamp") || has(n, "light");
r.isKoboldFlame = has(n, "kobold")
&& (has(n, "candle") || has(n, "torch") || has(n, "mine"));
// Fire / brazier / torch model detection (for ambient emitter + rendering)
const bool fireName = has(n, "fire") || has(n, "campfire") || has(n, "bonfire");
const bool brazierName = has(n, "brazier") || has(n, "cauldronfire");
const bool forgeName = has(n, "forge") && !has(n, "forgelava");
const bool torchName = has(n, "torch") && !r.isKoboldFlame;
r.isBrazierOrFire = fireName || brazierName;
r.isTorch = torchName;
// ---------------------------------------------------------------
// Collision: shape categories (mirrors original logic ordering)
// ---------------------------------------------------------------
@ -83,7 +95,11 @@ M2ClassificationResult classifyM2Model(
|| has(n, "seat") || has(n, "throne");
const bool smallSolid = (statueName && !sittable)
|| has(n, "crate") || has(n, "box")
|| has(n, "chest") || has(n, "barrel");
|| has(n, "chest") || has(n, "barrel")
|| has(n, "anvil") || has(n, "mailbox")
|| has(n, "cauldron") || has(n, "cannon")
|| has(n, "wagon") || has(n, "cart")
|| has(n, "table") || has(n, "desk");
const bool chestName = has(n, "chest");
r.collisionSteppedFountain = has(n, "fountain");
@ -106,17 +122,22 @@ M2ClassificationResult classifyM2Model(
// Foliage token table (sorted alphabetically)
// ---------------------------------------------------------------
static constexpr auto kFoliageTokens = std::to_array<std::string_view>({
"algae", "bamboo", "banana", "branch", "bush",
"cactus", "canopy", "cattail", "coconut", "coral",
"corn", "crop", "dead-grass", "dead_grass", "deadgrass",
"algae", "bamboo", "banana", "barley", "bracken",
"branch", "briars", "brush", "bush",
"cactus", "canopy", "cattail", "clover", "coconut",
"coral", "corn", "crop",
"dead-grass", "dead_grass", "deadgrass",
"dry-grass", "dry_grass", "drygrass",
"fern", "fireflies", "firefly", "fireflys",
"flower", "frond", "fungus", "gourd", "grass",
"hay", "hedge", "ivy", "kelp", "leaf",
"leaves", "lily", "melon", "moss", "mushroom",
"palm", "pumpkin", "reed", "root", "seaweed",
"shrub", "squash", "stalk", "thorn", "toadstool",
"vine", "watermelon", "weed", "wheat",
"fern", "fernleaf", "fireflies", "firefly", "fireflys",
"flower", "frond", "fungus", "gourd", "grapes",
"grass",
"hay", "hedge", "hops", "ivy",
"kelp", "leaf", "leaves", "lichen", "lily",
"melon", "moss", "mushroom", "nettle",
"palm", "pinecone", "pumpkin", "reed", "root",
"sapling", "seaweed", "seedling", "shrub", "squash",
"stalk", "thorn", "thistle", "toadstool",
"underbrush", "vine", "watermelon", "weed", "wheat",
});
// "plant" is foliage unless "planter" is also present (planters are solid curbs).
@ -173,20 +194,44 @@ M2ClassificationResult classifyM2Model(
r.shadowWindFoliage = r.isFoliageLike;
r.isFireflyEffect = ambientCreature;
// Small foliage: foliage-like models with a small bounding box.
// Used to skip rendering during taxi/flight for performance.
r.isSmallFoliage = r.isFoliageLike && !treeLike
&& horiz < 3.0f && vert < 2.0f;
// ---------------------------------------------------------------
// Spell effects (named tokens + particle-dominated geometry heuristic)
// ---------------------------------------------------------------
static constexpr auto kEffectTokens = std::to_array<std::string_view>({
"bubbles", "hazardlight", "instancenewportal", "instanceportal",
"bubbles", "dustcloud", "hazardlight",
"instancenewportal", "instanceportal",
"lavabubble", "lavasplash", "lavasteam", "levelup",
"lightshaft", "mageportal", "particleemitter",
"spotlight", "volumetriclight", "wisps", "worldtreeportal",
"smokepuff", "sparkle", "spotlight",
"steam", "volumetriclight", "wisps", "worldtreeportal",
});
r.isSpellEffect = hasAny(n, kEffectTokens)
|| (emitterCount >= 3 && vertexCount <= 200);
// Instance portals are spell effects too.
if (r.isInstancePortal) r.isSpellEffect = true;
// ---------------------------------------------------------------
// Ambient emitter type (for sound system integration)
// ---------------------------------------------------------------
if (r.isBrazierOrFire) {
const bool isSmallFire = has(n, "small") || has(n, "campfire");
r.ambientEmitterType = isSmallFire ? AmbientEmitterType::FireplaceSmall
: AmbientEmitterType::FireplaceLarge;
} else if (r.isTorch) {
r.ambientEmitterType = AmbientEmitterType::Torch;
} else if (forgeName) {
r.ambientEmitterType = AmbientEmitterType::Forge;
} else if (r.collisionSteppedFountain) {
r.ambientEmitterType = AmbientEmitterType::Fountain;
} else if (r.isWaterfall) {
r.ambientEmitterType = AmbientEmitterType::Waterfall;
}
return r;
}
@ -244,5 +289,28 @@ M2BatchTexClassification classifyBatchTexture(const std::string& lowerTexKey)
return r;
}
// ---------------------------------------------------------------------------
// classifyAmbientEmitter — lightweight name-only emitter type detection
// ---------------------------------------------------------------------------
AmbientEmitterType classifyAmbientEmitter(const std::string& lowerName)
{
const bool fireName = has(lowerName, "fire") || has(lowerName, "campfire")
|| has(lowerName, "bonfire");
const bool brazierName = has(lowerName, "brazier") || has(lowerName, "cauldronfire");
const bool forgeName = has(lowerName, "forge") && !has(lowerName, "forgelava");
if (fireName || brazierName) {
const bool isSmall = has(lowerName, "small") || has(lowerName, "campfire");
return isSmall ? AmbientEmitterType::FireplaceSmall
: AmbientEmitterType::FireplaceLarge;
}
if (has(lowerName, "torch")) return AmbientEmitterType::Torch;
if (forgeName) return AmbientEmitterType::Forge;
if (has(lowerName, "fountain")) return AmbientEmitterType::Fountain;
if (has(lowerName, "waterfall")) return AmbientEmitterType::Waterfall;
return AmbientEmitterType::None;
}
} // namespace rendering
} // namespace wowee

View file

@ -1123,6 +1123,20 @@ bool M2Renderer::hasModel(uint32_t modelId) const {
return models.find(modelId) != models.end();
}
void M2Renderer::markModelAsSpellEffect(uint32_t modelId) {
auto it = models.find(modelId);
if (it != models.end()) {
it->second.isSpellEffect = true;
// Spell effects MUST have bone animation for ribbons/particles to work.
// The classifier may have set disableAnimation=true based on name tokens
// (e.g. "chest" in HolySmite_Low_Chest.m2) — override that for spell effects.
if (it->second.disableAnimation && it->second.hasAnimation) {
it->second.disableAnimation = false;
LOG_INFO("SpellEffect: re-enabled animation for '", it->second.name, "'");
}
}
}
bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
if (models.find(modelId) != models.end()) {
// Already loaded
@ -1186,6 +1200,7 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
gpuModel.disableAnimation = cls.disableAnimation;
gpuModel.shadowWindFoliage = cls.shadowWindFoliage;
gpuModel.isFireflyEffect = cls.isFireflyEffect;
gpuModel.isSmallFoliage = cls.isSmallFoliage;
gpuModel.isSmoke = cls.isSmoke;
gpuModel.isSpellEffect = cls.isSpellEffect;
gpuModel.isLavaModel = cls.isLavaModel;
@ -1194,6 +1209,10 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
gpuModel.isElvenLike = cls.isElvenLike;
gpuModel.isLanternLike = cls.isLanternLike;
gpuModel.isKoboldFlame = cls.isKoboldFlame;
gpuModel.isWaterfall = cls.isWaterfall;
gpuModel.isBrazierOrFire = cls.isBrazierOrFire;
gpuModel.isTorch = cls.isTorch;
gpuModel.ambientEmitterType = cls.ambientEmitterType;
gpuModel.boundMin = tightMin;
gpuModel.boundMax = tightMax;
gpuModel.boundRadius = model.boundRadius;
@ -1402,17 +1421,25 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
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];
// Resolve texture: ribbon textureIndex is a direct index into the
// model's texture array (NOT through the textureLookup table).
uint16_t texDirect = model.ribbonEmitters[ri].textureIndex;
if (texDirect < allTextures.size() && allTextures[texDirect] != nullptr) {
gpuModel.ribbonTextures[ri] = allTextures[texDirect];
} else {
LOG_WARNING("M2 '", model.name, "' ribbon emitter[", ri,
"] texLookup=", texLookupIdx, " resolved texIdx=", texIdx,
" out of range (", allTextures.size(),
" textures) — using white fallback");
// Fallback: try through textureLookup table
uint32_t texIdx = (texDirect < model.textureLookup.size())
? model.textureLookup[texDirect] : UINT32_MAX;
if (texIdx < allTextures.size() && allTextures[texIdx] != nullptr) {
gpuModel.ribbonTextures[ri] = allTextures[texIdx];
} else {
LOG_WARNING("M2 '", model.name, "' ribbon emitter[", ri,
"] texIndex=", texDirect, " lookup failed"
" (direct=", (texDirect < allTextures.size() ? "yes" : "OOB"),
" lookup=", texIdx,
" textures=", allTextures.size(),
") — using white fallback");
}
}
// Allocate descriptor set (reuse particleTexLayout_ = single sampler)
if (particleTexLayout_ && materialDescPool_) {

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

View file

@ -46,7 +46,8 @@ uint32_t M2Renderer::createInstance(uint32_t modelId, const glm::vec3& position,
// Deduplicate: skip if same model already at nearly the same position.
// Uses hash map for O(1) lookup instead of O(N) scan.
if (!mdlRef.isGroundDetail) {
// Spell effects are exempt — transient visuals must always create fresh instances.
if (!mdlRef.isGroundDetail && !mdlRef.isSpellEffect) {
DedupKey dk{modelId,
static_cast<int32_t>(std::round(position.x * 10.0f)),
static_cast<int32_t>(std::round(position.y * 10.0f)),
@ -111,7 +112,8 @@ uint32_t M2Renderer::createInstance(uint32_t modelId, const glm::vec3& position,
}
// Register in dedup map before pushing (uses original position, not ground-adjusted)
if (!mdlRef.isGroundDetail) {
// Spell effects are exempt from dedup tracking (transient, overlapping allowed).
if (!mdlRef.isGroundDetail && !mdlRef.isSpellEffect) {
DedupKey dk{modelId,
static_cast<int32_t>(std::round(position.x * 10.0f)),
static_cast<int32_t>(std::round(position.y * 10.0f)),

View file

@ -1936,7 +1936,7 @@ bool Renderer::initializeRenderers(pipeline::AssetManager* assetManager, const s
// Initialize SpellVisualSystem once M2Renderer is available (§4.4)
if (!spellVisualSystem_) {
spellVisualSystem_ = std::make_unique<SpellVisualSystem>();
spellVisualSystem_->initialize(m2Renderer.get());
spellVisualSystem_->initialize(m2Renderer.get(), this);
}
}

View file

@ -1,5 +1,7 @@
#include "rendering/spell_visual_system.hpp"
#include "rendering/m2_renderer.hpp"
#include "rendering/renderer.hpp"
#include "rendering/character_renderer.hpp"
#include "pipeline/asset_manager.hpp"
#include "pipeline/dbc_loader.hpp"
#include "pipeline/dbc_layout.hpp"
@ -11,13 +13,15 @@
namespace wowee {
namespace rendering {
void SpellVisualSystem::initialize(M2Renderer* m2Renderer) {
void SpellVisualSystem::initialize(M2Renderer* m2Renderer, Renderer* renderer) {
m2Renderer_ = m2Renderer;
renderer_ = renderer;
}
void SpellVisualSystem::shutdown() {
reset();
m2Renderer_ = nullptr;
renderer_ = nullptr;
cachedAssetManager_ = nullptr;
}
@ -38,13 +42,26 @@ void SpellVisualSystem::loadSpellVisualDbc() {
const pipeline::DBCFieldMap* fxLayout = layout ? layout->getLayout("SpellVisualEffectName") : nullptr;
uint32_t svCastKitField = svLayout ? (*svLayout)["CastKit"] : 2;
uint32_t svPrecastKitField = svLayout ? (*svLayout)["PrecastKit"] : 1;
uint32_t svImpactKitField = svLayout ? (*svLayout)["ImpactKit"] : 3;
uint32_t svMissileField = svLayout ? (*svLayout)["MissileModel"] : 8;
uint32_t kitSpecial0Field = kitLayout ? (*kitLayout)["SpecialEffect0"] : 11;
uint32_t kitBaseField = kitLayout ? (*kitLayout)["BaseEffect"] : 5;
uint32_t fxFilePathField = fxLayout ? (*fxLayout)["FilePath"] : 2;
// Helper to look up effectName path from a kit ID
// Kit effect fields to probe, in priority order.
// SpecialEffect0 > BaseEffect > LeftHand > RightHand > Chest > Head > Breath
struct KitField { const char* name; uint32_t fallback; };
static constexpr KitField kitFieldDefs[] = {
{"SpecialEffect0", 11}, {"BaseEffect", 5},
{"LeftHandEffect", 6}, {"RightHandEffect", 7},
{"ChestEffect", 4}, {"HeadEffect", 3},
{"BreathEffect", 8}, {"SpecialEffect1", 12},
{"SpecialEffect2", 13},
};
constexpr size_t numKitFields = sizeof(kitFieldDefs) / sizeof(kitFieldDefs[0]);
uint32_t kitFields[numKitFields];
for (size_t k = 0; k < numKitFields; ++k)
kitFields[k] = kitLayout ? kitLayout->field(kitFieldDefs[k].name) : kitFieldDefs[k].fallback;
// Load SpellVisualEffectName.dbc — ID → M2 path
auto fxDbc = cachedAssetManager_->loadDBC("SpellVisualEffectName.dbc");
if (!fxDbc || !fxDbc->isLoaded() || fxDbc->getFieldCount() <= fxFilePathField) {
@ -56,10 +73,22 @@ void SpellVisualSystem::loadSpellVisualDbc() {
for (uint32_t i = 0; i < fxDbc->getRecordCount(); ++i) {
uint32_t id = fxDbc->getUInt32(i, 0);
std::string p = fxDbc->getString(i, fxFilePathField);
if (id && !p.empty()) effectPaths[id] = p;
if (id && !p.empty()) {
// DBC stores old-format extensions (.mdx, .mdl) but extracted assets are .m2
if (p.size() > 4) {
std::string ext = p.substr(p.size() - 4);
// Case-insensitive extension check
for (auto& c : ext) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (ext == ".mdx" || ext == ".mdl") {
p = p.substr(0, p.size() - 4) + ".m2";
}
}
effectPaths[id] = p;
}
}
// Load SpellVisualKit.dbc — kitId → best SpellVisualEffectName ID
// Probes all effect slots in priority order and keeps the first valid hit.
auto kitDbc = cachedAssetManager_->loadDBC("SpellVisualKit.dbc");
std::unordered_map<uint32_t, uint32_t> kitToEffectName; // kitId → effectNameId
if (kitDbc && kitDbc->isLoaded()) {
@ -67,10 +96,11 @@ void SpellVisualSystem::loadSpellVisualDbc() {
for (uint32_t i = 0; i < kitDbc->getRecordCount(); ++i) {
uint32_t kitId = kitDbc->getUInt32(i, 0);
if (!kitId) continue;
// Prefer SpecialEffect0, fall back to BaseEffect
uint32_t eff = 0;
if (kitSpecial0Field < fc) eff = kitDbc->getUInt32(i, kitSpecial0Field);
if (!eff && kitBaseField < fc) eff = kitDbc->getUInt32(i, kitBaseField);
for (size_t k = 0; k < numKitFields && !eff; ++k) {
if (kitFields[k] < fc)
eff = kitDbc->getUInt32(i, kitFields[k]);
}
if (eff) kitToEffectName[kitId] = eff;
}
}
@ -96,11 +126,18 @@ void SpellVisualSystem::loadSpellVisualDbc() {
return;
}
uint32_t svFc = svDbc->getFieldCount();
uint32_t loadedCast = 0, loadedImpact = 0;
uint32_t loadedPrecast = 0, loadedCast = 0, loadedImpact = 0;
for (uint32_t i = 0; i < svDbc->getRecordCount(); ++i) {
uint32_t vid = svDbc->getUInt32(i, 0);
if (!vid) continue;
// Precast path: PrecastKit → SpecialEffect0/BaseEffect
{
std::string path;
if (svPrecastKitField < svFc)
path = kitPath(svDbc->getUInt32(i, svPrecastKitField));
if (!path.empty()) { spellVisualPrecastPath_[vid] = path; ++loadedPrecast; }
}
// Cast path: CastKit → SpecialEffect0/BaseEffect, fallback to MissileModel
{
std::string path;
@ -120,12 +157,211 @@ void SpellVisualSystem::loadSpellVisualDbc() {
if (!path.empty()) { spellVisualImpactPath_[vid] = path; ++loadedImpact; }
}
}
LOG_INFO("SpellVisual: loaded cast=", loadedCast, " impact=", loadedImpact,
" visual→M2 mappings (of ", svDbc->getRecordCount(), " records)");
LOG_INFO("SpellVisual: loaded precast=", loadedPrecast, " cast=", loadedCast, " impact=", loadedImpact,
" visual\u2192M2 mappings (of ", svDbc->getRecordCount(), " records)");
}
// ---------------------------------------------------------------------------
// Classify model path to a character attachment point for bone tracking
// ---------------------------------------------------------------------------
uint32_t SpellVisualSystem::classifyAttachmentId(const std::string& modelPath) {
std::string lower = modelPath;
for (auto& c : lower) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
// "hand" effects track the right hand (attachment 1)
if (lower.find("_hand") != std::string::npos || lower.find("hand_") != std::string::npos)
return 1; // RightHand
// "chest" effects track chest/torso (attachment 5 in M2 spec)
if (lower.find("_chest") != std::string::npos || lower.find("chest_") != std::string::npos)
return 5; // Chest
// "head" effects track head (attachment 11)
if (lower.find("_head") != std::string::npos || lower.find("head_") != std::string::npos)
return 11; // Head
return 0; // No bone tracking (static position or base effect)
}
// ---------------------------------------------------------------------------
// Height offset for spell effect placement (fallback when no bone tracking)
// ---------------------------------------------------------------------------
glm::vec3 SpellVisualSystem::applyEffectHeightOffset(const glm::vec3& basePos, const std::string& modelPath) {
// Lowercase the path for case-insensitive matching
std::string lower = modelPath;
for (auto& c : lower) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
// "hand" effects go at hand height (~0.8m above feet)
if (lower.find("_hand") != std::string::npos || lower.find("hand_") != std::string::npos) {
return basePos + glm::vec3(0.0f, 0.0f, 0.8f);
}
// "chest" effects go at chest height (~1.0m above feet)
if (lower.find("_chest") != std::string::npos || lower.find("chest_") != std::string::npos) {
return basePos + glm::vec3(0.0f, 0.0f, 1.0f);
}
// "head" effects go at head height (~1.6m above feet)
if (lower.find("_head") != std::string::npos || lower.find("head_") != std::string::npos) {
return basePos + glm::vec3(0.0f, 0.0f, 1.6f);
}
// "base" / "feet" / ground effects stay at ground level
return basePos;
}
void SpellVisualSystem::playSpellVisualPrecast(uint32_t visualId, const glm::vec3& worldPosition,
uint32_t castTimeMs) {
LOG_INFO("SpellVisual: playSpellVisualPrecast visualId=", visualId,
" pos=(", worldPosition.x, ",", worldPosition.y, ",", worldPosition.z,
") castTimeMs=", castTimeMs);
if (!m2Renderer_ || visualId == 0) {
LOG_WARNING("SpellVisual: playSpellVisualPrecast early-out: m2Renderer_=", (m2Renderer_ ? "yes" : "null"),
" visualId=", visualId);
return;
}
if (!cachedAssetManager_)
cachedAssetManager_ = core::Application::getInstance().getAssetManager();
if (!cachedAssetManager_) { LOG_WARNING("SpellVisual: no AssetManager"); return; }
if (!spellVisualDbcLoaded_) loadSpellVisualDbc();
// Try precast path first, fall back to cast path
auto pathIt = spellVisualPrecastPath_.find(visualId);
if (pathIt == spellVisualPrecastPath_.end()) {
// No precast kit — fall back to playing cast kit
playSpellVisual(visualId, worldPosition, false);
return;
}
const std::string& modelPath = pathIt->second;
LOG_INFO("SpellVisual: precast path resolved to: ", modelPath);
// Get or assign a model ID for this path
auto midIt = spellVisualModelIds_.find(modelPath);
uint32_t modelId = 0;
if (midIt != spellVisualModelIds_.end()) {
modelId = midIt->second;
} else {
if (nextSpellVisualModelId_ >= 999800) {
LOG_WARNING("SpellVisual: model ID pool exhausted");
return;
}
modelId = nextSpellVisualModelId_++;
spellVisualModelIds_[modelPath] = modelId;
}
if (spellVisualFailedModels_.count(modelId)) {
LOG_WARNING("SpellVisual: precast model in failed-cache, skipping: ", modelPath);
return;
}
if (!m2Renderer_->hasModel(modelId)) {
auto m2Data = cachedAssetManager_->readFile(modelPath);
if (m2Data.empty()) {
LOG_WARNING("SpellVisual: could not read precast model: ", modelPath);
spellVisualFailedModels_.insert(modelId);
// Fall back to cast kit
playSpellVisual(visualId, worldPosition, false);
return;
}
LOG_INFO("SpellVisual: precast M2 data read OK, size=", m2Data.size(), " bytes");
pipeline::M2Model model = pipeline::M2Loader::load(m2Data);
LOG_INFO("SpellVisual: precast M2 parsed: verts=", model.vertices.size(),
" bones=", model.bones.size(), " particles=", model.particleEmitters.size(),
" ribbons=", model.ribbonEmitters.size(),
" globalSeqs=", model.globalSequenceDurations.size(),
" sequences=", model.sequences.size());
if (model.vertices.empty() && model.particleEmitters.empty()) {
LOG_WARNING("SpellVisual: empty precast model: ", modelPath);
spellVisualFailedModels_.insert(modelId);
playSpellVisual(visualId, worldPosition, false);
return;
}
if (model.version >= 264) {
std::string skinPath = modelPath.substr(0, modelPath.rfind('.')) + "00.skin";
auto skinData = cachedAssetManager_->readFile(skinPath);
if (!skinData.empty()) {
pipeline::M2Loader::loadSkin(skinData, model);
LOG_INFO("SpellVisual: loaded skin, indices=", model.indices.size());
}
}
if (!m2Renderer_->loadModel(model, modelId)) {
LOG_WARNING("SpellVisual: failed to load precast model to GPU: ", modelPath);
spellVisualFailedModels_.insert(modelId);
playSpellVisual(visualId, worldPosition, false);
return;
}
m2Renderer_->markModelAsSpellEffect(modelId);
LOG_INFO("SpellVisual: loaded precast model id=", modelId, " path=", modelPath);
}
// Determine attachment point for bone tracking (hand/chest/head → follow character bones)
uint32_t attachId = classifyAttachmentId(modelPath);
glm::vec3 spawnPos = worldPosition;
if (attachId != 0 && renderer_) {
auto* charRenderer = renderer_->getCharacterRenderer();
uint32_t charInstId = renderer_->getCharacterInstanceId();
if (charRenderer && charInstId != 0) {
glm::mat4 attachMat;
if (charRenderer->getAttachmentTransform(charInstId, attachId, attachMat)) {
spawnPos = glm::vec3(attachMat[3]);
} else {
spawnPos = applyEffectHeightOffset(worldPosition, modelPath);
attachId = 0;
}
} else {
spawnPos = applyEffectHeightOffset(worldPosition, modelPath);
attachId = 0;
}
} else {
spawnPos = applyEffectHeightOffset(worldPosition, modelPath);
}
uint32_t instanceId = m2Renderer_->createInstance(modelId,
spawnPos,
glm::vec3(0.0f), 1.0f);
if (instanceId == 0) {
LOG_WARNING("SpellVisual: createInstance returned 0 for precast model=", modelPath);
return;
}
// Duration: prefer server cast time if available (long casts like Hearthstone=10s),
// otherwise fall back to M2 animation duration, then default.
float duration;
if (castTimeMs >= 500) {
// Server cast time available — precast should last the full cast duration
duration = std::clamp(static_cast<float>(castTimeMs) / 1000.0f, 0.5f, 30.0f);
} else {
float animDurMs = m2Renderer_->getInstanceAnimDuration(instanceId);
duration = (animDurMs > 100.0f)
? std::clamp(animDurMs / 1000.0f, 0.5f, SPELL_VISUAL_MAX_DURATION)
: SPELL_VISUAL_DEFAULT_DURATION;
}
activeSpellVisuals_.push_back({instanceId, 0.0f, duration, true, attachId});
LOG_INFO("SpellVisual: spawned precast visualId=", visualId, " instanceId=", instanceId,
" duration=", duration, "s castTimeMs=", castTimeMs, " attach=", attachId,
" model=", modelPath,
" active=", activeSpellVisuals_.size());
// Hand effects: spawn a mirror copy on the left hand (attachment 2)
if (attachId == 1 /* RightHand */) {
glm::vec3 leftPos = worldPosition;
if (renderer_) {
auto* cr = renderer_->getCharacterRenderer();
uint32_t ci = renderer_->getCharacterInstanceId();
if (cr && ci != 0) {
glm::mat4 lm;
if (cr->getAttachmentTransform(ci, 2, lm))
leftPos = glm::vec3(lm[3]);
}
}
uint32_t leftId = m2Renderer_->createInstance(modelId, leftPos, glm::vec3(0.0f), 1.0f);
if (leftId != 0) {
activeSpellVisuals_.push_back({leftId, 0.0f, duration, true, 2 /* LeftHand */});
}
}
}
void SpellVisualSystem::playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition,
bool useImpactKit) {
LOG_INFO("SpellVisual: playSpellVisual visualId=", visualId, " impact=", useImpactKit,
" pos=(", worldPosition.x, ",", worldPosition.y, ",", worldPosition.z, ")");
if (!m2Renderer_ || visualId == 0) return;
if (!cachedAssetManager_)
@ -137,9 +373,13 @@ void SpellVisualSystem::playSpellVisual(uint32_t visualId, const glm::vec3& worl
// Select cast or impact path map
auto& pathMap = useImpactKit ? spellVisualImpactPath_ : spellVisualCastPath_;
auto pathIt = pathMap.find(visualId);
if (pathIt == pathMap.end()) return; // No model for this visual
if (pathIt == pathMap.end()) {
LOG_WARNING("SpellVisual: no ", (useImpactKit ? "impact" : "cast"), " path for visualId=", visualId);
return;
}
const std::string& modelPath = pathIt->second;
LOG_INFO("SpellVisual: ", (useImpactKit ? "impact" : "cast"), " path resolved to: ", modelPath);
// Get or assign a model ID for this path
auto midIt = spellVisualModelIds_.find(modelPath);
@ -156,19 +396,26 @@ void SpellVisualSystem::playSpellVisual(uint32_t visualId, const glm::vec3& worl
}
// Skip models that have previously failed to load (avoid repeated I/O)
if (spellVisualFailedModels_.count(modelId)) return;
if (spellVisualFailedModels_.count(modelId)) {
LOG_WARNING("SpellVisual: model in failed-cache, skipping: ", modelPath);
return;
}
// Load the M2 model if not already loaded
if (!m2Renderer_->hasModel(modelId)) {
auto m2Data = cachedAssetManager_->readFile(modelPath);
if (m2Data.empty()) {
LOG_DEBUG("SpellVisual: could not read model: ", modelPath);
LOG_WARNING("SpellVisual: could not read model: ", modelPath);
spellVisualFailedModels_.insert(modelId);
return;
}
LOG_INFO("SpellVisual: cast/impact M2 data read OK, size=", m2Data.size(), " bytes");
pipeline::M2Model model = pipeline::M2Loader::load(m2Data);
LOG_INFO("SpellVisual: M2 parsed: verts=", model.vertices.size(),
" bones=", model.bones.size(), " particles=", model.particleEmitters.size(),
" ribbons=", model.ribbonEmitters.size());
if (model.vertices.empty() && model.particleEmitters.empty()) {
LOG_DEBUG("SpellVisual: empty model: ", modelPath);
LOG_WARNING("SpellVisual: empty model: ", modelPath);
spellVisualFailedModels_.insert(modelId);
return;
}
@ -183,11 +430,38 @@ void SpellVisualSystem::playSpellVisual(uint32_t visualId, const glm::vec3& worl
spellVisualFailedModels_.insert(modelId);
return;
}
LOG_DEBUG("SpellVisual: loaded model id=", modelId, " path=", modelPath);
m2Renderer_->markModelAsSpellEffect(modelId);
LOG_INFO("SpellVisual: loaded model id=", modelId, " path=", modelPath);
}
// Determine attachment point for bone tracking on cast effects at caster
uint32_t attachId = 0;
if (!useImpactKit) {
attachId = classifyAttachmentId(modelPath);
}
glm::vec3 spawnPos = worldPosition;
if (attachId != 0 && renderer_) {
auto* charRenderer = renderer_->getCharacterRenderer();
uint32_t charInstId = renderer_->getCharacterInstanceId();
if (charRenderer && charInstId != 0) {
glm::mat4 attachMat;
if (charRenderer->getAttachmentTransform(charInstId, attachId, attachMat)) {
spawnPos = glm::vec3(attachMat[3]);
} else {
spawnPos = applyEffectHeightOffset(worldPosition, modelPath);
attachId = 0;
}
} else {
spawnPos = applyEffectHeightOffset(worldPosition, modelPath);
attachId = 0;
}
} else {
spawnPos = applyEffectHeightOffset(worldPosition, modelPath);
}
// Spawn instance at world position
uint32_t instanceId = m2Renderer_->createInstance(modelId, worldPosition,
uint32_t instanceId = m2Renderer_->createInstance(modelId,
spawnPos,
glm::vec3(0.0f), 1.0f);
if (instanceId == 0) {
LOG_WARNING("SpellVisual: failed to create instance for visualId=", visualId);
@ -198,18 +472,62 @@ void SpellVisualSystem::playSpellVisual(uint32_t visualId, const glm::vec3& worl
float duration = (animDurMs > 100.0f)
? std::clamp(animDurMs / 1000.0f, 0.5f, SPELL_VISUAL_MAX_DURATION)
: SPELL_VISUAL_DEFAULT_DURATION;
activeSpellVisuals_.push_back({instanceId, 0.0f, duration});
LOG_DEBUG("SpellVisual: spawned visualId=", visualId, " instanceId=", instanceId,
" duration=", duration, "s model=", modelPath);
activeSpellVisuals_.push_back({instanceId, 0.0f, duration, false, attachId});
LOG_INFO("SpellVisual: spawned ", (useImpactKit ? "impact" : "cast"), " visualId=", visualId,
" instanceId=", instanceId, " duration=", duration, "s animDurMs=", animDurMs,
" attach=", attachId, " model=", modelPath, " active=", activeSpellVisuals_.size());
// Hand effects: spawn a mirror copy on the left hand (attachment 2)
if (attachId == 1 /* RightHand */) {
glm::vec3 leftPos = worldPosition;
if (renderer_) {
auto* cr = renderer_->getCharacterRenderer();
uint32_t ci = renderer_->getCharacterInstanceId();
if (cr && ci != 0) {
glm::mat4 lm;
if (cr->getAttachmentTransform(ci, 2, lm))
leftPos = glm::vec3(lm[3]);
}
}
uint32_t leftId = m2Renderer_->createInstance(modelId, leftPos, glm::vec3(0.0f), 1.0f);
if (leftId != 0) {
activeSpellVisuals_.push_back({leftId, 0.0f, duration, false, 2 /* LeftHand */});
}
}
}
void SpellVisualSystem::update(float deltaTime) {
if (activeSpellVisuals_.empty() || !m2Renderer_) return;
// Get character bone tracking context (once per frame)
CharacterRenderer* charRenderer = renderer_ ? renderer_->getCharacterRenderer() : nullptr;
uint32_t charInstId = renderer_ ? renderer_->getCharacterInstanceId() : 0;
for (auto it = activeSpellVisuals_.begin(); it != activeSpellVisuals_.end(); ) {
it->elapsed += deltaTime;
if (it->elapsed >= it->duration) {
m2Renderer_->removeInstance(it->instanceId);
it = activeSpellVisuals_.erase(it);
} else {
// Update position for bone-tracked effects (follow character hands/chest/head)
if (it->attachmentId != 0 && charRenderer && charInstId != 0) {
glm::mat4 attachMat;
if (charRenderer->getAttachmentTransform(charInstId, it->attachmentId, attachMat)) {
glm::vec3 bonePos = glm::vec3(attachMat[3]);
m2Renderer_->setInstancePosition(it->instanceId, bonePos);
}
}
++it;
}
}
}
void SpellVisualSystem::cancelAllPrecastVisuals() {
if (!m2Renderer_) return;
for (auto it = activeSpellVisuals_.begin(); it != activeSpellVisuals_.end(); ) {
if (it->isPrecast) {
m2Renderer_->removeInstance(it->instanceId);
it = activeSpellVisuals_.erase(it);
} else {
++it;
}

View file

@ -3,6 +3,7 @@
#include "rendering/vk_context.hpp"
#include "rendering/water_renderer.hpp"
#include "rendering/m2_renderer.hpp"
#include "rendering/m2_model_classifier.hpp"
#include "rendering/wmo_renderer.hpp"
#include "rendering/camera.hpp"
#include "audio/ambient_sound_manager.hpp"
@ -691,36 +692,21 @@ std::shared_ptr<PendingTile> TerrainManager::prepareTile(int x, int y) {
doodadLogCount++;
}
if (m2PathLower.find("fire") != std::string::npos ||
m2PathLower.find("brazier") != std::string::npos ||
m2PathLower.find("campfire") != std::string::npos) {
// Fireplace/brazier emitter
auto emitterType = rendering::classifyAmbientEmitter(m2PathLower);
if (emitterType != rendering::AmbientEmitterType::None) {
PendingTile::AmbientEmitter emitter;
emitter.position = worldPos;
if (m2PathLower.find("small") != std::string::npos || m2PathLower.find("campfire") != std::string::npos) {
emitter.type = 0; // FIREPLACE_SMALL
} else {
emitter.type = 1; // FIREPLACE_LARGE
// Map classifier enum to AmbientSoundManager type codes
switch (emitterType) {
case rendering::AmbientEmitterType::FireplaceSmall: emitter.type = 0; break;
case rendering::AmbientEmitterType::FireplaceLarge: emitter.type = 1; break;
case rendering::AmbientEmitterType::Torch: emitter.type = 2; break;
case rendering::AmbientEmitterType::Fountain: emitter.type = 3; break;
case rendering::AmbientEmitterType::Waterfall: emitter.type = 6; break;
case rendering::AmbientEmitterType::Forge: emitter.type = 1; break; // Forge → large fire
default: emitter.type = 0; break;
}
pending->ambientEmitters.push_back(emitter);
} else if (m2PathLower.find("torch") != std::string::npos) {
// Torch emitter
PendingTile::AmbientEmitter emitter;
emitter.position = worldPos;
emitter.type = 2; // TORCH
pending->ambientEmitters.push_back(emitter);
} else if (m2PathLower.find("fountain") != std::string::npos) {
// Fountain emitter
PendingTile::AmbientEmitter emitter;
emitter.position = worldPos;
emitter.type = 3; // FOUNTAIN
pending->ambientEmitters.push_back(emitter);
} else if (m2PathLower.find("waterfall") != std::string::npos) {
// Waterfall emitter
PendingTile::AmbientEmitter emitter;
emitter.position = worldPos;
emitter.type = 6; // WATERFALL
pending->ambientEmitters.push_back(emitter);
}
PendingTile::WMODoodadReady doodadReady;