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

@ -2493,6 +2493,7 @@ public:
uint32_t schoolMask = 0; uint8_t dispelType = 0; uint32_t attrEx = 0;
int32_t effectBasePoints[3] = {0, 0, 0};
float durationSec = 0.0f;
uint32_t spellVisualId = 0;
};
static constexpr size_t PLAYER_EXPLORED_ZONES_COUNT = 128;
std::string getAreaName(uint32_t areaId) const;

View file

@ -6,6 +6,7 @@
#include "game/handler_types.hpp"
#include "audio/spell_sound_manager.hpp"
#include "network/packet.hpp"
#include <glm/glm.hpp>
#include <array>
#include <chrono>
#include <functional>
@ -283,6 +284,15 @@ private:
void playSpellCastSound(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) ---
void parseEffectPowerDrain(network::Packet& packet, uint32_t effectLogCount,
uint64_t caster, uint32_t spellId, bool isPlayerCaster,

View file

@ -7,6 +7,17 @@
namespace wowee {
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.
* Pure data no Vulkan, GPU, or asset-manager dependencies.
@ -25,6 +36,7 @@ struct M2ClassificationResult {
// --- Rendering / effect classification ---
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 isLavaModel = false; ///< Lava surface (UV scroll animation)
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 isInvisibleTrap = false; ///< Event-object invisible trap (no render, no collision)
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 ---
bool disableAnimation = false; ///< Keep visually stable (foliage, chest lids, etc.)
@ -89,5 +107,18 @@ struct M2BatchTexClassification {
*/
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 wowee

View file

@ -2,6 +2,7 @@
#include "pipeline/m2_loader.hpp"
#include "pipeline/blp_loader.hpp"
#include "rendering/m2_model_classifier.hpp"
#include <vulkan/vulkan.h>
#include <vk_mem_alloc.h>
#include <glm/glm.hpp>
@ -78,11 +79,15 @@ struct M2ModelGPU {
bool collisionTreeTrunk = false;
bool collisionNoBlock = false;
bool collisionStatue = false;
bool isSmallFoliage = false; // Small foliage (bushes, grass, plants) - skip during taxi
bool isInvisibleTrap = false; // Invisible trap objects (don't render, no collision)
bool isGroundDetail = false; // Ground clutter/detail doodads (special fallback render path)
bool isSmallFoliage = false; // Small foliage (bushes, grass, plants) - skip during taxi
bool isInvisibleTrap = false; // Invisible trap objects (don't render, no collision)
bool isGroundDetail = false; // Ground clutter/detail doodads (special fallback render path)
bool isWaterVegetation = false; // Cattails, reeds, kelp etc. near water (insect spawning)
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)
struct CollisionMesh {
@ -282,6 +287,8 @@ public:
bool hasModel(uint32_t modelId) const;
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,
const glm::vec3& rotation = glm::vec3(0.0f),

View file

@ -12,14 +12,16 @@ namespace pipeline { class AssetManager; }
namespace rendering {
class M2Renderer;
class Renderer;
class CharacterRenderer;
class SpellVisualSystem {
public:
SpellVisualSystem() = default;
~SpellVisualSystem() = default;
// Initialize with references to the M2 renderer (for model loading/instance spawning)
void initialize(M2Renderer* m2Renderer);
// Initialize with references to the M2 renderer and parent renderer
void initialize(M2Renderer* m2Renderer, Renderer* renderer);
void shutdown();
// Spawn a spell visual at a world position.
@ -27,9 +29,17 @@ public:
void playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition,
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.
void update(float deltaTime);
// Remove all active precast visual instances (cast canceled/interrupted).
void cancelAllPrecastVisuals();
// Remove all active spell visual instances and reset caches.
// Called on map change / combat reset.
void reset();
@ -40,14 +50,18 @@ private:
uint32_t instanceId;
float elapsed;
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();
M2Renderer* m2Renderer_ = nullptr;
Renderer* renderer_ = nullptr;
pipeline::AssetManager* cachedAssetManager_ = nullptr;
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> spellVisualImpactPath_; // visualId → impact M2 path
std::unordered_map<std::string, uint32_t> spellVisualModelIds_; // M2 path → M2Renderer modelId
@ -56,6 +70,12 @@ private:
bool spellVisualDbcLoaded_ = false;
static constexpr float SPELL_VISUAL_MAX_DURATION = 5.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