Merge pull request #21 from ldmonster/chore/add-classification-to-render

refactor(rendering): extract M2 classification into pure functions
This commit is contained in:
Kelsi Rae Davis 2026-03-24 10:18:24 -07:00 committed by GitHub
commit 0a32c0fa27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 386 additions and 312 deletions

View file

@ -529,6 +529,7 @@ set(WOWEE_SOURCES
src/rendering/character_preview.cpp
src/rendering/wmo_renderer.cpp
src/rendering/m2_renderer.cpp
src/rendering/m2_model_classifier.cpp
src/rendering/quest_marker_renderer.cpp
src/rendering/minimap.cpp
src/rendering/world_map.cpp

View file

@ -0,0 +1,93 @@
#pragma once
#include <glm/glm.hpp>
#include <string>
#include <cstddef>
namespace wowee {
namespace rendering {
/**
* Output of classifyM2Model(): all name/geometry-based flags for an M2 model.
* Pure data no Vulkan, GPU, or asset-manager dependencies.
*/
struct M2ClassificationResult {
// --- Collision shape selectors ---
bool collisionNoBlock = false; ///< Foliage/soft-trees/rugs: no blocking
bool collisionBridge = false; ///< Walk-on-top bridge/plank/walkway
bool collisionPlanter = false; ///< Low stepped planter/curb
bool collisionSteppedFountain = false; ///< Stepped fountain base
bool collisionSteppedLowPlatform = false; ///< Low stepped platform (curb/planter/bridge)
bool collisionStatue = false; ///< Statue/monument/sculpture
bool collisionSmallSolidProp = false; ///< Blockable solid prop (crate/chest/barrel)
bool collisionNarrowVerticalProp = false; ///< Narrow tall prop (lamp/post/pole)
bool collisionTreeTrunk = false; ///< Tree trunk cylinder
// --- Rendering / effect classification ---
bool isFoliageLike = false; ///< Foliage or tree (wind sway, disabled animation)
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)
bool isWaterVegetation = false; ///< Aquatic vegetation (cattails, kelp, reeds, etc.)
bool isFireflyEffect = false; ///< Ambient creature (exempt from particle dampeners)
bool isElvenLike = false; ///< Night elf / Blood elf themed model
bool isLanternLike = false; ///< Lantern/lamp/light model
bool isKoboldFlame = false; ///< Kobold candle/torch model
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)
// --- Animation flags ---
bool disableAnimation = false; ///< Keep visually stable (foliage, chest lids, etc.)
bool shadowWindFoliage = false; ///< Apply wind sway in shadow pass for foliage/trees
};
/**
* Classify an M2 model by name and geometry.
*
* Pure function no Vulkan, VkContext, or AssetManager dependencies.
* All results are derived solely from the model name string and tight vertex bounds.
*
* @param name Full model path/name from the M2 header (any case)
* @param boundsMin Per-vertex tight bounding-box minimum
* @param boundsMax Per-vertex tight bounding-box maximum
* @param vertexCount Number of mesh vertices
* @param emitterCount Number of particle emitters
*/
M2ClassificationResult classifyM2Model(
const std::string& name,
const glm::vec3& boundsMin,
const glm::vec3& boundsMax,
std::size_t vertexCount,
std::size_t emitterCount);
// ---------------------------------------------------------------------------
// Batch texture classification
// ---------------------------------------------------------------------------
/**
* Per-batch texture key classification glow / tint token flags.
* Input must be a lowercased, backslash-normalised texture path (as stored in
* M2Renderer's textureKeysLower vector). Pure data no Vulkan dependencies.
*/
struct M2BatchTexClassification {
bool exactLanternGlowTex = false; ///< One of the known exact lantern-glow texture paths
bool hasGlowToken = false; ///< glow / flare / halo / light
bool hasFlameToken = false; ///< flame / fire / flamelick / ember
bool hasGlowCardToken = false; ///< glow / flamelick / lensflare / t_vfx / lightbeam / glowball / genericglow
bool likelyFlame = false; ///< fire / flame / torch
bool lanternFamily = false; ///< lantern / lamp / elf / silvermoon / quel / thalas
int glowTint = 0; ///< 0 = neutral, 1 = cool (blue/arcane), 2 = warm (red/scarlet)
};
/**
* Classify a batch texture by its lowercased path for glow/tint hinting.
*
* Pure function no Vulkan, VkContext, or AssetManager dependencies.
*
* @param lowerTexKey Lowercased, backslash-normalised texture path (may be empty)
*/
M2BatchTexClassification classifyBatchTexture(const std::string& lowerTexKey);
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,248 @@
#include "rendering/m2_model_classifier.hpp"
#include <algorithm>
#include <array>
#include <cctype>
#include <string_view>
namespace wowee {
namespace rendering {
namespace {
// Returns true if `lower` contains `token` as a substring.
// Caller must provide an already-lowercased string.
inline bool has(const std::string& lower, std::string_view token) noexcept {
return lower.find(token) != std::string::npos;
}
// Returns true if any token in the compile-time array is a substring of `lower`.
template <std::size_t N>
bool hasAny(const std::string& lower,
const std::array<std::string_view, N>& tokens) noexcept {
for (auto tok : tokens)
if (lower.find(tok) != std::string::npos) return true;
return false;
}
} // namespace
M2ClassificationResult classifyM2Model(
const std::string& name,
const glm::vec3& boundsMin,
const glm::vec3& boundsMax,
std::size_t vertexCount,
std::size_t emitterCount)
{
// Single lowercased copy — all token checks share it.
std::string n = name;
std::transform(n.begin(), n.end(), n.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
M2ClassificationResult r;
// ---------------------------------------------------------------
// Geometry metrics
// ---------------------------------------------------------------
const glm::vec3 dims = boundsMax - boundsMin;
const float horiz = std::max(dims.x, dims.y);
const float vert = std::max(0.0f, dims.z);
const bool lowWide = (horiz > 1.4f && vert > 0.2f && vert < horiz * 0.70f);
const bool lowPlat = (horiz > 1.8f && vert > 0.2f && vert < 1.8f);
// ---------------------------------------------------------------
// Simple single-token flags
// ---------------------------------------------------------------
r.isInvisibleTrap = has(n, "invisibletrap");
r.isGroundDetail = has(n, "\\nodxt\\detail\\") || has(n, "\\detail\\");
r.isSmoke = has(n, "smoke");
r.isLavaModel = has(n, "forgelava") || has(n, "lavapot") || has(n, "lavaflow");
r.isInstancePortal = has(n, "instanceportal") || has(n, "instancenewportal")
|| has(n, "portalfx") || has(n, "spellportal");
r.isWaterVegetation = has(n, "cattail") || has(n, "reed") || has(n, "bulrush")
|| has(n, "seaweed") || has(n, "kelp") || has(n, "lilypad");
r.isElvenLike = has(n, "elf") || has(n, "elven") || has(n, "quel");
r.isLanternLike = has(n, "lantern") || has(n, "lamp") || has(n, "light");
r.isKoboldFlame = has(n, "kobold")
&& (has(n, "candle") || has(n, "torch") || has(n, "mine"));
// ---------------------------------------------------------------
// Collision: shape categories (mirrors original logic ordering)
// ---------------------------------------------------------------
const bool isPlanter = has(n, "planter");
const bool likelyCurb = isPlanter || has(n, "curb") || has(n, "base")
|| has(n, "ring") || has(n, "well");
const bool knownSwPlanter = has(n, "stormwindplanter")
|| has(n, "stormwindwindowplanter");
const bool bridgeName = has(n, "bridge") || has(n, "plank") || has(n, "walkway");
const bool statueName = has(n, "statue") || has(n, "monument") || has(n, "sculpture");
const bool sittable = has(n, "chair") || has(n, "bench") || has(n, "stool")
|| has(n, "seat") || has(n, "throne");
const bool smallSolid = (statueName && !sittable)
|| has(n, "crate") || has(n, "box")
|| has(n, "chest") || has(n, "barrel");
const bool chestName = has(n, "chest");
r.collisionSteppedFountain = has(n, "fountain");
r.collisionSteppedLowPlatform = !r.collisionSteppedFountain
&& (knownSwPlanter || bridgeName
|| (likelyCurb && (lowPlat || lowWide)));
r.collisionBridge = bridgeName;
r.collisionPlanter = isPlanter;
r.collisionStatue = statueName;
const bool narrowVertName = has(n, "lamp") || has(n, "lantern")
|| has(n, "post") || has(n, "pole");
const bool narrowVertShape = (horiz > 0.12f && horiz < 2.0f
&& vert > 2.2f && vert > horiz * 1.8f);
r.collisionNarrowVerticalProp = !r.collisionSteppedFountain
&& !r.collisionSteppedLowPlatform
&& (narrowVertName || narrowVertShape);
// ---------------------------------------------------------------
// Foliage token table (sorted alphabetically)
// ---------------------------------------------------------------
static constexpr auto kFoliageTokens = std::to_array<std::string_view>({
"algae", "bamboo", "banana", "branch", "bush",
"cactus", "canopy", "cattail", "coconut", "coral",
"corn", "crop", "dead-grass", "dead_grass", "deadgrass",
"dry-grass", "dry_grass", "drygrass",
"fern", "fireflies", "firefly", "fireflys",
"flower", "frond", "fungus", "gourd", "grass",
"hay", "hedge", "ivy", "kelp", "leaf",
"leaves", "lily", "melon", "moss", "mushroom",
"palm", "pumpkin", "reed", "root", "seaweed",
"shrub", "squash", "stalk", "thorn", "toadstool",
"vine", "watermelon", "weed", "wheat",
});
// "plant" is foliage unless "planter" is also present (planters are solid curbs).
const bool foliagePlant = has(n, "plant") && !isPlanter;
const bool foliageName = foliagePlant || hasAny(n, kFoliageTokens);
const bool treeLike = has(n, "tree");
const bool hardTreePart = has(n, "trunk") || has(n, "stump") || has(n, "log");
// Trees wide/tall enough to have a visible trunk → solid cylinder collision.
const bool treeWithTrunk = treeLike && !hardTreePart && !foliageName
&& horiz > 6.0f && vert > 4.0f;
const bool softTree = treeLike && !hardTreePart && !treeWithTrunk;
r.collisionTreeTrunk = treeWithTrunk;
const bool genericSolid = (horiz > 0.6f && horiz < 6.0f
&& vert > 0.30f && vert < 4.0f
&& vert > horiz * 0.16f) || statueName;
const bool curbLikeName = has(n, "curb") || has(n, "planter")
|| has(n, "ring") || has(n, "well") || has(n, "base");
const bool lowPlatLikeShape = lowWide || lowPlat;
r.collisionSmallSolidProp = !r.collisionSteppedFountain
&& !r.collisionSteppedLowPlatform
&& !r.collisionNarrowVerticalProp
&& !r.collisionTreeTrunk
&& !curbLikeName
&& !lowPlatLikeShape
&& (smallSolid
|| (genericSolid && !foliageName && !softTree));
const bool carpetOrRug = has(n, "carpet") || has(n, "rug");
const bool forceSolidCurb = r.collisionSteppedLowPlatform || knownSwPlanter
|| likelyCurb || r.collisionPlanter;
r.collisionNoBlock = (foliageName || softTree || carpetOrRug) && !forceSolidCurb;
// Ground-clutter detail cards are always non-blocking.
if (r.isGroundDetail) r.collisionNoBlock = true;
// ---------------------------------------------------------------
// Ambient creatures: fireflies, dragonflies, moths, butterflies
// ---------------------------------------------------------------
static constexpr auto kAmbientTokens = std::to_array<std::string_view>({
"butterfly", "dragonflies", "dragonfly",
"fireflies", "firefly", "fireflys", "moth",
});
const bool ambientCreature = hasAny(n, kAmbientTokens);
// ---------------------------------------------------------------
// Animation / foliage rendering flags
// ---------------------------------------------------------------
const bool foliageOrTree = foliageName || treeLike;
r.isFoliageLike = foliageOrTree && !ambientCreature;
r.disableAnimation = r.isFoliageLike || chestName;
r.shadowWindFoliage = r.isFoliageLike;
r.isFireflyEffect = ambientCreature;
// ---------------------------------------------------------------
// Spell effects (named tokens + particle-dominated geometry heuristic)
// ---------------------------------------------------------------
static constexpr auto kEffectTokens = std::to_array<std::string_view>({
"bubbles", "hazardlight", "instancenewportal", "instanceportal",
"lavabubble", "lavasplash", "lavasteam", "levelup",
"lightshaft", "mageportal", "particleemitter",
"spotlight", "volumetriclight", "wisps", "worldtreeportal",
});
r.isSpellEffect = hasAny(n, kEffectTokens)
|| (emitterCount >= 3 && vertexCount <= 200);
// Instance portals are spell effects too.
if (r.isInstancePortal) r.isSpellEffect = true;
return r;
}
// ---------------------------------------------------------------------------
// classifyBatchTexture
// ---------------------------------------------------------------------------
M2BatchTexClassification classifyBatchTexture(const std::string& lowerTexKey)
{
M2BatchTexClassification r;
// Exact paths for well-known lantern / lamp glow-card textures.
static constexpr auto kExactGlowTextures = std::to_array<std::string_view>({
"world\\azeroth\\karazahn\\passivedoodads\\bonfire\\flamelicksmallblue.blp",
"world\\expansion06\\doodads\\nightelf\\7ne_druid_streetlamp01_light.blp",
"world\\generic\\human\\passive doodads\\stormwind\\t_vfx_glow01_64.blp",
"world\\generic\\nightelf\\passive doodads\\lamps\\glowblue32.blp",
"world\\generic\\nightelf\\passive doodads\\magicalimplements\\glow.blp",
});
for (auto s : kExactGlowTextures)
if (lowerTexKey == s) { r.exactLanternGlowTex = true; break; }
static constexpr auto kGlowTokens = std::to_array<std::string_view>({
"flare", "glow", "halo", "light",
});
static constexpr auto kFlameTokens = std::to_array<std::string_view>({
"ember", "fire", "flame", "flamelick",
});
static constexpr auto kGlowCardTokens = std::to_array<std::string_view>({
"flamelick", "genericglow", "glow", "glowball",
"lensflare", "lightbeam", "t_vfx",
});
static constexpr auto kLikelyFlameTokens = std::to_array<std::string_view>({
"fire", "flame", "torch",
});
static constexpr auto kLanternFamilyTokens = std::to_array<std::string_view>({
"elf", "lamp", "lantern", "quel", "silvermoon", "thalas",
});
static constexpr auto kCoolTintTokens = std::to_array<std::string_view>({
"arcane", "blue", "nightelf",
});
static constexpr auto kRedTintTokens = std::to_array<std::string_view>({
"red", "ruby", "scarlet",
});
r.hasGlowToken = hasAny(lowerTexKey, kGlowTokens);
r.hasFlameToken = hasAny(lowerTexKey, kFlameTokens);
r.hasGlowCardToken = hasAny(lowerTexKey, kGlowCardTokens);
r.likelyFlame = hasAny(lowerTexKey, kLikelyFlameTokens);
r.lanternFamily = hasAny(lowerTexKey, kLanternFamilyTokens);
r.glowTint = hasAny(lowerTexKey, kCoolTintTokens) ? 1
: hasAny(lowerTexKey, kRedTintTokens) ? 2
: 0;
return r;
}
} // namespace rendering
} // namespace wowee

View file

@ -1,4 +1,5 @@
#include "rendering/m2_renderer.hpp"
#include "rendering/m2_model_classifier.hpp"
#include "rendering/vk_context.hpp"
#include "rendering/vk_buffer.hpp"
#include "rendering/vk_texture.hpp"
@ -1004,15 +1005,6 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
M2ModelGPU gpuModel;
gpuModel.name = model.name;
// Detect invisible trap models (event objects that should not render or collide)
std::string lowerName = model.name;
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
bool isInvisibleTrap = (lowerName.find("invisibletrap") != std::string::npos);
gpuModel.isInvisibleTrap = isInvisibleTrap;
if (isInvisibleTrap) {
LOG_INFO("Loading InvisibleTrap model: ", model.name, " (will be invisible, no collision)");
}
// Use tight bounds from actual vertices for collision/camera occlusion.
// Header bounds in some M2s are overly conservative.
glm::vec3 tightMin(0.0f);
@ -1025,165 +1017,40 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
tightMax = glm::max(tightMax, v.position);
}
}
bool foliageOrTreeLike = false;
bool chestName = false;
bool groundDetailModel = false;
{
std::string lowerName = model.name;
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
gpuModel.collisionSteppedFountain = (lowerName.find("fountain") != std::string::npos);
glm::vec3 dims = tightMax - tightMin;
float horiz = std::max(dims.x, dims.y);
float vert = std::max(0.0f, dims.z);
bool lowWideShape = (horiz > 1.4f && vert > 0.2f && vert < horiz * 0.70f);
bool likelyCurbName =
(lowerName.find("planter") != std::string::npos) ||
(lowerName.find("curb") != std::string::npos) ||
(lowerName.find("base") != std::string::npos) ||
(lowerName.find("ring") != std::string::npos) ||
(lowerName.find("well") != std::string::npos);
bool knownStormwindPlanter =
(lowerName.find("stormwindplanter") != std::string::npos) ||
(lowerName.find("stormwindwindowplanter") != std::string::npos);
bool lowPlatformShape = (horiz > 1.8f && vert > 0.2f && vert < 1.8f);
bool bridgeName =
(lowerName.find("bridge") != std::string::npos) ||
(lowerName.find("plank") != std::string::npos) ||
(lowerName.find("walkway") != std::string::npos);
gpuModel.collisionSteppedLowPlatform = (!gpuModel.collisionSteppedFountain) &&
(knownStormwindPlanter ||
bridgeName ||
(likelyCurbName && (lowPlatformShape || lowWideShape)));
gpuModel.collisionBridge = bridgeName;
bool isPlanter = (lowerName.find("planter") != std::string::npos);
gpuModel.collisionPlanter = isPlanter;
bool statueName =
(lowerName.find("statue") != std::string::npos) ||
(lowerName.find("monument") != std::string::npos) ||
(lowerName.find("sculpture") != std::string::npos);
gpuModel.collisionStatue = statueName;
// Sittable furniture: chairs/benches/stools cause players to get stuck against
// invisible bounding boxes; WMOs already handle room collision.
bool sittableFurnitureName =
(lowerName.find("chair") != std::string::npos) ||
(lowerName.find("bench") != std::string::npos) ||
(lowerName.find("stool") != std::string::npos) ||
(lowerName.find("seat") != std::string::npos) ||
(lowerName.find("throne") != std::string::npos);
bool smallSolidPropName =
(statueName && !sittableFurnitureName) ||
(lowerName.find("crate") != std::string::npos) ||
(lowerName.find("box") != std::string::npos) ||
(lowerName.find("chest") != std::string::npos) ||
(lowerName.find("barrel") != std::string::npos);
chestName = (lowerName.find("chest") != std::string::npos);
bool foliageName =
(lowerName.find("bush") != std::string::npos) ||
(lowerName.find("grass") != std::string::npos) ||
(lowerName.find("drygrass") != std::string::npos) ||
(lowerName.find("dry_grass") != std::string::npos) ||
(lowerName.find("dry-grass") != std::string::npos) ||
(lowerName.find("deadgrass") != std::string::npos) ||
(lowerName.find("dead_grass") != std::string::npos) ||
(lowerName.find("dead-grass") != std::string::npos) ||
((lowerName.find("plant") != std::string::npos) && !isPlanter) ||
(lowerName.find("flower") != std::string::npos) ||
(lowerName.find("shrub") != std::string::npos) ||
(lowerName.find("fern") != std::string::npos) ||
(lowerName.find("vine") != std::string::npos) ||
(lowerName.find("lily") != std::string::npos) ||
(lowerName.find("weed") != std::string::npos) ||
(lowerName.find("wheat") != std::string::npos) ||
(lowerName.find("pumpkin") != std::string::npos) ||
(lowerName.find("firefly") != std::string::npos) ||
(lowerName.find("fireflies") != std::string::npos) ||
(lowerName.find("fireflys") != std::string::npos) ||
(lowerName.find("mushroom") != std::string::npos) ||
(lowerName.find("fungus") != std::string::npos) ||
(lowerName.find("toadstool") != std::string::npos) ||
(lowerName.find("root") != std::string::npos) ||
(lowerName.find("branch") != std::string::npos) ||
(lowerName.find("thorn") != std::string::npos) ||
(lowerName.find("moss") != std::string::npos) ||
(lowerName.find("ivy") != std::string::npos) ||
(lowerName.find("seaweed") != std::string::npos) ||
(lowerName.find("kelp") != std::string::npos) ||
(lowerName.find("cattail") != std::string::npos) ||
(lowerName.find("reed") != std::string::npos) ||
(lowerName.find("palm") != std::string::npos) ||
(lowerName.find("bamboo") != std::string::npos) ||
(lowerName.find("banana") != std::string::npos) ||
(lowerName.find("coconut") != std::string::npos) ||
(lowerName.find("watermelon") != std::string::npos) ||
(lowerName.find("melon") != std::string::npos) ||
(lowerName.find("squash") != std::string::npos) ||
(lowerName.find("gourd") != std::string::npos) ||
(lowerName.find("canopy") != std::string::npos) ||
(lowerName.find("hedge") != std::string::npos) ||
(lowerName.find("cactus") != std::string::npos) ||
(lowerName.find("leaf") != std::string::npos) ||
(lowerName.find("leaves") != std::string::npos) ||
(lowerName.find("stalk") != std::string::npos) ||
(lowerName.find("corn") != std::string::npos) ||
(lowerName.find("crop") != std::string::npos) ||
(lowerName.find("hay") != std::string::npos) ||
(lowerName.find("frond") != std::string::npos) ||
(lowerName.find("algae") != std::string::npos) ||
(lowerName.find("coral") != std::string::npos);
bool treeLike = (lowerName.find("tree") != std::string::npos);
foliageOrTreeLike = (foliageName || treeLike);
groundDetailModel =
(lowerName.find("\\nodxt\\detail\\") != std::string::npos) ||
(lowerName.find("\\detail\\") != std::string::npos);
bool hardTreePart =
(lowerName.find("trunk") != std::string::npos) ||
(lowerName.find("stump") != std::string::npos) ||
(lowerName.find("log") != std::string::npos);
// Trees with visible trunks get collision. Threshold: canopy wider than 6
// model units AND taller than 4 units (filters out small bushes/saplings).
bool treeWithTrunk = treeLike && !hardTreePart && !foliageName && horiz > 6.0f && vert > 4.0f;
bool softTree = treeLike && !hardTreePart && !treeWithTrunk;
bool forceSolidCurb = gpuModel.collisionSteppedLowPlatform || knownStormwindPlanter || likelyCurbName || gpuModel.collisionPlanter;
bool narrowVerticalName =
(lowerName.find("lamp") != std::string::npos) ||
(lowerName.find("lantern") != std::string::npos) ||
(lowerName.find("post") != std::string::npos) ||
(lowerName.find("pole") != std::string::npos);
bool narrowVerticalShape =
(horiz > 0.12f && horiz < 2.0f && vert > 2.2f && vert > horiz * 1.8f);
gpuModel.collisionTreeTrunk = treeWithTrunk;
gpuModel.collisionNarrowVerticalProp =
!gpuModel.collisionSteppedFountain &&
!gpuModel.collisionSteppedLowPlatform &&
(narrowVerticalName || narrowVerticalShape);
bool genericSolidPropShape =
(horiz > 0.6f && horiz < 6.0f && vert > 0.30f && vert < 4.0f && vert > horiz * 0.16f) ||
statueName;
bool curbLikeName =
(lowerName.find("curb") != std::string::npos) ||
(lowerName.find("planter") != std::string::npos) ||
(lowerName.find("ring") != std::string::npos) ||
(lowerName.find("well") != std::string::npos) ||
(lowerName.find("base") != std::string::npos);
bool lowPlatformLikeShape = lowWideShape || lowPlatformShape;
bool carpetOrRug =
(lowerName.find("carpet") != std::string::npos) ||
(lowerName.find("rug") != std::string::npos);
gpuModel.collisionSmallSolidProp =
!gpuModel.collisionSteppedFountain &&
!gpuModel.collisionSteppedLowPlatform &&
!gpuModel.collisionNarrowVerticalProp &&
!gpuModel.collisionTreeTrunk &&
!curbLikeName &&
!lowPlatformLikeShape &&
(smallSolidPropName || (genericSolidPropShape && !foliageName && !softTree));
// Disable collision for foliage, soft trees, and decorative carpets/rugs
gpuModel.collisionNoBlock = ((foliageName || softTree || carpetOrRug) &&
!forceSolidCurb);
// Classify model from name and geometry — pure function, no GPU dependencies.
auto cls = classifyM2Model(model.name, tightMin, tightMax,
model.vertices.size(),
model.particleEmitters.size());
const bool isInvisibleTrap = cls.isInvisibleTrap;
const bool groundDetailModel = cls.isGroundDetail;
if (isInvisibleTrap) {
LOG_INFO("Loading InvisibleTrap model: ", model.name, " (will be invisible, no collision)");
}
gpuModel.isInvisibleTrap = cls.isInvisibleTrap;
gpuModel.collisionSteppedFountain = cls.collisionSteppedFountain;
gpuModel.collisionSteppedLowPlatform = cls.collisionSteppedLowPlatform;
gpuModel.collisionBridge = cls.collisionBridge;
gpuModel.collisionPlanter = cls.collisionPlanter;
gpuModel.collisionStatue = cls.collisionStatue;
gpuModel.collisionTreeTrunk = cls.collisionTreeTrunk;
gpuModel.collisionNarrowVerticalProp = cls.collisionNarrowVerticalProp;
gpuModel.collisionSmallSolidProp = cls.collisionSmallSolidProp;
gpuModel.collisionNoBlock = cls.collisionNoBlock;
gpuModel.isGroundDetail = cls.isGroundDetail;
gpuModel.isFoliageLike = cls.isFoliageLike;
gpuModel.disableAnimation = cls.disableAnimation;
gpuModel.shadowWindFoliage = cls.shadowWindFoliage;
gpuModel.isFireflyEffect = cls.isFireflyEffect;
gpuModel.isSmoke = cls.isSmoke;
gpuModel.isSpellEffect = cls.isSpellEffect;
gpuModel.isLavaModel = cls.isLavaModel;
gpuModel.isInstancePortal = cls.isInstancePortal;
gpuModel.isWaterVegetation = cls.isWaterVegetation;
gpuModel.isElvenLike = cls.isElvenLike;
gpuModel.isLanternLike = cls.isLanternLike;
gpuModel.isKoboldFlame = cls.isKoboldFlame;
gpuModel.boundMin = tightMin;
gpuModel.boundMax = tightMax;
gpuModel.boundRadius = model.boundRadius;
@ -1201,79 +1068,7 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
break;
}
}
bool ambientCreature =
(lowerName.find("firefly") != std::string::npos) ||
(lowerName.find("fireflies") != std::string::npos) ||
(lowerName.find("fireflys") != std::string::npos) ||
(lowerName.find("dragonfly") != std::string::npos) ||
(lowerName.find("dragonflies") != std::string::npos) ||
(lowerName.find("butterfly") != std::string::npos) ||
(lowerName.find("moth") != std::string::npos);
gpuModel.disableAnimation = (foliageOrTreeLike && !ambientCreature) || chestName;
gpuModel.shadowWindFoliage = foliageOrTreeLike && !ambientCreature;
gpuModel.isFoliageLike = foliageOrTreeLike && !ambientCreature;
gpuModel.isElvenLike =
(lowerName.find("elf") != std::string::npos) ||
(lowerName.find("elven") != std::string::npos) ||
(lowerName.find("quel") != std::string::npos);
gpuModel.isLanternLike =
(lowerName.find("lantern") != std::string::npos) ||
(lowerName.find("lamp") != std::string::npos) ||
(lowerName.find("light") != std::string::npos);
gpuModel.isKoboldFlame =
(lowerName.find("kobold") != std::string::npos) &&
((lowerName.find("candle") != std::string::npos) ||
(lowerName.find("torch") != std::string::npos) ||
(lowerName.find("mine") != std::string::npos));
gpuModel.isGroundDetail = groundDetailModel;
if (groundDetailModel) {
// Ground clutter (grass/pebbles/detail cards) should never block camera/movement.
gpuModel.collisionNoBlock = true;
}
// Spell effect / pure-visual models: particle-dominated with minimal geometry,
// or named effect models (light shafts, portals, emitters, spotlights)
bool effectByName =
(lowerName.find("lightshaft") != std::string::npos) ||
(lowerName.find("volumetriclight") != std::string::npos) ||
(lowerName.find("instanceportal") != std::string::npos) ||
(lowerName.find("instancenewportal") != std::string::npos) ||
(lowerName.find("mageportal") != std::string::npos) ||
(lowerName.find("worldtreeportal") != std::string::npos) ||
(lowerName.find("particleemitter") != std::string::npos) ||
(lowerName.find("bubbles") != std::string::npos) ||
(lowerName.find("spotlight") != std::string::npos) ||
(lowerName.find("hazardlight") != std::string::npos) ||
(lowerName.find("lavasplash") != std::string::npos) ||
(lowerName.find("lavabubble") != std::string::npos) ||
(lowerName.find("lavasteam") != std::string::npos) ||
(lowerName.find("wisps") != std::string::npos) ||
(lowerName.find("levelup") != std::string::npos);
gpuModel.isSpellEffect = effectByName ||
(hasParticles && model.vertices.size() <= 200 &&
model.particleEmitters.size() >= 3);
gpuModel.isLavaModel =
(lowerName.find("forgelava") != std::string::npos) ||
(lowerName.find("lavapot") != std::string::npos) ||
(lowerName.find("lavaflow") != std::string::npos);
gpuModel.isInstancePortal =
(lowerName.find("instanceportal") != std::string::npos) ||
(lowerName.find("instancenewportal") != std::string::npos) ||
(lowerName.find("portalfx") != std::string::npos) ||
(lowerName.find("spellportal") != std::string::npos);
// Instance portals are spell effects too (additive blend, no collision)
if (gpuModel.isInstancePortal) {
gpuModel.isSpellEffect = true;
}
// Water vegetation: cattails, reeds, bulrushes, kelp, seaweed, lilypad near water
gpuModel.isWaterVegetation =
(lowerName.find("cattail") != std::string::npos) ||
(lowerName.find("reed") != std::string::npos) ||
(lowerName.find("bulrush") != std::string::npos) ||
(lowerName.find("seaweed") != std::string::npos) ||
(lowerName.find("kelp") != std::string::npos) ||
(lowerName.find("lilypad") != std::string::npos);
// Ambient creature effects: particle-based glow (exempt from particle dampeners)
gpuModel.isFireflyEffect = ambientCreature;
// Build collision mesh + spatial grid from M2 bounding geometry
gpuModel.collision.vertices = model.collisionVertices;
@ -1284,14 +1079,6 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
" tris, grid ", gpuModel.collision.gridCellsX, "x", gpuModel.collision.gridCellsY);
}
// Flag smoke models for UV scroll animation (in addition to particle emitters)
{
std::string smokeName = model.name;
std::transform(smokeName.begin(), smokeName.end(), smokeName.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
gpuModel.isSmoke = (smokeName.find("smoke") != std::string::npos);
}
// Identify idle variation sequences (animation ID 0 = Stand)
for (int i = 0; i < static_cast<int>(model.sequences.size()); i++) {
if (model.sequences[i].id == 0 && model.sequences[i].duration > 0) {
@ -1412,14 +1199,7 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
static const bool kGlowDiag = envFlagEnabled("WOWEE_M2_GLOW_DIAG", false);
if (kGlowDiag) {
std::string lowerName = model.name;
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
const bool lanternLike =
(lowerName.find("lantern") != std::string::npos) ||
(lowerName.find("lamp") != std::string::npos) ||
(lowerName.find("light") != std::string::npos);
if (lanternLike) {
if (gpuModel.isLanternLike) {
for (size_t ti = 0; ti < model.textures.size(); ++ti) {
const std::string key = (ti < textureKeysLower.size()) ? textureKeysLower[ti] : std::string();
LOG_DEBUG("M2 GLOW TEX '", model.name, "' tex[", ti, "]='", key, "' flags=0x",
@ -1561,60 +1341,15 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
}
}
bgpu.texture = tex;
const bool exactLanternGlowTexture =
(batchTexKeyLower == "world\\expansion06\\doodads\\nightelf\\7ne_druid_streetlamp01_light.blp") ||
(batchTexKeyLower == "world\\generic\\nightelf\\passive doodads\\lamps\\glowblue32.blp") ||
(batchTexKeyLower == "world\\generic\\human\\passive doodads\\stormwind\\t_vfx_glow01_64.blp") ||
(batchTexKeyLower == "world\\azeroth\\karazahn\\passivedoodads\\bonfire\\flamelicksmallblue.blp") ||
(batchTexKeyLower == "world\\generic\\nightelf\\passive doodads\\magicalimplements\\glow.blp");
const bool texHasGlowToken =
(batchTexKeyLower.find("glow") != std::string::npos) ||
(batchTexKeyLower.find("flare") != std::string::npos) ||
(batchTexKeyLower.find("halo") != std::string::npos) ||
(batchTexKeyLower.find("light") != std::string::npos);
const bool texHasFlameToken =
(batchTexKeyLower.find("flame") != std::string::npos) ||
(batchTexKeyLower.find("fire") != std::string::npos) ||
(batchTexKeyLower.find("flamelick") != std::string::npos) ||
(batchTexKeyLower.find("ember") != std::string::npos);
const bool texGlowCardToken =
(batchTexKeyLower.find("glow") != std::string::npos) ||
(batchTexKeyLower.find("flamelick") != std::string::npos) ||
(batchTexKeyLower.find("lensflare") != std::string::npos) ||
(batchTexKeyLower.find("t_vfx") != std::string::npos) ||
(batchTexKeyLower.find("lightbeam") != std::string::npos) ||
(batchTexKeyLower.find("glowball") != std::string::npos) ||
(batchTexKeyLower.find("genericglow") != std::string::npos);
const bool texLikelyFlame =
(batchTexKeyLower.find("fire") != std::string::npos) ||
(batchTexKeyLower.find("flame") != std::string::npos) ||
(batchTexKeyLower.find("torch") != std::string::npos);
const bool texLanternFamily =
(batchTexKeyLower.find("lantern") != std::string::npos) ||
(batchTexKeyLower.find("lamp") != std::string::npos) ||
(batchTexKeyLower.find("elf") != std::string::npos) ||
(batchTexKeyLower.find("silvermoon") != std::string::npos) ||
(batchTexKeyLower.find("quel") != std::string::npos) ||
(batchTexKeyLower.find("thalas") != std::string::npos);
const bool modelLanternFamily =
(lowerName.find("lantern") != std::string::npos) ||
(lowerName.find("lamp") != std::string::npos) ||
(lowerName.find("light") != std::string::npos);
const auto tcls = classifyBatchTexture(batchTexKeyLower);
const bool modelLanternFamily = gpuModel.isLanternLike;
bgpu.lanternGlowHint =
exactLanternGlowTexture ||
((texHasGlowToken || (modelLanternFamily && texHasFlameToken)) &&
(texLanternFamily || modelLanternFamily) &&
(!texLikelyFlame || modelLanternFamily));
bgpu.glowCardLike = bgpu.lanternGlowHint && texGlowCardToken;
const bool texCoolTint =
(batchTexKeyLower.find("blue") != std::string::npos) ||
(batchTexKeyLower.find("nightelf") != std::string::npos) ||
(batchTexKeyLower.find("arcane") != std::string::npos);
const bool texRedTint =
(batchTexKeyLower.find("red") != std::string::npos) ||
(batchTexKeyLower.find("scarlet") != std::string::npos) ||
(batchTexKeyLower.find("ruby") != std::string::npos);
bgpu.glowTint = texCoolTint ? 1 : (texRedTint ? 2 : 0);
tcls.exactLanternGlowTex ||
((tcls.hasGlowToken || (modelLanternFamily && tcls.hasFlameToken)) &&
(tcls.lanternFamily || modelLanternFamily) &&
(!tcls.likelyFlame || modelLanternFamily));
bgpu.glowCardLike = bgpu.lanternGlowHint && tcls.hasGlowCardToken;
bgpu.glowTint = tcls.glowTint;
bool texHasAlpha = false;
if (tex != nullptr && tex != whiteTexture_.get()) {
auto ait = textureHasAlphaByPtr_.find(tex);
@ -1682,10 +1417,7 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
}
// Optional diagnostics for glow/light batches (disabled by default).
if (kGlowDiag &&
(lowerName.find("light") != std::string::npos ||
lowerName.find("lamp") != std::string::npos ||
lowerName.find("lantern") != std::string::npos)) {
if (kGlowDiag && gpuModel.isLanternLike) {
LOG_DEBUG("M2 GLOW DIAG '", model.name, "' batch ", gpuModel.batches.size(),
": blend=", bgpu.blendMode, " matFlags=0x",
std::hex, bgpu.materialFlags, std::dec,