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.
This commit is contained in:
Kelsi 2026-02-04 14:06:59 -08:00
parent 6ca9e9024a
commit 11a4958e84
5 changed files with 114 additions and 38 deletions

View file

@ -132,6 +132,7 @@ struct M2Model {
// Skeletal animation
std::vector<M2Bone> bones;
std::vector<M2Sequence> sequences;
std::vector<uint32_t> globalSequenceDurations; // Per-global-sequence loop durations (ms)
// Rendering
std::vector<M2Batch> batches;

View file

@ -56,7 +56,9 @@ struct M2ModelGPU {
// Skeletal animation data (kept from M2Model for bone computation)
std::vector<pipeline::M2Bone> bones;
std::vector<pipeline::M2Sequence> sequences;
std::vector<uint32_t> 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<int> idleVariationIndices; // Sequence indices for idle variations (animId 0)
bool isValid() const { return vao != 0 && indexCount > 0; }

View file

@ -422,6 +422,13 @@ M2Model M2Loader::load(const std::vector<uint8_t>& 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<uint32_t>(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

View file

@ -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<float> {
// 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<float> {
std::optional<float> terrainH;
std::optional<float> 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<float> wmoH;
std::optional<float> 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)) {

View file

@ -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<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) {
@ -723,18 +746,38 @@ static int findKeyframeIndex(const std::vector<uint32_t>& timestamps, float time
return static_cast<int>(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<uint32_t>& globalSeqDurations,
int& outSeqIdx, float& outTime) {
if (track.globalSequence >= 0 &&
static_cast<size_t>(track.globalSequence) < globalSeqDurations.size()) {
// Global sequence: always use sub-array 0, wrap time at global duration
outSeqIdx = 0;
float dur = static_cast<float>(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<uint32_t>& globalSeqDurations) {
if (!track.hasData()) return def;
if (seqIdx < 0 || seqIdx >= static_cast<int>(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<int>(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<size_t>(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<float>(keys.timestamps[i0]);
float t1 = static_cast<float>(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<uint32_t>& globalSeqDurations) {
glm::quat identity(1.0f, 0.0f, 0.0f, 0.0f);
if (!track.hasData()) return identity;
if (seqIdx < 0 || seqIdx >= static_cast<int>(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<int>(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<size_t>(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<float>(keys.timestamps[i0]);
float t1 = static_cast<float>(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);
}
}