mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-04-24 20:43:51 +00:00
Merge pull request #58 from ldmonster/feat/add-spells-animation
[feat] rendering: spell visual effects system
This commit is contained in:
commit
b41b3d2c71
18 changed files with 803 additions and 90 deletions
|
|
@ -180,6 +180,7 @@
|
||||||
"RangeIndex": 33,
|
"RangeIndex": 33,
|
||||||
"Rank": 129,
|
"Rank": 129,
|
||||||
"SchoolEnum": 1,
|
"SchoolEnum": 1,
|
||||||
|
"SpellVisualID": 115,
|
||||||
"Tooltip": 147
|
"Tooltip": 147
|
||||||
},
|
},
|
||||||
"SpellIcon": {
|
"SpellIcon": {
|
||||||
|
|
@ -193,7 +194,8 @@
|
||||||
"CastKit": 2,
|
"CastKit": 2,
|
||||||
"ID": 0,
|
"ID": 0,
|
||||||
"ImpactKit": 3,
|
"ImpactKit": 3,
|
||||||
"MissileModel": 8
|
"MissileModel": 8,
|
||||||
|
"PrecastKit": 1
|
||||||
},
|
},
|
||||||
"SpellVisualEffectName": {
|
"SpellVisualEffectName": {
|
||||||
"FilePath": 2,
|
"FilePath": 2,
|
||||||
|
|
@ -201,7 +203,12 @@
|
||||||
},
|
},
|
||||||
"SpellVisualKit": {
|
"SpellVisualKit": {
|
||||||
"BaseEffect": 5,
|
"BaseEffect": 5,
|
||||||
|
"BreathEffect": 8,
|
||||||
|
"ChestEffect": 4,
|
||||||
|
"HeadEffect": 3,
|
||||||
"ID": 0,
|
"ID": 0,
|
||||||
|
"LeftHandEffect": 6,
|
||||||
|
"RightHandEffect": 7,
|
||||||
"SpecialEffect0": 11,
|
"SpecialEffect0": 11,
|
||||||
"SpecialEffect1": 12,
|
"SpecialEffect1": 12,
|
||||||
"SpecialEffect2": 13
|
"SpecialEffect2": 13
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,7 @@
|
||||||
"RangeIndex": 40,
|
"RangeIndex": 40,
|
||||||
"Rank": 136,
|
"Rank": 136,
|
||||||
"SchoolMask": 215,
|
"SchoolMask": 215,
|
||||||
|
"SpellVisualID": 122,
|
||||||
"Tooltip": 154
|
"Tooltip": 154
|
||||||
},
|
},
|
||||||
"SpellIcon": {
|
"SpellIcon": {
|
||||||
|
|
@ -236,7 +237,8 @@
|
||||||
"CastKit": 2,
|
"CastKit": 2,
|
||||||
"ID": 0,
|
"ID": 0,
|
||||||
"ImpactKit": 3,
|
"ImpactKit": 3,
|
||||||
"MissileModel": 8
|
"MissileModel": 8,
|
||||||
|
"PrecastKit": 1
|
||||||
},
|
},
|
||||||
"SpellVisualEffectName": {
|
"SpellVisualEffectName": {
|
||||||
"FilePath": 2,
|
"FilePath": 2,
|
||||||
|
|
@ -244,7 +246,12 @@
|
||||||
},
|
},
|
||||||
"SpellVisualKit": {
|
"SpellVisualKit": {
|
||||||
"BaseEffect": 5,
|
"BaseEffect": 5,
|
||||||
|
"BreathEffect": 8,
|
||||||
|
"ChestEffect": 4,
|
||||||
|
"HeadEffect": 3,
|
||||||
"ID": 0,
|
"ID": 0,
|
||||||
|
"LeftHandEffect": 6,
|
||||||
|
"RightHandEffect": 7,
|
||||||
"SpecialEffect0": 11,
|
"SpecialEffect0": 11,
|
||||||
"SpecialEffect1": 12,
|
"SpecialEffect1": 12,
|
||||||
"SpecialEffect2": 13
|
"SpecialEffect2": 13
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,7 @@
|
||||||
"RangeIndex": 33,
|
"RangeIndex": 33,
|
||||||
"Rank": 129,
|
"Rank": 129,
|
||||||
"SchoolEnum": 1,
|
"SchoolEnum": 1,
|
||||||
|
"SpellVisualID": 115,
|
||||||
"Tooltip": 147
|
"Tooltip": 147
|
||||||
},
|
},
|
||||||
"SpellIcon": {
|
"SpellIcon": {
|
||||||
|
|
@ -230,7 +231,8 @@
|
||||||
"CastKit": 2,
|
"CastKit": 2,
|
||||||
"ID": 0,
|
"ID": 0,
|
||||||
"ImpactKit": 3,
|
"ImpactKit": 3,
|
||||||
"MissileModel": 8
|
"MissileModel": 8,
|
||||||
|
"PrecastKit": 1
|
||||||
},
|
},
|
||||||
"SpellVisualEffectName": {
|
"SpellVisualEffectName": {
|
||||||
"FilePath": 2,
|
"FilePath": 2,
|
||||||
|
|
@ -238,7 +240,12 @@
|
||||||
},
|
},
|
||||||
"SpellVisualKit": {
|
"SpellVisualKit": {
|
||||||
"BaseEffect": 5,
|
"BaseEffect": 5,
|
||||||
|
"BreathEffect": 8,
|
||||||
|
"ChestEffect": 4,
|
||||||
|
"HeadEffect": 3,
|
||||||
"ID": 0,
|
"ID": 0,
|
||||||
|
"LeftHandEffect": 6,
|
||||||
|
"RightHandEffect": 7,
|
||||||
"SpecialEffect0": 11,
|
"SpecialEffect0": 11,
|
||||||
"SpecialEffect1": 12,
|
"SpecialEffect1": 12,
|
||||||
"SpecialEffect2": 13
|
"SpecialEffect2": 13
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,7 @@
|
||||||
"RangeIndex": 49,
|
"RangeIndex": 49,
|
||||||
"Rank": 153,
|
"Rank": 153,
|
||||||
"SchoolMask": 225,
|
"SchoolMask": 225,
|
||||||
|
"SpellVisualID": 131,
|
||||||
"Tooltip": 139
|
"Tooltip": 139
|
||||||
},
|
},
|
||||||
"SpellIcon": {
|
"SpellIcon": {
|
||||||
|
|
@ -252,7 +253,8 @@
|
||||||
"CastKit": 2,
|
"CastKit": 2,
|
||||||
"ID": 0,
|
"ID": 0,
|
||||||
"ImpactKit": 3,
|
"ImpactKit": 3,
|
||||||
"MissileModel": 8
|
"MissileModel": 8,
|
||||||
|
"PrecastKit": 1
|
||||||
},
|
},
|
||||||
"SpellVisualEffectName": {
|
"SpellVisualEffectName": {
|
||||||
"FilePath": 2,
|
"FilePath": 2,
|
||||||
|
|
@ -260,7 +262,12 @@
|
||||||
},
|
},
|
||||||
"SpellVisualKit": {
|
"SpellVisualKit": {
|
||||||
"BaseEffect": 5,
|
"BaseEffect": 5,
|
||||||
|
"BreathEffect": 8,
|
||||||
|
"ChestEffect": 4,
|
||||||
|
"HeadEffect": 3,
|
||||||
"ID": 0,
|
"ID": 0,
|
||||||
|
"LeftHandEffect": 6,
|
||||||
|
"RightHandEffect": 7,
|
||||||
"SpecialEffect0": 11,
|
"SpecialEffect0": 11,
|
||||||
"SpecialEffect1": 12,
|
"SpecialEffect1": 12,
|
||||||
"SpecialEffect2": 13
|
"SpecialEffect2": 13
|
||||||
|
|
|
||||||
|
|
@ -2493,6 +2493,7 @@ public:
|
||||||
uint32_t schoolMask = 0; uint8_t dispelType = 0; uint32_t attrEx = 0;
|
uint32_t schoolMask = 0; uint8_t dispelType = 0; uint32_t attrEx = 0;
|
||||||
int32_t effectBasePoints[3] = {0, 0, 0};
|
int32_t effectBasePoints[3] = {0, 0, 0};
|
||||||
float durationSec = 0.0f;
|
float durationSec = 0.0f;
|
||||||
|
uint32_t spellVisualId = 0;
|
||||||
};
|
};
|
||||||
static constexpr size_t PLAYER_EXPLORED_ZONES_COUNT = 128;
|
static constexpr size_t PLAYER_EXPLORED_ZONES_COUNT = 128;
|
||||||
std::string getAreaName(uint32_t areaId) const;
|
std::string getAreaName(uint32_t areaId) const;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
#include "game/handler_types.hpp"
|
#include "game/handler_types.hpp"
|
||||||
#include "audio/spell_sound_manager.hpp"
|
#include "audio/spell_sound_manager.hpp"
|
||||||
#include "network/packet.hpp"
|
#include "network/packet.hpp"
|
||||||
|
#include <glm/glm.hpp>
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
|
@ -283,6 +284,15 @@ private:
|
||||||
void playSpellCastSound(uint32_t spellId);
|
void playSpellCastSound(uint32_t spellId);
|
||||||
void playSpellImpactSound(uint32_t spellId);
|
void playSpellImpactSound(uint32_t spellId);
|
||||||
|
|
||||||
|
// Resolve SpellVisualID from Spell.dbc cache for a given spellId.
|
||||||
|
uint32_t resolveSpellVisualId(uint32_t spellId);
|
||||||
|
// Resolve render-space position for a unit GUID (player or entity).
|
||||||
|
bool resolveUnitPosition(uint64_t guid, glm::vec3& outPos);
|
||||||
|
// Play the cast/precast visual effect at the caster's position.
|
||||||
|
void triggerCastVisual(uint32_t spellId, uint64_t casterGuid, uint32_t castTimeMs = 0);
|
||||||
|
// Play the impact visual effect at the target's position.
|
||||||
|
void triggerImpactVisual(uint32_t spellId, uint64_t targetGuid);
|
||||||
|
|
||||||
// --- handleSpellLogExecute per-effect parsers (extracted to reduce nesting) ---
|
// --- handleSpellLogExecute per-effect parsers (extracted to reduce nesting) ---
|
||||||
void parseEffectPowerDrain(network::Packet& packet, uint32_t effectLogCount,
|
void parseEffectPowerDrain(network::Packet& packet, uint32_t effectLogCount,
|
||||||
uint64_t caster, uint32_t spellId, bool isPlayerCaster,
|
uint64_t caster, uint32_t spellId, bool isPlayerCaster,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,17 @@
|
||||||
namespace wowee {
|
namespace wowee {
|
||||||
namespace rendering {
|
namespace rendering {
|
||||||
|
|
||||||
|
/// Ambient sound emitter type for doodad models (fire, water, etc.).
|
||||||
|
enum class AmbientEmitterType : uint8_t {
|
||||||
|
None = 0,
|
||||||
|
FireplaceSmall = 1, ///< Small fire / campfire
|
||||||
|
FireplaceLarge = 2, ///< Large brazier / bonfire
|
||||||
|
Torch = 3, ///< Wall torch / standing torch
|
||||||
|
Fountain = 4, ///< Fountain water loop
|
||||||
|
Waterfall = 5, ///< Waterfall ambient
|
||||||
|
Forge = 6, ///< Forge / anvil fire
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Output of classifyM2Model(): all name/geometry-based flags for an M2 model.
|
* Output of classifyM2Model(): all name/geometry-based flags for an M2 model.
|
||||||
* Pure data — no Vulkan, GPU, or asset-manager dependencies.
|
* Pure data — no Vulkan, GPU, or asset-manager dependencies.
|
||||||
|
|
@ -25,6 +36,7 @@ struct M2ClassificationResult {
|
||||||
|
|
||||||
// --- Rendering / effect classification ---
|
// --- Rendering / effect classification ---
|
||||||
bool isFoliageLike = false; ///< Foliage or tree (wind sway, disabled animation)
|
bool isFoliageLike = false; ///< Foliage or tree (wind sway, disabled animation)
|
||||||
|
bool isSmallFoliage = false; ///< Small bush/grass/plant (skip during taxi/flight)
|
||||||
bool isSpellEffect = false; ///< Spell effect / particle-dominated visual
|
bool isSpellEffect = false; ///< Spell effect / particle-dominated visual
|
||||||
bool isLavaModel = false; ///< Lava surface (UV scroll animation)
|
bool isLavaModel = false; ///< Lava surface (UV scroll animation)
|
||||||
bool isInstancePortal = false; ///< Instance portal (additive, spin, no collision)
|
bool isInstancePortal = false; ///< Instance portal (additive, spin, no collision)
|
||||||
|
|
@ -36,6 +48,12 @@ struct M2ClassificationResult {
|
||||||
bool isGroundDetail = false; ///< Ground-clutter detail doodad (always non-blocking)
|
bool isGroundDetail = false; ///< Ground-clutter detail doodad (always non-blocking)
|
||||||
bool isInvisibleTrap = false; ///< Event-object invisible trap (no render, no collision)
|
bool isInvisibleTrap = false; ///< Event-object invisible trap (no render, no collision)
|
||||||
bool isSmoke = false; ///< Smoke model (UV scroll animation)
|
bool isSmoke = false; ///< Smoke model (UV scroll animation)
|
||||||
|
bool isWaterfall = false; ///< Waterfall model (ambient sound + splash particles)
|
||||||
|
bool isBrazierOrFire = false; ///< Brazier / campfire / bonfire model
|
||||||
|
bool isTorch = false; ///< Wall-mounted or standing torch
|
||||||
|
|
||||||
|
// --- Ambient emitter type (for sound system) ---
|
||||||
|
AmbientEmitterType ambientEmitterType = AmbientEmitterType::None;
|
||||||
|
|
||||||
// --- Animation flags ---
|
// --- Animation flags ---
|
||||||
bool disableAnimation = false; ///< Keep visually stable (foliage, chest lids, etc.)
|
bool disableAnimation = false; ///< Keep visually stable (foliage, chest lids, etc.)
|
||||||
|
|
@ -89,5 +107,18 @@ struct M2BatchTexClassification {
|
||||||
*/
|
*/
|
||||||
M2BatchTexClassification classifyBatchTexture(const std::string& lowerTexKey);
|
M2BatchTexClassification classifyBatchTexture(const std::string& lowerTexKey);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lightweight ambient emitter classification (name-only, no geometry needed)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify an M2 model path for ambient sound emitter type.
|
||||||
|
* Faster than the full classifyM2Model() when only the emitter type is needed.
|
||||||
|
*
|
||||||
|
* @param lowerName Lowercased model path/name
|
||||||
|
* @return AmbientEmitterType::None if the model is not an ambient emitter source
|
||||||
|
*/
|
||||||
|
AmbientEmitterType classifyAmbientEmitter(const std::string& lowerName);
|
||||||
|
|
||||||
} // namespace rendering
|
} // namespace rendering
|
||||||
} // namespace wowee
|
} // namespace wowee
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
#include "pipeline/m2_loader.hpp"
|
#include "pipeline/m2_loader.hpp"
|
||||||
#include "pipeline/blp_loader.hpp"
|
#include "pipeline/blp_loader.hpp"
|
||||||
|
#include "rendering/m2_model_classifier.hpp"
|
||||||
#include <vulkan/vulkan.h>
|
#include <vulkan/vulkan.h>
|
||||||
#include <vk_mem_alloc.h>
|
#include <vk_mem_alloc.h>
|
||||||
#include <glm/glm.hpp>
|
#include <glm/glm.hpp>
|
||||||
|
|
@ -78,11 +79,15 @@ struct M2ModelGPU {
|
||||||
bool collisionTreeTrunk = false;
|
bool collisionTreeTrunk = false;
|
||||||
bool collisionNoBlock = false;
|
bool collisionNoBlock = false;
|
||||||
bool collisionStatue = false;
|
bool collisionStatue = false;
|
||||||
bool isSmallFoliage = false; // Small foliage (bushes, grass, plants) - skip during taxi
|
bool isSmallFoliage = false; // Small foliage (bushes, grass, plants) - skip during taxi
|
||||||
bool isInvisibleTrap = false; // Invisible trap objects (don't render, no collision)
|
bool isInvisibleTrap = false; // Invisible trap objects (don't render, no collision)
|
||||||
bool isGroundDetail = false; // Ground clutter/detail doodads (special fallback render path)
|
bool isGroundDetail = false; // Ground clutter/detail doodads (special fallback render path)
|
||||||
bool isWaterVegetation = false; // Cattails, reeds, kelp etc. near water (insect spawning)
|
bool isWaterVegetation = false; // Cattails, reeds, kelp etc. near water (insect spawning)
|
||||||
bool isFireflyEffect = false; // Firefly/fireflies M2 (exempt from particle dampeners)
|
bool isFireflyEffect = false; // Firefly/fireflies M2 (exempt from particle dampeners)
|
||||||
|
bool isWaterfall = false; // Waterfall model (ambient sound + splash particles)
|
||||||
|
bool isBrazierOrFire = false; // Brazier / campfire / bonfire model
|
||||||
|
bool isTorch = false; // Wall-mounted or standing torch
|
||||||
|
AmbientEmitterType ambientEmitterType = AmbientEmitterType::None;
|
||||||
|
|
||||||
// Collision mesh with spatial grid (from M2 bounding geometry)
|
// Collision mesh with spatial grid (from M2 bounding geometry)
|
||||||
struct CollisionMesh {
|
struct CollisionMesh {
|
||||||
|
|
@ -282,6 +287,8 @@ public:
|
||||||
|
|
||||||
bool hasModel(uint32_t modelId) const;
|
bool hasModel(uint32_t modelId) const;
|
||||||
bool loadModel(const pipeline::M2Model& model, uint32_t modelId);
|
bool loadModel(const pipeline::M2Model& model, uint32_t modelId);
|
||||||
|
/** Mark a loaded model as a spell effect (full-brightness particles, no collision). */
|
||||||
|
void markModelAsSpellEffect(uint32_t modelId);
|
||||||
|
|
||||||
uint32_t createInstance(uint32_t modelId, const glm::vec3& position,
|
uint32_t createInstance(uint32_t modelId, const glm::vec3& position,
|
||||||
const glm::vec3& rotation = glm::vec3(0.0f),
|
const glm::vec3& rotation = glm::vec3(0.0f),
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,16 @@ namespace pipeline { class AssetManager; }
|
||||||
namespace rendering {
|
namespace rendering {
|
||||||
|
|
||||||
class M2Renderer;
|
class M2Renderer;
|
||||||
|
class Renderer;
|
||||||
|
class CharacterRenderer;
|
||||||
|
|
||||||
class SpellVisualSystem {
|
class SpellVisualSystem {
|
||||||
public:
|
public:
|
||||||
SpellVisualSystem() = default;
|
SpellVisualSystem() = default;
|
||||||
~SpellVisualSystem() = default;
|
~SpellVisualSystem() = default;
|
||||||
|
|
||||||
// Initialize with references to the M2 renderer (for model loading/instance spawning)
|
// Initialize with references to the M2 renderer and parent renderer
|
||||||
void initialize(M2Renderer* m2Renderer);
|
void initialize(M2Renderer* m2Renderer, Renderer* renderer);
|
||||||
void shutdown();
|
void shutdown();
|
||||||
|
|
||||||
// Spawn a spell visual at a world position.
|
// Spawn a spell visual at a world position.
|
||||||
|
|
@ -27,9 +29,17 @@ public:
|
||||||
void playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition,
|
void playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition,
|
||||||
bool useImpactKit = false);
|
bool useImpactKit = false);
|
||||||
|
|
||||||
|
// Spawn a precast visual effect at a world position.
|
||||||
|
// castTimeMs: server cast time in milliseconds (0 = use anim duration).
|
||||||
|
void playSpellVisualPrecast(uint32_t visualId, const glm::vec3& worldPosition,
|
||||||
|
uint32_t castTimeMs = 0);
|
||||||
|
|
||||||
// Advance lifetime timers and remove expired instances.
|
// Advance lifetime timers and remove expired instances.
|
||||||
void update(float deltaTime);
|
void update(float deltaTime);
|
||||||
|
|
||||||
|
// Remove all active precast visual instances (cast canceled/interrupted).
|
||||||
|
void cancelAllPrecastVisuals();
|
||||||
|
|
||||||
// Remove all active spell visual instances and reset caches.
|
// Remove all active spell visual instances and reset caches.
|
||||||
// Called on map change / combat reset.
|
// Called on map change / combat reset.
|
||||||
void reset();
|
void reset();
|
||||||
|
|
@ -40,14 +50,18 @@ private:
|
||||||
uint32_t instanceId;
|
uint32_t instanceId;
|
||||||
float elapsed;
|
float elapsed;
|
||||||
float duration; // per-instance lifetime in seconds (from M2 anim or default)
|
float duration; // per-instance lifetime in seconds (from M2 anim or default)
|
||||||
|
bool isPrecast; // true for precast effects (removed on cancel/interrupt)
|
||||||
|
uint32_t attachmentId; // character attachment point to track (0=none/static)
|
||||||
};
|
};
|
||||||
|
|
||||||
void loadSpellVisualDbc();
|
void loadSpellVisualDbc();
|
||||||
|
|
||||||
M2Renderer* m2Renderer_ = nullptr;
|
M2Renderer* m2Renderer_ = nullptr;
|
||||||
|
Renderer* renderer_ = nullptr;
|
||||||
pipeline::AssetManager* cachedAssetManager_ = nullptr;
|
pipeline::AssetManager* cachedAssetManager_ = nullptr;
|
||||||
|
|
||||||
std::vector<SpellVisualInstance> activeSpellVisuals_;
|
std::vector<SpellVisualInstance> activeSpellVisuals_;
|
||||||
|
std::unordered_map<uint32_t, std::string> spellVisualPrecastPath_; // visualId → precast M2 path
|
||||||
std::unordered_map<uint32_t, std::string> spellVisualCastPath_; // visualId → cast M2 path
|
std::unordered_map<uint32_t, std::string> spellVisualCastPath_; // visualId → cast M2 path
|
||||||
std::unordered_map<uint32_t, std::string> spellVisualImpactPath_; // visualId → impact M2 path
|
std::unordered_map<uint32_t, std::string> spellVisualImpactPath_; // visualId → impact M2 path
|
||||||
std::unordered_map<std::string, uint32_t> spellVisualModelIds_; // M2 path → M2Renderer modelId
|
std::unordered_map<std::string, uint32_t> spellVisualModelIds_; // M2 path → M2Renderer modelId
|
||||||
|
|
@ -56,6 +70,12 @@ private:
|
||||||
bool spellVisualDbcLoaded_ = false;
|
bool spellVisualDbcLoaded_ = false;
|
||||||
static constexpr float SPELL_VISUAL_MAX_DURATION = 5.0f;
|
static constexpr float SPELL_VISUAL_MAX_DURATION = 5.0f;
|
||||||
static constexpr float SPELL_VISUAL_DEFAULT_DURATION = 2.0f;
|
static constexpr float SPELL_VISUAL_DEFAULT_DURATION = 2.0f;
|
||||||
|
|
||||||
|
// Determine character attachment point from model path keywords
|
||||||
|
static uint32_t classifyAttachmentId(const std::string& modelPath);
|
||||||
|
|
||||||
|
// Apply height offset based on model path keywords (Hand → hands, Chest → chest, Base → ground)
|
||||||
|
static glm::vec3 applyEffectHeightOffset(const glm::vec3& basePos, const std::string& modelPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace rendering
|
} // namespace rendering
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,56 @@ void SpellHandler::playSpellImpactSound(uint32_t spellId) {
|
||||||
audio::SpellSoundManager::SpellPower::MEDIUM);
|
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) {
|
static std::string displaySpellName(GameHandler& handler, uint32_t spellId) {
|
||||||
if (spellId == 0) return {};
|
if (spellId == 0) return {};
|
||||||
|
|
@ -387,6 +437,11 @@ void SpellHandler::cancelCast() {
|
||||||
queuedSpellTarget_ = 0;
|
queuedSpellTarget_ = 0;
|
||||||
if (owner_.addonEventCallbackRef())
|
if (owner_.addonEventCallbackRef())
|
||||||
owner_.addonEventCallbackRef()("UNIT_SPELLCAST_STOP", {"player"});
|
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) {
|
void SpellHandler::startCraftQueue(uint32_t spellId, int count) {
|
||||||
|
|
@ -828,6 +883,11 @@ void SpellHandler::handleCastFailed(network::Packet& packet) {
|
||||||
owner_.pendingGameObjectInteractGuidRef() = 0;
|
owner_.pendingGameObjectInteractGuidRef() = 0;
|
||||||
craftQueueSpellId_ = 0;
|
craftQueueSpellId_ = 0;
|
||||||
craftQueueRemaining_ = 0;
|
craftQueueRemaining_ = 0;
|
||||||
|
// Remove lingering precast visual effects
|
||||||
|
if (auto* renderer = owner_.services().renderer) {
|
||||||
|
if (auto* svs = renderer->getSpellVisualSystem())
|
||||||
|
svs->cancelAllPrecastVisuals();
|
||||||
|
}
|
||||||
queuedSpellId_ = 0;
|
queuedSpellId_ = 0;
|
||||||
queuedSpellTarget_ = 0;
|
queuedSpellTarget_ = 0;
|
||||||
|
|
||||||
|
|
@ -948,6 +1008,12 @@ void SpellHandler::handleSpellStart(network::Packet& packet) {
|
||||||
if (!unitId.empty())
|
if (!unitId.empty())
|
||||||
owner_.addonEventCallbackRef()("UNIT_SPELLCAST_START", {unitId, std::to_string(data.spellId)});
|
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) {
|
void SpellHandler::handleSpellGo(network::Packet& packet) {
|
||||||
|
|
@ -1094,6 +1160,29 @@ void SpellHandler::handleSpellGo(network::Packet& packet) {
|
||||||
|
|
||||||
if (playerIsHit || playerHitEnemy)
|
if (playerIsHit || playerHitEnemy)
|
||||||
playSpellImpactSound(data.spellId);
|
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) {
|
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 ebp1Field = spellL ? spellL->field("EffectBasePoints1") : 0xFFFFFFFF;
|
||||||
const uint32_t ebp2Field = spellL ? spellL->field("EffectBasePoints2") : 0xFFFFFFFF;
|
const uint32_t ebp2Field = spellL ? spellL->field("EffectBasePoints2") : 0xFFFFFFFF;
|
||||||
const uint32_t durIdxField = spellL ? spellL->field("DurationIndex") : 0xFFFFFFFF;
|
const uint32_t durIdxField = spellL ? spellL->field("DurationIndex") : 0xFFFFFFFF;
|
||||||
|
const uint32_t spellVisualIdField = spellL ? spellL->field("SpellVisualID") : 0xFFFFFFFF;
|
||||||
|
|
||||||
uint32_t count = dbc->getRecordCount();
|
uint32_t count = dbc->getRecordCount();
|
||||||
for (uint32_t i = 0; i < count; ++i) {
|
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 name = dbc->getString(i, nameField);
|
||||||
std::string rank = dbc->getString(i, rankField);
|
std::string rank = dbc->getString(i, rankField);
|
||||||
if (!name.empty()) {
|
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) {
|
if (tooltipField != 0xFFFFFFFF) {
|
||||||
entry.description = dbc->getString(i, tooltipField);
|
entry.description = dbc->getString(i, tooltipField);
|
||||||
}
|
}
|
||||||
|
|
@ -1830,6 +1920,9 @@ void SpellHandler::loadSpellNameCache() const {
|
||||||
// Duration: read DurationIndex and resolve via SpellDuration.dbc later
|
// Duration: read DurationIndex and resolve via SpellDuration.dbc later
|
||||||
if (durIdxField != 0xFFFFFFFF)
|
if (durIdxField != 0xFFFFFFFF)
|
||||||
entry.durationSec = static_cast<float>(dbc->getUInt32(i, durIdxField)); // store index temporarily
|
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);
|
owner_.spellNameCacheRef()[id] = std::move(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2417,6 +2510,11 @@ void SpellHandler::handleSpellFailure(network::Packet& packet) {
|
||||||
craftQueueRemaining_ = 0;
|
craftQueueRemaining_ = 0;
|
||||||
queuedSpellId_ = 0;
|
queuedSpellId_ = 0;
|
||||||
queuedSpellTarget_ = 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* ac = owner_.services().audioCoordinator) {
|
||||||
if (auto* ssm = ac->getSpellSoundManager()) {
|
if (auto* ssm = ac->getSpellSoundManager()) {
|
||||||
ssm->stopPrecast();
|
ssm->stopPrecast();
|
||||||
|
|
|
||||||
|
|
@ -1378,10 +1378,43 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
|
||||||
if (rib.edgesPerSecond < 1.0f || rib.edgesPerSecond > 200.0f) rib.edgesPerSecond = 15.0f;
|
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;
|
if (rib.edgeLifetime < 0.05f || rib.edgeLifetime > 10.0f) rib.edgeLifetime = 0.5f;
|
||||||
|
|
||||||
// visibilityTrack M2TrackDisk at 0x98 (uint8, treat as float 0/1)
|
// visibilityTrack M2TrackDisk at 0x98 — keys are uint8 (0/1), NOT float.
|
||||||
|
// Must read as uint8 and convert to float, else 0x01 reads as
|
||||||
|
// float ~1.4e-45 which fails the visibility > 0.5 check.
|
||||||
if (base + 0x98 + sizeof(M2TrackDisk) <= m2Data.size()) {
|
if (base + 0x98 + sizeof(M2TrackDisk) <= m2Data.size()) {
|
||||||
M2TrackDisk disk = readValue<M2TrackDisk>(m2Data, base + 0x98);
|
M2TrackDisk disk = readValue<M2TrackDisk>(m2Data, base + 0x98);
|
||||||
parseAnimTrack(m2Data, disk, rib.visibilityTrack, TrackType::FLOAT, ribSeqFlags);
|
auto& track = rib.visibilityTrack;
|
||||||
|
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] & kM2SeqFlagEmbeddedData)) 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(uint8_t) > m2Data.size()) continue;
|
||||||
|
track.sequences[s].timestamps = readArray<uint32_t>(m2Data, tsOfs, tsCount);
|
||||||
|
track.sequences[s].floatValues.reserve(kCount);
|
||||||
|
for (uint32_t k = 0; k < kCount; k++) {
|
||||||
|
uint8_t raw = readValue<uint8_t>(m2Data, kOfs + k);
|
||||||
|
track.sequences[s].floatValues.push_back(raw != 0 ? 1.0f : 0.0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip garbage emitters (common M2 artifact: alternating emitters
|
||||||
|
// have bone=UINT_MAX or other invalid state)
|
||||||
|
if (rib.bone == 0xFFFFFFFF) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
model.ribbonEmitters.push_back(std::move(rib));
|
model.ribbonEmitters.push_back(std::move(rib));
|
||||||
|
|
|
||||||
|
|
@ -56,19 +56,31 @@ M2ClassificationResult classifyM2Model(
|
||||||
r.isInvisibleTrap = has(n, "invisibletrap");
|
r.isInvisibleTrap = has(n, "invisibletrap");
|
||||||
r.isGroundDetail = has(n, "\\nodxt\\detail\\") || has(n, "\\detail\\");
|
r.isGroundDetail = has(n, "\\nodxt\\detail\\") || has(n, "\\detail\\");
|
||||||
r.isSmoke = has(n, "smoke");
|
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")
|
r.isInstancePortal = has(n, "instanceportal") || has(n, "instancenewportal")
|
||||||
|| has(n, "portalfx") || has(n, "spellportal");
|
|| has(n, "portalfx") || has(n, "spellportal");
|
||||||
|
|
||||||
r.isWaterVegetation = has(n, "cattail") || has(n, "reed") || has(n, "bulrush")
|
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.isElvenLike = has(n, "elf") || has(n, "elven") || has(n, "quel");
|
||||||
r.isLanternLike = has(n, "lantern") || has(n, "lamp") || has(n, "light");
|
r.isLanternLike = has(n, "lantern") || has(n, "lamp") || has(n, "light");
|
||||||
r.isKoboldFlame = has(n, "kobold")
|
r.isKoboldFlame = has(n, "kobold")
|
||||||
&& (has(n, "candle") || has(n, "torch") || has(n, "mine"));
|
&& (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)
|
// Collision: shape categories (mirrors original logic ordering)
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|
@ -83,7 +95,11 @@ M2ClassificationResult classifyM2Model(
|
||||||
|| has(n, "seat") || has(n, "throne");
|
|| has(n, "seat") || has(n, "throne");
|
||||||
const bool smallSolid = (statueName && !sittable)
|
const bool smallSolid = (statueName && !sittable)
|
||||||
|| has(n, "crate") || has(n, "box")
|
|| 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");
|
const bool chestName = has(n, "chest");
|
||||||
|
|
||||||
r.collisionSteppedFountain = has(n, "fountain");
|
r.collisionSteppedFountain = has(n, "fountain");
|
||||||
|
|
@ -106,17 +122,22 @@ M2ClassificationResult classifyM2Model(
|
||||||
// Foliage token table (sorted alphabetically)
|
// Foliage token table (sorted alphabetically)
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
static constexpr auto kFoliageTokens = std::to_array<std::string_view>({
|
static constexpr auto kFoliageTokens = std::to_array<std::string_view>({
|
||||||
"algae", "bamboo", "banana", "branch", "bush",
|
"algae", "bamboo", "banana", "barley", "bracken",
|
||||||
"cactus", "canopy", "cattail", "coconut", "coral",
|
"branch", "briars", "brush", "bush",
|
||||||
"corn", "crop", "dead-grass", "dead_grass", "deadgrass",
|
"cactus", "canopy", "cattail", "clover", "coconut",
|
||||||
|
"coral", "corn", "crop",
|
||||||
|
"dead-grass", "dead_grass", "deadgrass",
|
||||||
"dry-grass", "dry_grass", "drygrass",
|
"dry-grass", "dry_grass", "drygrass",
|
||||||
"fern", "fireflies", "firefly", "fireflys",
|
"fern", "fernleaf", "fireflies", "firefly", "fireflys",
|
||||||
"flower", "frond", "fungus", "gourd", "grass",
|
"flower", "frond", "fungus", "gourd", "grapes",
|
||||||
"hay", "hedge", "ivy", "kelp", "leaf",
|
"grass",
|
||||||
"leaves", "lily", "melon", "moss", "mushroom",
|
"hay", "hedge", "hops", "ivy",
|
||||||
"palm", "pumpkin", "reed", "root", "seaweed",
|
"kelp", "leaf", "leaves", "lichen", "lily",
|
||||||
"shrub", "squash", "stalk", "thorn", "toadstool",
|
"melon", "moss", "mushroom", "nettle",
|
||||||
"vine", "watermelon", "weed", "wheat",
|
"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).
|
// "plant" is foliage unless "planter" is also present (planters are solid curbs).
|
||||||
|
|
@ -173,20 +194,44 @@ M2ClassificationResult classifyM2Model(
|
||||||
r.shadowWindFoliage = r.isFoliageLike;
|
r.shadowWindFoliage = r.isFoliageLike;
|
||||||
r.isFireflyEffect = ambientCreature;
|
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)
|
// Spell effects (named tokens + particle-dominated geometry heuristic)
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
static constexpr auto kEffectTokens = std::to_array<std::string_view>({
|
static constexpr auto kEffectTokens = std::to_array<std::string_view>({
|
||||||
"bubbles", "hazardlight", "instancenewportal", "instanceportal",
|
"bubbles", "dustcloud", "hazardlight",
|
||||||
|
"instancenewportal", "instanceportal",
|
||||||
"lavabubble", "lavasplash", "lavasteam", "levelup",
|
"lavabubble", "lavasplash", "lavasteam", "levelup",
|
||||||
"lightshaft", "mageportal", "particleemitter",
|
"lightshaft", "mageportal", "particleemitter",
|
||||||
"spotlight", "volumetriclight", "wisps", "worldtreeportal",
|
"smokepuff", "sparkle", "spotlight",
|
||||||
|
"steam", "volumetriclight", "wisps", "worldtreeportal",
|
||||||
});
|
});
|
||||||
r.isSpellEffect = hasAny(n, kEffectTokens)
|
r.isSpellEffect = hasAny(n, kEffectTokens)
|
||||||
|| (emitterCount >= 3 && vertexCount <= 200);
|
|| (emitterCount >= 3 && vertexCount <= 200);
|
||||||
// Instance portals are spell effects too.
|
// Instance portals are spell effects too.
|
||||||
if (r.isInstancePortal) r.isSpellEffect = true;
|
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;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -244,5 +289,28 @@ M2BatchTexClassification classifyBatchTexture(const std::string& lowerTexKey)
|
||||||
return r;
|
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 rendering
|
||||||
} // namespace wowee
|
} // namespace wowee
|
||||||
|
|
|
||||||
|
|
@ -1123,6 +1123,20 @@ bool M2Renderer::hasModel(uint32_t modelId) const {
|
||||||
return models.find(modelId) != models.end();
|
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) {
|
bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
|
||||||
if (models.find(modelId) != models.end()) {
|
if (models.find(modelId) != models.end()) {
|
||||||
// Already loaded
|
// Already loaded
|
||||||
|
|
@ -1186,6 +1200,7 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
|
||||||
gpuModel.disableAnimation = cls.disableAnimation;
|
gpuModel.disableAnimation = cls.disableAnimation;
|
||||||
gpuModel.shadowWindFoliage = cls.shadowWindFoliage;
|
gpuModel.shadowWindFoliage = cls.shadowWindFoliage;
|
||||||
gpuModel.isFireflyEffect = cls.isFireflyEffect;
|
gpuModel.isFireflyEffect = cls.isFireflyEffect;
|
||||||
|
gpuModel.isSmallFoliage = cls.isSmallFoliage;
|
||||||
gpuModel.isSmoke = cls.isSmoke;
|
gpuModel.isSmoke = cls.isSmoke;
|
||||||
gpuModel.isSpellEffect = cls.isSpellEffect;
|
gpuModel.isSpellEffect = cls.isSpellEffect;
|
||||||
gpuModel.isLavaModel = cls.isLavaModel;
|
gpuModel.isLavaModel = cls.isLavaModel;
|
||||||
|
|
@ -1194,6 +1209,10 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
|
||||||
gpuModel.isElvenLike = cls.isElvenLike;
|
gpuModel.isElvenLike = cls.isElvenLike;
|
||||||
gpuModel.isLanternLike = cls.isLanternLike;
|
gpuModel.isLanternLike = cls.isLanternLike;
|
||||||
gpuModel.isKoboldFlame = cls.isKoboldFlame;
|
gpuModel.isKoboldFlame = cls.isKoboldFlame;
|
||||||
|
gpuModel.isWaterfall = cls.isWaterfall;
|
||||||
|
gpuModel.isBrazierOrFire = cls.isBrazierOrFire;
|
||||||
|
gpuModel.isTorch = cls.isTorch;
|
||||||
|
gpuModel.ambientEmitterType = cls.ambientEmitterType;
|
||||||
gpuModel.boundMin = tightMin;
|
gpuModel.boundMin = tightMin;
|
||||||
gpuModel.boundMax = tightMax;
|
gpuModel.boundMax = tightMax;
|
||||||
gpuModel.boundRadius = model.boundRadius;
|
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.ribbonTextures.resize(model.ribbonEmitters.size(), whiteTexture_.get());
|
||||||
gpuModel.ribbonTexSets.resize(model.ribbonEmitters.size(), VK_NULL_HANDLE);
|
gpuModel.ribbonTexSets.resize(model.ribbonEmitters.size(), VK_NULL_HANDLE);
|
||||||
for (size_t ri = 0; ri < model.ribbonEmitters.size(); ri++) {
|
for (size_t ri = 0; ri < model.ribbonEmitters.size(); ri++) {
|
||||||
// Resolve texture via textureLookup table
|
// Resolve texture: ribbon textureIndex is a direct index into the
|
||||||
uint16_t texLookupIdx = model.ribbonEmitters[ri].textureIndex;
|
// model's texture array (NOT through the textureLookup table).
|
||||||
uint32_t texIdx = (texLookupIdx < model.textureLookup.size())
|
uint16_t texDirect = model.ribbonEmitters[ri].textureIndex;
|
||||||
? model.textureLookup[texLookupIdx] : UINT32_MAX;
|
if (texDirect < allTextures.size() && allTextures[texDirect] != nullptr) {
|
||||||
if (texIdx < allTextures.size() && allTextures[texIdx] != nullptr) {
|
gpuModel.ribbonTextures[ri] = allTextures[texDirect];
|
||||||
gpuModel.ribbonTextures[ri] = allTextures[texIdx];
|
|
||||||
} else {
|
} else {
|
||||||
LOG_WARNING("M2 '", model.name, "' ribbon emitter[", ri,
|
// Fallback: try through textureLookup table
|
||||||
"] texLookup=", texLookupIdx, " resolved texIdx=", texIdx,
|
uint32_t texIdx = (texDirect < model.textureLookup.size())
|
||||||
" out of range (", allTextures.size(),
|
? model.textureLookup[texDirect] : UINT32_MAX;
|
||||||
" textures) — using white fallback");
|
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)
|
// Allocate descriptor set (reuse particleTexLayout_ = single sampler)
|
||||||
if (particleTexLayout_ && materialDescPool_) {
|
if (particleTexLayout_ && materialDescPool_) {
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,15 @@ void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt
|
||||||
}
|
}
|
||||||
|
|
||||||
inst.particles.push_back(p);
|
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
|
// Cap accumulator to avoid bursts after lag
|
||||||
if (inst.emitterAccumulators[ei] > 2.0f) {
|
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
|
// Determine bone world position for spine
|
||||||
glm::vec3 spineWorld = inst.position;
|
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);
|
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 {
|
} else {
|
||||||
glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f);
|
glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f);
|
||||||
spineWorld = glm::vec3(inst.modelMatrix * local);
|
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)
|
// Evaluate animated tracks (use first available sequence key, or fallback value)
|
||||||
auto getFloatVal = [&](const pipeline::M2AnimationTrack& track, float fallback) -> float {
|
auto getFloatVal = [&](const pipeline::M2AnimationTrack& track, float fallback) -> float {
|
||||||
for (const auto& seq : track.sequences) {
|
for (const auto& seq : track.sequences) {
|
||||||
|
|
@ -311,6 +330,16 @@ void M2Renderer::updateRibbons(M2Instance& inst, const M2ModelGPU& gpu, float dt
|
||||||
e.heightBelow = heightBelow;
|
e.heightBelow = heightBelow;
|
||||||
e.age = 0.0f;
|
e.age = 0.0f;
|
||||||
edges.push_back(e);
|
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
|
// Cap trail length
|
||||||
if (edges.size() > 128) edges.pop_front();
|
if (edges.size() > 128) edges.pop_front();
|
||||||
}
|
}
|
||||||
|
|
@ -359,7 +388,17 @@ void M2Renderer::renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSe
|
||||||
// Descriptor set for texture
|
// Descriptor set for texture
|
||||||
VkDescriptorSet texSet = (ri < gpu.ribbonTexSets.size())
|
VkDescriptorSet texSet = (ri < gpu.ribbonTexSets.size())
|
||||||
? gpu.ribbonTexSets[ri] : VK_NULL_HANDLE;
|
? 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);
|
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;
|
if (draws.empty() || written == 0) return;
|
||||||
|
|
||||||
VkExtent2D ext = vkCtx_->getSwapchainExtent();
|
VkExtent2D ext = vkCtx_->getSwapchainExtent();
|
||||||
|
|
@ -471,7 +533,13 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame
|
||||||
if (rawScale > 2.0f) alpha *= 0.02f;
|
if (rawScale > 2.0f) alpha *= 0.02f;
|
||||||
if (em.blendingType == 3 || em.blendingType == 4) alpha *= 0.05f;
|
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();
|
VkTexture* tex = whiteTexture_.get();
|
||||||
if (p.emitterIndex < static_cast<int>(gpu.particleTextures.size())) {
|
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;
|
if (totalParticles == 0) return;
|
||||||
|
|
||||||
// Bind per-frame set (set 0) for particle pipeline
|
// Bind per-frame set (set 0) for particle pipeline
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// Deduplicate: skip if same model already at nearly the same position.
|
||||||
// Uses hash map for O(1) lookup instead of O(N) scan.
|
// 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,
|
DedupKey dk{modelId,
|
||||||
static_cast<int32_t>(std::round(position.x * 10.0f)),
|
static_cast<int32_t>(std::round(position.x * 10.0f)),
|
||||||
static_cast<int32_t>(std::round(position.y * 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)
|
// 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,
|
DedupKey dk{modelId,
|
||||||
static_cast<int32_t>(std::round(position.x * 10.0f)),
|
static_cast<int32_t>(std::round(position.x * 10.0f)),
|
||||||
static_cast<int32_t>(std::round(position.y * 10.0f)),
|
static_cast<int32_t>(std::round(position.y * 10.0f)),
|
||||||
|
|
|
||||||
|
|
@ -1936,7 +1936,7 @@ bool Renderer::initializeRenderers(pipeline::AssetManager* assetManager, const s
|
||||||
// Initialize SpellVisualSystem once M2Renderer is available (§4.4)
|
// Initialize SpellVisualSystem once M2Renderer is available (§4.4)
|
||||||
if (!spellVisualSystem_) {
|
if (!spellVisualSystem_) {
|
||||||
spellVisualSystem_ = std::make_unique<SpellVisualSystem>();
|
spellVisualSystem_ = std::make_unique<SpellVisualSystem>();
|
||||||
spellVisualSystem_->initialize(m2Renderer.get());
|
spellVisualSystem_->initialize(m2Renderer.get(), this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
#include "rendering/spell_visual_system.hpp"
|
#include "rendering/spell_visual_system.hpp"
|
||||||
#include "rendering/m2_renderer.hpp"
|
#include "rendering/m2_renderer.hpp"
|
||||||
|
#include "rendering/renderer.hpp"
|
||||||
|
#include "rendering/character_renderer.hpp"
|
||||||
#include "pipeline/asset_manager.hpp"
|
#include "pipeline/asset_manager.hpp"
|
||||||
#include "pipeline/dbc_loader.hpp"
|
#include "pipeline/dbc_loader.hpp"
|
||||||
#include "pipeline/dbc_layout.hpp"
|
#include "pipeline/dbc_layout.hpp"
|
||||||
|
|
@ -11,13 +13,15 @@
|
||||||
namespace wowee {
|
namespace wowee {
|
||||||
namespace rendering {
|
namespace rendering {
|
||||||
|
|
||||||
void SpellVisualSystem::initialize(M2Renderer* m2Renderer) {
|
void SpellVisualSystem::initialize(M2Renderer* m2Renderer, Renderer* renderer) {
|
||||||
m2Renderer_ = m2Renderer;
|
m2Renderer_ = m2Renderer;
|
||||||
|
renderer_ = renderer;
|
||||||
}
|
}
|
||||||
|
|
||||||
void SpellVisualSystem::shutdown() {
|
void SpellVisualSystem::shutdown() {
|
||||||
reset();
|
reset();
|
||||||
m2Renderer_ = nullptr;
|
m2Renderer_ = nullptr;
|
||||||
|
renderer_ = nullptr;
|
||||||
cachedAssetManager_ = nullptr;
|
cachedAssetManager_ = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,13 +42,26 @@ void SpellVisualSystem::loadSpellVisualDbc() {
|
||||||
const pipeline::DBCFieldMap* fxLayout = layout ? layout->getLayout("SpellVisualEffectName") : nullptr;
|
const pipeline::DBCFieldMap* fxLayout = layout ? layout->getLayout("SpellVisualEffectName") : nullptr;
|
||||||
|
|
||||||
uint32_t svCastKitField = svLayout ? (*svLayout)["CastKit"] : 2;
|
uint32_t svCastKitField = svLayout ? (*svLayout)["CastKit"] : 2;
|
||||||
|
uint32_t svPrecastKitField = svLayout ? (*svLayout)["PrecastKit"] : 1;
|
||||||
uint32_t svImpactKitField = svLayout ? (*svLayout)["ImpactKit"] : 3;
|
uint32_t svImpactKitField = svLayout ? (*svLayout)["ImpactKit"] : 3;
|
||||||
uint32_t svMissileField = svLayout ? (*svLayout)["MissileModel"] : 8;
|
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;
|
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
|
// Load SpellVisualEffectName.dbc — ID → M2 path
|
||||||
auto fxDbc = cachedAssetManager_->loadDBC("SpellVisualEffectName.dbc");
|
auto fxDbc = cachedAssetManager_->loadDBC("SpellVisualEffectName.dbc");
|
||||||
if (!fxDbc || !fxDbc->isLoaded() || fxDbc->getFieldCount() <= fxFilePathField) {
|
if (!fxDbc || !fxDbc->isLoaded() || fxDbc->getFieldCount() <= fxFilePathField) {
|
||||||
|
|
@ -56,10 +73,22 @@ void SpellVisualSystem::loadSpellVisualDbc() {
|
||||||
for (uint32_t i = 0; i < fxDbc->getRecordCount(); ++i) {
|
for (uint32_t i = 0; i < fxDbc->getRecordCount(); ++i) {
|
||||||
uint32_t id = fxDbc->getUInt32(i, 0);
|
uint32_t id = fxDbc->getUInt32(i, 0);
|
||||||
std::string p = fxDbc->getString(i, fxFilePathField);
|
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
|
// 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");
|
auto kitDbc = cachedAssetManager_->loadDBC("SpellVisualKit.dbc");
|
||||||
std::unordered_map<uint32_t, uint32_t> kitToEffectName; // kitId → effectNameId
|
std::unordered_map<uint32_t, uint32_t> kitToEffectName; // kitId → effectNameId
|
||||||
if (kitDbc && kitDbc->isLoaded()) {
|
if (kitDbc && kitDbc->isLoaded()) {
|
||||||
|
|
@ -67,10 +96,11 @@ void SpellVisualSystem::loadSpellVisualDbc() {
|
||||||
for (uint32_t i = 0; i < kitDbc->getRecordCount(); ++i) {
|
for (uint32_t i = 0; i < kitDbc->getRecordCount(); ++i) {
|
||||||
uint32_t kitId = kitDbc->getUInt32(i, 0);
|
uint32_t kitId = kitDbc->getUInt32(i, 0);
|
||||||
if (!kitId) continue;
|
if (!kitId) continue;
|
||||||
// Prefer SpecialEffect0, fall back to BaseEffect
|
|
||||||
uint32_t eff = 0;
|
uint32_t eff = 0;
|
||||||
if (kitSpecial0Field < fc) eff = kitDbc->getUInt32(i, kitSpecial0Field);
|
for (size_t k = 0; k < numKitFields && !eff; ++k) {
|
||||||
if (!eff && kitBaseField < fc) eff = kitDbc->getUInt32(i, kitBaseField);
|
if (kitFields[k] < fc)
|
||||||
|
eff = kitDbc->getUInt32(i, kitFields[k]);
|
||||||
|
}
|
||||||
if (eff) kitToEffectName[kitId] = eff;
|
if (eff) kitToEffectName[kitId] = eff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -96,11 +126,18 @@ void SpellVisualSystem::loadSpellVisualDbc() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
uint32_t svFc = svDbc->getFieldCount();
|
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) {
|
for (uint32_t i = 0; i < svDbc->getRecordCount(); ++i) {
|
||||||
uint32_t vid = svDbc->getUInt32(i, 0);
|
uint32_t vid = svDbc->getUInt32(i, 0);
|
||||||
if (!vid) continue;
|
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
|
// Cast path: CastKit → SpecialEffect0/BaseEffect, fallback to MissileModel
|
||||||
{
|
{
|
||||||
std::string path;
|
std::string path;
|
||||||
|
|
@ -120,12 +157,211 @@ void SpellVisualSystem::loadSpellVisualDbc() {
|
||||||
if (!path.empty()) { spellVisualImpactPath_[vid] = path; ++loadedImpact; }
|
if (!path.empty()) { spellVisualImpactPath_[vid] = path; ++loadedImpact; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LOG_INFO("SpellVisual: loaded cast=", loadedCast, " impact=", loadedImpact,
|
LOG_INFO("SpellVisual: loaded precast=", loadedPrecast, " cast=", loadedCast, " impact=", loadedImpact,
|
||||||
" visual→M2 mappings (of ", svDbc->getRecordCount(), " records)");
|
" 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,
|
void SpellVisualSystem::playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition,
|
||||||
bool useImpactKit) {
|
bool useImpactKit) {
|
||||||
|
LOG_INFO("SpellVisual: playSpellVisual visualId=", visualId, " impact=", useImpactKit,
|
||||||
|
" pos=(", worldPosition.x, ",", worldPosition.y, ",", worldPosition.z, ")");
|
||||||
if (!m2Renderer_ || visualId == 0) return;
|
if (!m2Renderer_ || visualId == 0) return;
|
||||||
|
|
||||||
if (!cachedAssetManager_)
|
if (!cachedAssetManager_)
|
||||||
|
|
@ -137,9 +373,13 @@ void SpellVisualSystem::playSpellVisual(uint32_t visualId, const glm::vec3& worl
|
||||||
// Select cast or impact path map
|
// Select cast or impact path map
|
||||||
auto& pathMap = useImpactKit ? spellVisualImpactPath_ : spellVisualCastPath_;
|
auto& pathMap = useImpactKit ? spellVisualImpactPath_ : spellVisualCastPath_;
|
||||||
auto pathIt = pathMap.find(visualId);
|
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;
|
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
|
// Get or assign a model ID for this path
|
||||||
auto midIt = spellVisualModelIds_.find(modelPath);
|
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)
|
// 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
|
// Load the M2 model if not already loaded
|
||||||
if (!m2Renderer_->hasModel(modelId)) {
|
if (!m2Renderer_->hasModel(modelId)) {
|
||||||
auto m2Data = cachedAssetManager_->readFile(modelPath);
|
auto m2Data = cachedAssetManager_->readFile(modelPath);
|
||||||
if (m2Data.empty()) {
|
if (m2Data.empty()) {
|
||||||
LOG_DEBUG("SpellVisual: could not read model: ", modelPath);
|
LOG_WARNING("SpellVisual: could not read model: ", modelPath);
|
||||||
spellVisualFailedModels_.insert(modelId);
|
spellVisualFailedModels_.insert(modelId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
LOG_INFO("SpellVisual: cast/impact M2 data read OK, size=", m2Data.size(), " bytes");
|
||||||
pipeline::M2Model model = pipeline::M2Loader::load(m2Data);
|
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()) {
|
if (model.vertices.empty() && model.particleEmitters.empty()) {
|
||||||
LOG_DEBUG("SpellVisual: empty model: ", modelPath);
|
LOG_WARNING("SpellVisual: empty model: ", modelPath);
|
||||||
spellVisualFailedModels_.insert(modelId);
|
spellVisualFailedModels_.insert(modelId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -183,11 +430,38 @@ void SpellVisualSystem::playSpellVisual(uint32_t visualId, const glm::vec3& worl
|
||||||
spellVisualFailedModels_.insert(modelId);
|
spellVisualFailedModels_.insert(modelId);
|
||||||
return;
|
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
|
// 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);
|
glm::vec3(0.0f), 1.0f);
|
||||||
if (instanceId == 0) {
|
if (instanceId == 0) {
|
||||||
LOG_WARNING("SpellVisual: failed to create instance for visualId=", visualId);
|
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)
|
float duration = (animDurMs > 100.0f)
|
||||||
? std::clamp(animDurMs / 1000.0f, 0.5f, SPELL_VISUAL_MAX_DURATION)
|
? std::clamp(animDurMs / 1000.0f, 0.5f, SPELL_VISUAL_MAX_DURATION)
|
||||||
: SPELL_VISUAL_DEFAULT_DURATION;
|
: SPELL_VISUAL_DEFAULT_DURATION;
|
||||||
activeSpellVisuals_.push_back({instanceId, 0.0f, duration});
|
activeSpellVisuals_.push_back({instanceId, 0.0f, duration, false, attachId});
|
||||||
LOG_DEBUG("SpellVisual: spawned visualId=", visualId, " instanceId=", instanceId,
|
LOG_INFO("SpellVisual: spawned ", (useImpactKit ? "impact" : "cast"), " visualId=", visualId,
|
||||||
" duration=", duration, "s model=", modelPath);
|
" 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) {
|
void SpellVisualSystem::update(float deltaTime) {
|
||||||
if (activeSpellVisuals_.empty() || !m2Renderer_) return;
|
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(); ) {
|
for (auto it = activeSpellVisuals_.begin(); it != activeSpellVisuals_.end(); ) {
|
||||||
it->elapsed += deltaTime;
|
it->elapsed += deltaTime;
|
||||||
if (it->elapsed >= it->duration) {
|
if (it->elapsed >= it->duration) {
|
||||||
m2Renderer_->removeInstance(it->instanceId);
|
m2Renderer_->removeInstance(it->instanceId);
|
||||||
it = activeSpellVisuals_.erase(it);
|
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 {
|
} else {
|
||||||
++it;
|
++it;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
#include "rendering/vk_context.hpp"
|
#include "rendering/vk_context.hpp"
|
||||||
#include "rendering/water_renderer.hpp"
|
#include "rendering/water_renderer.hpp"
|
||||||
#include "rendering/m2_renderer.hpp"
|
#include "rendering/m2_renderer.hpp"
|
||||||
|
#include "rendering/m2_model_classifier.hpp"
|
||||||
#include "rendering/wmo_renderer.hpp"
|
#include "rendering/wmo_renderer.hpp"
|
||||||
#include "rendering/camera.hpp"
|
#include "rendering/camera.hpp"
|
||||||
#include "audio/ambient_sound_manager.hpp"
|
#include "audio/ambient_sound_manager.hpp"
|
||||||
|
|
@ -691,36 +692,21 @@ std::shared_ptr<PendingTile> TerrainManager::prepareTile(int x, int y) {
|
||||||
doodadLogCount++;
|
doodadLogCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m2PathLower.find("fire") != std::string::npos ||
|
auto emitterType = rendering::classifyAmbientEmitter(m2PathLower);
|
||||||
m2PathLower.find("brazier") != std::string::npos ||
|
if (emitterType != rendering::AmbientEmitterType::None) {
|
||||||
m2PathLower.find("campfire") != std::string::npos) {
|
|
||||||
// Fireplace/brazier emitter
|
|
||||||
PendingTile::AmbientEmitter emitter;
|
PendingTile::AmbientEmitter emitter;
|
||||||
emitter.position = worldPos;
|
emitter.position = worldPos;
|
||||||
if (m2PathLower.find("small") != std::string::npos || m2PathLower.find("campfire") != std::string::npos) {
|
// Map classifier enum to AmbientSoundManager type codes
|
||||||
emitter.type = 0; // FIREPLACE_SMALL
|
switch (emitterType) {
|
||||||
} else {
|
case rendering::AmbientEmitterType::FireplaceSmall: emitter.type = 0; break;
|
||||||
emitter.type = 1; // FIREPLACE_LARGE
|
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);
|
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;
|
PendingTile::WMODoodadReady doodadReady;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue