From 11a4958e8490d5c2f322a1a8d8c0ac0fd5f16646 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 4 Feb 2026 14:06:59 -0800 Subject: [PATCH] Add M2 global sequence animation, smoke UV scroll, and fix WMO floor detection - Parse global sequence durations from M2 binary and use them in bone interpolation so torches, candles, and other env doodads animate. - Add UV scroll shader effect for smoke models (HouseSmoke, SmokeStack) as a workaround for unimplemented M2 particle emitters. - Tighten WMO floor probe heights to prevent multi-story buildings from returning the wrong floor, fixing player clipping through inn floors and camera locking onto the second floor. - Use player ground level as reference for camera orbit floor collision so the camera doesn't fight upper floors in buildings. --- include/pipeline/m2_loader.hpp | 1 + include/rendering/m2_renderer.hpp | 2 + src/pipeline/m2_loader.cpp | 7 ++ src/rendering/camera_controller.cpp | 38 +++++----- src/rendering/m2_renderer.cpp | 104 ++++++++++++++++++++++------ 5 files changed, 114 insertions(+), 38 deletions(-) diff --git a/include/pipeline/m2_loader.hpp b/include/pipeline/m2_loader.hpp index 9a7e52ae..f13db470 100644 --- a/include/pipeline/m2_loader.hpp +++ b/include/pipeline/m2_loader.hpp @@ -132,6 +132,7 @@ struct M2Model { // Skeletal animation std::vector bones; std::vector sequences; + std::vector globalSequenceDurations; // Per-global-sequence loop durations (ms) // Rendering std::vector batches; diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 0de1c2ec..06be404a 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -56,7 +56,9 @@ struct M2ModelGPU { // Skeletal animation data (kept from M2Model for bone computation) std::vector bones; std::vector sequences; + std::vector globalSequenceDurations; // Loop durations for global sequence tracks bool hasAnimation = false; // True if any bone has keyframes + bool isSmoke = false; // True for smoke models (UV scroll animation) std::vector idleVariationIndices; // Sequence indices for idle variations (animId 0) bool isValid() const { return vao != 0 && indexCount > 0; } diff --git a/src/pipeline/m2_loader.cpp b/src/pipeline/m2_loader.cpp index eb0ae976..52b77c83 100644 --- a/src/pipeline/m2_loader.cpp +++ b/src/pipeline/m2_loader.cpp @@ -422,6 +422,13 @@ M2Model M2Loader::load(const std::vector& m2Data) { core::Logger::getInstance().debug(" Animation sequences: ", model.sequences.size()); } + // Read global sequence durations (used by environmental animations: smoke, fire, etc.) + if (header.nGlobalSequences > 0 && header.ofsGlobalSequences > 0) { + model.globalSequenceDurations = readArray(m2Data, + header.ofsGlobalSequences, header.nGlobalSequences); + core::Logger::getInstance().debug(" Global sequences: ", model.globalSequenceDurations.size()); + } + // Read bones with full animation track data if (header.nBones > 0 && header.ofsBones > 0) { // Verify we have enough data for the full bone structures diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 63de3c80..bea8bc5f 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -269,7 +269,7 @@ void CameraController::update(float deltaTime) { floorH = terrainManager->getHeightAt(targetPos.x, targetPos.y); } if (wmoRenderer) { - auto wh = wmoRenderer->getFloorHeight(targetPos.x, targetPos.y, targetPos.z + 5.0f); + auto wh = wmoRenderer->getFloorHeight(targetPos.x, targetPos.y, targetPos.z + 2.0f); if (wh && (!floorH || *wh > *floorH)) floorH = wh; } if (m2Renderer) { @@ -410,13 +410,13 @@ void CameraController::update(float deltaTime) { if (terrainManager) { terrainH = terrainManager->getHeightAt(x, y); } + float stepUpBudget = grounded ? 1.6f : 1.2f; if (wmoRenderer) { - wmoH = wmoRenderer->getFloorHeight(x, y, targetPos.z + 5.0f); + wmoH = wmoRenderer->getFloorHeight(x, y, targetPos.z + stepUpBudget + 0.5f); } if (m2Renderer) { m2H = m2Renderer->getFloorHeight(x, y, targetPos.z); } - float stepUpBudget = grounded ? 1.6f : 1.2f; auto base = selectReachableFloor(terrainH, wmoH, targetPos.z, stepUpBudget); bool fromM2 = false; if (m2H && *m2H <= targetPos.z + stepUpBudget && (!base || *m2H > *base)) { @@ -499,14 +499,17 @@ void CameraController::update(float deltaTime) { if (terrainManager) { terrainH = terrainManager->getHeightAt(x, y); } - float probeZ = std::max(targetPos.z, lastGroundZ) + 6.0f; + float stepUpBudget = grounded ? 1.6f : 1.2f; + // WMO probe: keep tight so multi-story buildings return the + // current floor, not a ceiling/upper floor the player can't reach. + float wmoProbeZ = std::max(targetPos.z, lastGroundZ) + stepUpBudget + 0.5f; + float m2ProbeZ = std::max(targetPos.z, lastGroundZ) + 6.0f; if (wmoRenderer) { - wmoH = wmoRenderer->getFloorHeight(x, y, probeZ); + wmoH = wmoRenderer->getFloorHeight(x, y, wmoProbeZ); } if (m2Renderer) { - m2H = m2Renderer->getFloorHeight(x, y, probeZ); + m2H = m2Renderer->getFloorHeight(x, y, m2ProbeZ); } - float stepUpBudget = grounded ? 1.6f : 1.2f; auto base = selectReachableFloor(terrainH, wmoH, targetPos.z, stepUpBudget); if (m2H && *m2H <= targetPos.z + stepUpBudget && (!base || *m2H > *base)) { base = m2H; @@ -572,19 +575,19 @@ void CameraController::update(float deltaTime) { // Find max safe distance using raycast + sphere radius collisionDistance = currentDistance; - // Helper to get floor height - auto getFloorAt = [&](float x, float y, float z) -> std::optional { + // Helper to get floor height for camera collision. + // Use the player's ground level as reference to avoid locking the camera + // to upper floors in multi-story buildings. + auto getFloorAt = [&](float x, float y, float /*z*/) -> std::optional { std::optional terrainH; std::optional wmoH; if (terrainManager) { terrainH = terrainManager->getHeightAt(x, y); } if (wmoRenderer) { - wmoH = wmoRenderer->getFloorHeight(x, y, z + 5.0f); + wmoH = wmoRenderer->getFloorHeight(x, y, lastGroundZ + 2.5f); } - // Camera floor clamp must allow larger step-up on ramps/stairs. - // Too-small limits let the camera slip below rising ground and see through floors. - return selectReachableFloor(terrainH, wmoH, z, 2.0f); + return selectReachableFloor(terrainH, wmoH, lastGroundZ, 2.0f); }; // Raycast against WMO bounding boxes @@ -697,7 +700,7 @@ void CameraController::update(float deltaTime) { std::optional wmoH; std::optional m2H; if (terrainManager) terrainH = terrainManager->getHeightAt(newPos.x, newPos.y); - if (wmoRenderer) wmoH = wmoRenderer->getFloorHeight(newPos.x, newPos.y, feetZ + 6.0f); + if (wmoRenderer) wmoH = wmoRenderer->getFloorHeight(newPos.x, newPos.y, feetZ + 2.0f); if (m2Renderer) m2H = m2Renderer->getFloorHeight(newPos.x, newPos.y, feetZ + 1.0f); auto floorH = selectHighestFloor(terrainH, wmoH, m2H); constexpr float MIN_SWIM_WATER_DEPTH = 1.8f; @@ -804,12 +807,13 @@ void CameraController::update(float deltaTime) { terrainH = terrainManager->getHeightAt(x, y); } float feetZ = newPos.z - eyeHeight; - float probeZ = std::max(feetZ, lastGroundZ) + 6.0f; + float wmoProbeZ = std::max(feetZ, lastGroundZ) + 1.5f; + float m2ProbeZ = std::max(feetZ, lastGroundZ) + 6.0f; if (wmoRenderer) { - wmoH = wmoRenderer->getFloorHeight(x, y, probeZ); + wmoH = wmoRenderer->getFloorHeight(x, y, wmoProbeZ); } if (m2Renderer) { - m2H = m2Renderer->getFloorHeight(x, y, probeZ); + m2H = m2Renderer->getFloorHeight(x, y, m2ProbeZ); } auto base = selectReachableFloor(terrainH, wmoH, feetZ, 1.0f); if (m2H && *m2H <= feetZ + 1.0f && (!base || *m2H > *base)) { diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index b1c1795f..d974d287 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -218,6 +218,7 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) { uniform mat4 uProjection; uniform bool uUseBones; uniform mat4 uBones[128]; + uniform float uScrollSpeed; // >0 for smoke UV scroll, 0 for normal out vec3 FragPos; out vec3 Normal; @@ -240,7 +241,9 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) { vec4 worldPos = uModel * vec4(pos, 1.0); FragPos = worldPos.xyz; Normal = mat3(uModel) * norm; - TexCoord = aTexCoord; + + // Scroll UV for rising smoke effect (scroll both axes for diagonal drift) + TexCoord = vec2(aTexCoord.x - uScrollSpeed, aTexCoord.y - uScrollSpeed * 0.3); gl_Position = uProjection * uView * worldPos; } @@ -258,6 +261,7 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) { uniform bool uHasTexture; uniform bool uAlphaTest; uniform float uFadeAlpha; + uniform float uScrollSpeed; // >0 for smoke out vec4 FragColor; @@ -269,13 +273,19 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) { texColor = vec4(0.6, 0.5, 0.4, 1.0); // Fallback brownish } - // Alpha test for leaves, fences, etc. - if (uAlphaTest && texColor.a < 0.5) { + bool isSmoke = (uScrollSpeed > 0.0); + + // Alpha test for leaves, fences, etc. (skip for smoke) + if (uAlphaTest && !isSmoke && texColor.a < 0.5) { discard; } // Distance fade - discard nearly invisible fragments float finalAlpha = texColor.a * uFadeAlpha; + if (isSmoke) { + // Very soft alpha so the 4-sided box mesh blends into a smooth plume + finalAlpha *= 0.25; + } if (finalAlpha < 0.02) { discard; } @@ -290,6 +300,10 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) { vec3 diffuse = diff * texColor.rgb; vec3 result = ambient + diffuse; + if (isSmoke) { + // Lighten smoke color to look like wispy gray smoke + result = mix(result, vec3(0.7, 0.7, 0.72), 0.5); + } FragColor = vec4(result, finalAlpha); } )"; @@ -467,6 +481,7 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { // Store bone/sequence data for animation gpuModel.bones = model.bones; gpuModel.sequences = model.sequences; + gpuModel.globalSequenceDurations = model.globalSequenceDurations; gpuModel.hasAnimation = false; for (const auto& bone : model.bones) { if (bone.translation.hasData() || bone.rotation.hasData() || bone.scale.hasData()) { @@ -475,6 +490,14 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { } } + // Flag smoke models for UV scroll animation (particle emitters not implemented) + { + std::string smokeName = model.name; + std::transform(smokeName.begin(), smokeName.end(), smokeName.begin(), + [](unsigned char c) { return static_cast(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(model.sequences.size()); i++) { if (model.sequences[i].id == 0 && model.sequences[i].duration > 0) { @@ -723,18 +746,38 @@ static int findKeyframeIndex(const std::vector& timestamps, float time return static_cast(timestamps.size() - 2); } +// Resolve sequence index and time for a track, handling global sequences. +static void resolveTrackTime(const pipeline::M2AnimationTrack& track, + int seqIdx, float time, + const std::vector& globalSeqDurations, + int& outSeqIdx, float& outTime) { + if (track.globalSequence >= 0 && + static_cast(track.globalSequence) < globalSeqDurations.size()) { + // Global sequence: always use sub-array 0, wrap time at global duration + outSeqIdx = 0; + float dur = static_cast(globalSeqDurations[track.globalSequence]); + outTime = (dur > 0.0f) ? std::fmod(time, dur) : 0.0f; + } else { + outSeqIdx = seqIdx; + outTime = time; + } +} + static glm::vec3 interpVec3(const pipeline::M2AnimationTrack& track, - int seqIdx, float time, const glm::vec3& def) { + int seqIdx, float time, const glm::vec3& def, + const std::vector& globalSeqDurations) { if (!track.hasData()) return def; - if (seqIdx < 0 || seqIdx >= static_cast(track.sequences.size())) return def; - const auto& keys = track.sequences[seqIdx]; + int si; float t; + resolveTrackTime(track, seqIdx, time, globalSeqDurations, si, t); + if (si < 0 || si >= static_cast(track.sequences.size())) return def; + const auto& keys = track.sequences[si]; if (keys.timestamps.empty() || keys.vec3Values.empty()) return def; auto safe = [&](const glm::vec3& v) -> glm::vec3 { if (std::isnan(v.x) || std::isnan(v.y) || std::isnan(v.z)) return def; return v; }; if (keys.vec3Values.size() == 1) return safe(keys.vec3Values[0]); - int idx = findKeyframeIndex(keys.timestamps, time); + int idx = findKeyframeIndex(keys.timestamps, t); if (idx < 0) return def; size_t i0 = static_cast(idx); size_t i1 = std::min(i0 + 1, keys.vec3Values.size() - 1); @@ -742,16 +785,19 @@ static glm::vec3 interpVec3(const pipeline::M2AnimationTrack& track, float t0 = static_cast(keys.timestamps[i0]); float t1 = static_cast(keys.timestamps[i1]); float dur = t1 - t0; - float t = (dur > 0.0f) ? glm::clamp((time - t0) / dur, 0.0f, 1.0f) : 0.0f; - return safe(glm::mix(keys.vec3Values[i0], keys.vec3Values[i1], t)); + float frac = (dur > 0.0f) ? glm::clamp((t - t0) / dur, 0.0f, 1.0f) : 0.0f; + return safe(glm::mix(keys.vec3Values[i0], keys.vec3Values[i1], frac)); } static glm::quat interpQuat(const pipeline::M2AnimationTrack& track, - int seqIdx, float time) { + int seqIdx, float time, + const std::vector& globalSeqDurations) { glm::quat identity(1.0f, 0.0f, 0.0f, 0.0f); if (!track.hasData()) return identity; - if (seqIdx < 0 || seqIdx >= static_cast(track.sequences.size())) return identity; - const auto& keys = track.sequences[seqIdx]; + int si; float t; + resolveTrackTime(track, seqIdx, time, globalSeqDurations, si, t); + if (si < 0 || si >= static_cast(track.sequences.size())) return identity; + const auto& keys = track.sequences[si]; if (keys.timestamps.empty() || keys.quatValues.empty()) return identity; auto safe = [&](const glm::quat& q) -> glm::quat { float len = glm::length(q); @@ -759,7 +805,7 @@ static glm::quat interpQuat(const pipeline::M2AnimationTrack& track, return q; }; if (keys.quatValues.size() == 1) return safe(keys.quatValues[0]); - int idx = findKeyframeIndex(keys.timestamps, time); + int idx = findKeyframeIndex(keys.timestamps, t); if (idx < 0) return identity; size_t i0 = static_cast(idx); size_t i1 = std::min(i0 + 1, keys.quatValues.size() - 1); @@ -767,20 +813,21 @@ static glm::quat interpQuat(const pipeline::M2AnimationTrack& track, float t0 = static_cast(keys.timestamps[i0]); float t1 = static_cast(keys.timestamps[i1]); float dur = t1 - t0; - float t = (dur > 0.0f) ? glm::clamp((time - t0) / dur, 0.0f, 1.0f) : 0.0f; - return glm::slerp(safe(keys.quatValues[i0]), safe(keys.quatValues[i1]), t); + float frac = (dur > 0.0f) ? glm::clamp((t - t0) / dur, 0.0f, 1.0f) : 0.0f; + return glm::slerp(safe(keys.quatValues[i0]), safe(keys.quatValues[i1]), frac); } static void computeBoneMatrices(const M2ModelGPU& model, M2Instance& instance) { size_t numBones = std::min(model.bones.size(), size_t(128)); if (numBones == 0) return; instance.boneMatrices.resize(numBones); + const auto& gsd = model.globalSequenceDurations; for (size_t i = 0; i < numBones; i++) { const auto& bone = model.bones[i]; - glm::vec3 trans = interpVec3(bone.translation, instance.currentSequenceIndex, instance.animTime, glm::vec3(0.0f)); - glm::quat rot = interpQuat(bone.rotation, instance.currentSequenceIndex, instance.animTime); - glm::vec3 scl = interpVec3(bone.scale, instance.currentSequenceIndex, instance.animTime, glm::vec3(1.0f)); + glm::vec3 trans = interpVec3(bone.translation, instance.currentSequenceIndex, instance.animTime, glm::vec3(0.0f), gsd); + glm::quat rot = interpQuat(bone.rotation, instance.currentSequenceIndex, instance.animTime, gsd); + glm::vec3 scl = interpVec3(bone.scale, instance.currentSequenceIndex, instance.animTime, glm::vec3(1.0f), gsd); // Sanity check scale to avoid degenerate matrices if (scl.x < 0.001f) scl.x = 1.0f; @@ -941,6 +988,11 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: shader->setUniform("uModel", instance.modelMatrix); shader->setUniform("uFadeAlpha", fadeAlpha); + // UV scroll for smoke models: pass pre-computed scroll offset + bool isSmoke = model.isSmoke; + float scrollSpeed = isSmoke ? (instance.animTime / 1000.0f * 0.15f) : 0.0f; + shader->setUniform("uScrollSpeed", scrollSpeed); + // Upload bone matrices if model has skeletal animation bool useBones = model.hasAnimation && !instance.boneMatrices.empty(); shader->setUniform("uUseBones", useBones); @@ -949,11 +1001,16 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: shader->setUniformMatrixArray("uBones[0]", instance.boneMatrices.data(), numBones); } - // Disable depth writes for fading objects to avoid z-fighting - if (fadeAlpha < 1.0f) { + // Disable depth writes for fading objects and smoke to avoid z-fighting + if (fadeAlpha < 1.0f || isSmoke) { glDepthMask(GL_FALSE); } + // Additive blending for smoke + if (isSmoke) { + glBlendFunc(GL_SRC_ALPHA, GL_ONE); + } + glBindVertexArray(model.vao); for (const auto& batch : model.batches) { @@ -977,7 +1034,12 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: glBindVertexArray(0); - if (fadeAlpha < 1.0f) { + // Restore blending mode after smoke + if (isSmoke) { + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + } + + if (fadeAlpha < 1.0f || isSmoke) { glDepthMask(GL_TRUE); } }