Implement M2 texture animation (UV scrolling) for fountain water

Parse M2TextureTransform entries and texture transform lookups from the
M2 binary, then apply per-batch UV offsets in the vertex shader using
the existing animation time base and global sequence durations.
This commit is contained in:
Kelsi 2026-02-06 01:49:27 -08:00
parent 4bb2828b21
commit ad04da31c3
4 changed files with 83 additions and 1 deletions

View file

@ -109,6 +109,13 @@ struct M2Batch {
uint16_t submeshLevel = 0; // Submesh level (0=base, 1+=LOD/alternate mesh)
};
// Texture transform (UV animation) data
struct M2TextureTransform {
M2AnimationTrack translation; // UV translation keyframes
M2AnimationTrack rotation; // UV rotation keyframes (quat)
M2AnimationTrack scale; // UV scale keyframes
};
// Attachment point (bone-anchored position for weapons, effects, etc.)
struct M2Attachment {
uint32_t id; // 0=Head, 1=RightHand, 2=LeftHand, etc.
@ -139,6 +146,10 @@ struct M2Model {
std::vector<M2Texture> textures;
std::vector<uint16_t> textureLookup; // Batch texture index lookup
// Texture transforms (UV animation)
std::vector<M2TextureTransform> textureTransforms;
std::vector<uint16_t> textureTransformLookup;
// Attachment points (for weapon/effect anchoring)
std::vector<M2Attachment> attachments;
std::vector<uint16_t> attachmentLookup; // attachment ID → index

View file

@ -31,6 +31,7 @@ struct M2ModelGPU {
uint32_t indexStart = 0; // offset in indices (not bytes)
uint32_t indexCount = 0;
bool hasAlpha = false;
uint16_t textureAnimIndex = 0xFFFF; // 0xFFFF = no texture animation
};
GLuint vao = 0;
@ -61,6 +62,11 @@ struct M2ModelGPU {
bool hasAnimation = false; // True if any bone has keyframes
bool isSmoke = false; // True for smoke models (UV scroll animation)
bool disableAnimation = false; // Keep foliage/tree doodads visually stable
bool hasTextureAnimation = false; // True if any batch has UV animation
// Texture transform data for UV animation
std::vector<pipeline::M2TextureTransform> textureTransforms;
std::vector<uint16_t> textureTransformLookup;
std::vector<int> idleVariationIndices; // Sequence indices for idle variations (animId 0)
bool isValid() const { return vao != 0 && indexCount > 0; }

View file

@ -210,6 +210,13 @@ struct CompressedQuat {
int16_t x, y, z, w;
};
// M2 texture transform (on-disk, 3 × M2TrackDisk = 60 bytes)
struct M2TextureTransformDisk {
M2TrackDisk translation; // 20
M2TrackDisk rotation; // 20
M2TrackDisk scaling; // 20
};
// M2 attachment point (on-disk)
struct M2AttachmentDisk {
uint32_t id;
@ -511,6 +518,35 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
model.textureLookup = readArray<uint16_t>(m2Data, header.ofsTexLookup, header.nTexLookup);
}
// Read texture transforms (UV animation data)
if (header.nUVAnimation > 0 && header.ofsUVAnimation > 0) {
// Build per-sequence flags for skipping external .anim data
std::vector<uint32_t> seqFlags;
seqFlags.reserve(model.sequences.size());
for (const auto& seq : model.sequences) {
seqFlags.push_back(seq.flags);
}
model.textureTransforms.reserve(header.nUVAnimation);
for (uint32_t i = 0; i < header.nUVAnimation; i++) {
uint32_t ofs = header.ofsUVAnimation + i * sizeof(M2TextureTransformDisk);
if (ofs + sizeof(M2TextureTransformDisk) > m2Data.size()) break;
M2TextureTransformDisk dt = readValue<M2TextureTransformDisk>(m2Data, ofs);
M2TextureTransform tt;
parseAnimTrack(m2Data, dt.translation, tt.translation, TrackType::VEC3, seqFlags);
parseAnimTrack(m2Data, dt.rotation, tt.rotation, TrackType::QUAT_COMPRESSED, seqFlags);
parseAnimTrack(m2Data, dt.scaling, tt.scale, TrackType::VEC3, seqFlags);
model.textureTransforms.push_back(std::move(tt));
}
core::Logger::getInstance().debug(" Texture transforms: ", model.textureTransforms.size());
}
// Read texture transform lookup (nTransLookup)
if (header.nTransLookup > 0 && header.ofsTransLookup > 0) {
model.textureTransformLookup = readArray<uint16_t>(m2Data, header.ofsTransLookup, header.nTransLookup);
}
// Read attachment points
if (header.nAttachments > 0 && header.ofsAttachments > 0) {
auto diskAttachments = readArray<M2AttachmentDisk>(m2Data, header.ofsAttachments, header.nAttachments);

View file

@ -219,6 +219,7 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) {
uniform mat4 uProjection;
uniform bool uUseBones;
uniform mat4 uBones[128];
uniform vec2 uUVOffset;
out vec3 FragPos;
out vec3 Normal;
out vec2 TexCoord;
@ -240,7 +241,7 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) {
vec4 worldPos = uModel * vec4(pos, 1.0);
FragPos = worldPos.xyz;
Normal = mat3(uModel) * norm;
TexCoord = aTexCoord;
TexCoord = aTexCoord + uUVOffset;
gl_Position = uProjection * uView * worldPos;
}
@ -710,6 +711,11 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
}
}
// Copy texture transform data for UV animation
gpuModel.textureTransforms = model.textureTransforms;
gpuModel.textureTransformLookup = model.textureTransformLookup;
gpuModel.hasTextureAnimation = false;
// Build per-batch GPU entries
if (!model.batches.empty()) {
for (const auto& batch : model.batches) {
@ -717,6 +723,12 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
bgpu.indexStart = batch.indexStart;
bgpu.indexCount = batch.indexCount;
// Store texture animation index from batch
bgpu.textureAnimIndex = batch.textureAnimIndex;
if (bgpu.textureAnimIndex != 0xFFFF) {
gpuModel.hasTextureAnimation = true;
}
// Resolve texture: batch.textureIndex → textureLookup → allTextures
GLuint tex = whiteTexture;
if (batch.textureIndex < model.textureLookup.size()) {
@ -1224,6 +1236,23 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm::
for (const auto& batch : model.batches) {
if (batch.indexCount == 0) continue;
// Compute UV offset for texture animation
glm::vec2 uvOffset(0.0f, 0.0f);
if (batch.textureAnimIndex != 0xFFFF && model.hasTextureAnimation) {
uint16_t lookupIdx = batch.textureAnimIndex;
if (lookupIdx < model.textureTransformLookup.size()) {
uint16_t transformIdx = model.textureTransformLookup[lookupIdx];
if (transformIdx < model.textureTransforms.size()) {
const auto& tt = model.textureTransforms[transformIdx];
glm::vec3 trans = interpVec3(tt.translation,
instance.currentSequenceIndex, instance.animTime,
glm::vec3(0.0f), model.globalSequenceDurations);
uvOffset = glm::vec2(trans.x, trans.y);
}
}
}
shader->setUniform("uUVOffset", uvOffset);
bool hasTexture = (batch.texture != 0);
shader->setUniform("uHasTexture", hasTexture);
shader->setUniform("uAlphaTest", batch.hasAlpha);