Fix M2 animated instance flashing (deer/bird/critter pop-in)

Root cause: bonesDirty was a single bool shared across both
double-buffered frame indices. When bones were copied to frame 0's
SSBO and bonesDirty cleared, frame 1's newly-allocated SSBO would
contain garbage/zeros and never get populated — causing animated
M2 instances to flash invisible on alternating frames.

Fix: Make bonesDirty per-frame-index (bool[2]) so each buffer
independently tracks whether it needs bone data uploaded. When
bones are recomputed, both indices are marked dirty. When uploaded
during render, only the current frame index is cleared. New buffer
allocations in prepareRender force their frame index dirty.
This commit is contained in:
Kelsi 2026-03-07 22:47:07 -08:00
parent 6cf08fbaa6
commit ac3c90dd75
2 changed files with 13 additions and 7 deletions

View file

@ -194,7 +194,7 @@ struct M2Instance {
// Frame-skip optimization (update distant animations less frequently)
uint8_t frameSkipCounter = 0;
bool bonesDirty = false; // Set when bones recomputed, cleared after upload
bool bonesDirty[2] = {false, false}; // Per-frame-index: set when bones recomputed, cleared after upload
// Per-instance bone SSBO (double-buffered)
::VkBuffer boneBuffer[2] = {};

View file

@ -1687,7 +1687,7 @@ uint32_t M2Renderer::createInstance(uint32_t modelId, const glm::vec3& position,
for (const auto& existing : instances) {
if (existing.modelId == modelId && !existing.boneMatrices.empty()) {
instance.boneMatrices = existing.boneMatrices;
instance.bonesDirty = true;
instance.bonesDirty[0] = instance.bonesDirty[1] = true;
break;
}
}
@ -1791,7 +1791,7 @@ uint32_t M2Renderer::createInstanceWithMatrix(uint32_t modelId, const glm::mat4&
for (const auto& existing : instances) {
if (existing.modelId == modelId && !existing.boneMatrices.empty()) {
instance.boneMatrices = existing.boneMatrices;
instance.bonesDirty = true;
instance.bonesDirty[0] = instance.bonesDirty[1] = true;
break;
}
}
@ -1951,7 +1951,7 @@ static void computeBoneMatrices(const M2ModelGPU& model, M2Instance& instance) {
instance.boneMatrices[i] = local;
}
}
instance.bonesDirty = true;
instance.bonesDirty[0] = instance.bonesDirty[1] = true;
}
void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::mat4& viewProjection) {
@ -2237,6 +2237,11 @@ void M2Renderer::prepareRender(uint32_t frameIndex, const Camera& camera) {
&instance.boneBuffer[frameIndex], &instance.boneAlloc[frameIndex], &allocInfo);
instance.boneMapped[frameIndex] = allocInfo.pMappedData;
// Force dirty so current boneMatrices get copied into this
// newly-allocated buffer during render (prevents garbage/zero
// bones when the other frame index already cleared bonesDirty).
instance.bonesDirty[frameIndex] = true;
instance.boneSet[frameIndex] = allocateBoneSet();
if (instance.boneSet[frameIndex]) {
VkDescriptorBufferInfo bufInfo{};
@ -2426,12 +2431,13 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
}
bool useBones = needsBones;
if (useBones) {
// Upload bone matrices only when recomputed (skip frame-skipped instances)
if (instance.bonesDirty && instance.boneMapped[frameIndex]) {
// Upload bone matrices only when recomputed (per-frame-index tracking
// ensures both double-buffered SSBOs get the latest bone data)
if (instance.bonesDirty[frameIndex] && instance.boneMapped[frameIndex]) {
int numBones = std::min(static_cast<int>(instance.boneMatrices.size()), 128);
memcpy(instance.boneMapped[frameIndex], instance.boneMatrices.data(),
numBones * sizeof(glm::mat4));
instance.bonesDirty = false;
instance.bonesDirty[frameIndex] = false;
}
// Bind bone descriptor set (set 2)