Fix M2 particle rendering: color, gravity, transparency, and animation

- Fix FBlock color keys from 3-byte BGR to 4-byte RGBA (CImVector) to
  prevent garbled purple/red colors from byte misalignment
- Add circular soft-edge falloff in particle fragment shader (GL_POINTS
  rendered as squares by default)
- Apply default gravity (4.0 spray, 1.5 mist) when M2 gravity is 0 since
  bone animation from .anim files isn't loaded yet
- Add drift velocity to speed=0 emitters so particles spread as mist
  instead of clustering at static bone positions
- Run particle updates for all nearby instances, not just those in
  boneWorkIndices_, to prevent particles freezing when bone culled
- Wrap animation time for particle models to keep emission tracks looping
- Cap particle scale to 1.5 and reduce point size multiplier (800→400)
- Desaturate FBlock colors 70% toward white for natural water appearance
- Reduce additive blend alpha to 5% and volume particles to 2%
This commit is contained in:
Kelsi 2026-02-16 02:12:43 -08:00
parent 35034ca544
commit bbcc18aa22
2 changed files with 76 additions and 20 deletions

View file

@ -572,7 +572,7 @@ void parseAnimTrackVanilla(const std::vector<uint8_t>& data,
// FBlocks are like M2Track but WITHOUT the interpolationType/globalSequence prefix. // FBlocks are like M2Track but WITHOUT the interpolationType/globalSequence prefix.
void parseFBlock(const std::vector<uint8_t>& data, uint32_t offset, void parseFBlock(const std::vector<uint8_t>& data, uint32_t offset,
M2FBlock& fb, int valueType) { M2FBlock& fb, int valueType) {
// valueType: 0 = color (3 bytes RGB), 1 = alpha (uint16), 2 = scale (float pair) // valueType: 0 = color (CImVector, 4 bytes RGBA), 1 = alpha (uint16), 2 = scale (float pair)
if (offset + sizeof(FBlockDisk) > data.size()) return; if (offset + sizeof(FBlockDisk) > data.size()) return;
FBlockDisk disk = readValue<FBlockDisk>(data, offset); FBlockDisk disk = readValue<FBlockDisk>(data, offset);
@ -595,15 +595,14 @@ void parseFBlock(const std::vector<uint8_t>& data, uint32_t offset,
uint32_t ofsKeys = disk.ofsKeys; uint32_t ofsKeys = disk.ofsKeys;
if (valueType == 0) { if (valueType == 0) {
// Color: 3 bytes per key. // Color: CImVector (4 bytes RGBA) per key. We extract RGB, ignore A.
// WotLK particle FBlock color keys are stored as BGR in practice for many assets if (ofsKeys + nKeys * 4 > data.size()) return;
// (notably water/waterfall emitters). Decode to RGB explicitly.
if (ofsKeys + nKeys * 3 > data.size()) return;
fb.vec3Values.reserve(nKeys); fb.vec3Values.reserve(nKeys);
for (uint32_t i = 0; i < nKeys; i++) { for (uint32_t i = 0; i < nKeys; i++) {
uint8_t b = data[ofsKeys + i * 3 + 0]; uint8_t r = data[ofsKeys + i * 4 + 0];
uint8_t g = data[ofsKeys + i * 3 + 1]; uint8_t g = data[ofsKeys + i * 4 + 1];
uint8_t r = data[ofsKeys + i * 3 + 2]; uint8_t b = data[ofsKeys + i * 4 + 2];
// byte 3 is alpha, handled separately by the alpha FBlock
fb.vec3Values.emplace_back(r / 255.0f, g / 255.0f, b / 255.0f); fb.vec3Values.emplace_back(r / 255.0f, g / 255.0f, b / 255.0f);
} }
} else if (valueType == 1) { } else if (valueType == 1) {

View file

@ -527,7 +527,7 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) {
vec4 viewPos = uView * vec4(aPos, 1.0); vec4 viewPos = uView * vec4(aPos, 1.0);
gl_Position = uProjection * viewPos; gl_Position = uProjection * viewPos;
float dist = max(-viewPos.z, 1.0); float dist = max(-viewPos.z, 1.0);
gl_PointSize = clamp(aSize * 800.0 / dist, 1.0, 256.0); gl_PointSize = clamp(aSize * 400.0 / dist, 1.0, 64.0);
vColor = aColor; vColor = aColor;
vTile = aTile; vTile = aTile;
} }
@ -542,6 +542,12 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) {
out vec4 FragColor; out vec4 FragColor;
void main() { void main() {
// Circular soft-edge falloff (GL_POINTS are square by default)
vec2 center = gl_PointCoord - vec2(0.5);
float dist = length(center);
if (dist > 0.5) discard;
float edgeFade = smoothstep(0.5, 0.2, dist);
vec2 tileCount = max(uTileCount, vec2(1.0)); vec2 tileCount = max(uTileCount, vec2(1.0));
float tilesX = tileCount.x; float tilesX = tileCount.x;
float tilesY = tileCount.y; float tilesY = tileCount.y;
@ -553,6 +559,7 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) {
vec2 uv = gl_PointCoord * tileSize + vec2(col, row) * tileSize; vec2 uv = gl_PointCoord * tileSize + vec2(col, row) * tileSize;
vec4 texColor = texture(uTexture, uv); vec4 texColor = texture(uTexture, uv);
FragColor = texColor * vColor; FragColor = texColor * vColor;
FragColor.a *= edgeFade;
if (FragColor.a < 0.01) discard; if (FragColor.a < 0.01) discard;
} }
)"; )";
@ -1520,6 +1527,11 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::
if (!model.hasAnimation || model.disableAnimation) { if (!model.hasAnimation || model.disableAnimation) {
instance.animTime += dtMs; instance.animTime += dtMs;
// Wrap animation time for models with particle emitters so emission
// rate tracks keep looping instead of running past their keyframes.
if (!model.particleEmitters.empty() && instance.animTime > 3333.0f) {
instance.animTime = std::fmod(instance.animTime, 3333.0f);
}
continue; continue;
} }
@ -1535,6 +1547,11 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::
} }
// Handle animation looping / variation transitions // Handle animation looping / variation transitions
// When animDuration is 0 (e.g. "Stand" with infinite loop) but the model
// has particle emitters, wrap time so particle emission tracks keep looping.
if (instance.animDuration <= 0.0f && !model.particleEmitters.empty()) {
instance.animDuration = 3333.0f; // ~3.3s loop for continuous particle effects
}
if (instance.animDuration > 0.0f && instance.animTime >= instance.animDuration) { if (instance.animDuration > 0.0f && instance.animTime >= instance.animDuration) {
if (instance.playingVariation) { if (instance.playingVariation) {
// Variation finished — return to idle // Variation finished — return to idle
@ -1637,16 +1654,20 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::
} }
// Phase 3: Particle update (sequential — uses RNG, not thread-safe) // Phase 3: Particle update (sequential — uses RNG, not thread-safe)
for (size_t idx : boneWorkIndices_) { // Run for ALL nearby instances with particle emitters, not just those in
if (idx >= instances.size()) continue; // boneWorkIndices_, so particles keep animating even when bone updates are culled.
for (size_t idx = 0; idx < instances.size(); ++idx) {
auto& instance = instances[idx]; auto& instance = instances[idx];
auto mdlIt = models.find(instance.modelId); auto mdlIt = models.find(instance.modelId);
if (mdlIt == models.end()) continue; if (mdlIt == models.end()) continue;
const auto& model = mdlIt->second; const auto& model = mdlIt->second;
if (!model.particleEmitters.empty()) { if (model.particleEmitters.empty()) continue;
emitParticles(instance, model, deltaTime); // Distance cull: only update particles within visible range
updateParticles(instance, deltaTime); glm::vec3 toCam = instance.position - cachedCamPos_;
} float distSq = glm::dot(toCam, toCam);
if (distSq > cachedMaxRenderDistSq_) continue;
emitParticles(instance, model, deltaTime);
updateParticles(instance, deltaTime);
} }
} }
@ -2254,6 +2275,17 @@ void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt
glm::mat3 rotMat = glm::mat3(inst.modelMatrix * boneXform); glm::mat3 rotMat = glm::mat3(inst.modelMatrix * boneXform);
p.velocity = rotMat * dir * speed; p.velocity = rotMat * dir * speed;
// When emission speed is ~0 and bone animation isn't loaded (.anim files),
// particles pile up at the same position. Give them a drift so they
// spread outward like a mist/spray effect instead of clustering.
if (std::abs(speed) < 0.01f) {
p.velocity = rotMat * glm::vec3(
distN(particleRng_) * 1.0f,
distN(particleRng_) * 1.0f,
-dist01(particleRng_) * 0.5f
);
}
const uint32_t tilesX = std::max<uint16_t>(em.textureCols, 1); const uint32_t tilesX = std::max<uint16_t>(em.textureCols, 1);
const uint32_t tilesY = std::max<uint16_t>(em.textureRows, 1); const uint32_t tilesY = std::max<uint16_t>(em.textureRows, 1);
const uint32_t totalTiles = tilesX * tilesY; const uint32_t totalTiles = tilesX * tilesY;
@ -2291,9 +2323,22 @@ void M2Renderer::updateParticles(M2Instance& inst, float dt) {
} }
// Apply gravity // Apply gravity
if (p.emitterIndex >= 0 && p.emitterIndex < static_cast<int>(gpu.particleEmitters.size())) { if (p.emitterIndex >= 0 && p.emitterIndex < static_cast<int>(gpu.particleEmitters.size())) {
float grav = interpFloat(gpu.particleEmitters[p.emitterIndex].gravity, const auto& pem = gpu.particleEmitters[p.emitterIndex];
float grav = interpFloat(pem.gravity,
inst.animTime, inst.currentSequenceIndex, inst.animTime, inst.currentSequenceIndex,
gpu.sequences, gpu.globalSequenceDurations); gpu.sequences, gpu.globalSequenceDurations);
// When M2 gravity is 0, apply default gravity so particles arc downward.
// Many fountain M2s rely on bone animation (.anim files) we don't load yet.
if (grav == 0.0f) {
float emSpeed = interpFloat(pem.emissionSpeed,
inst.animTime, inst.currentSequenceIndex,
gpu.sequences, gpu.globalSequenceDurations);
if (std::abs(emSpeed) > 0.1f) {
grav = 4.0f; // spray particles
} else {
grav = 1.5f; // mist/drift particles - gentler fall
}
}
p.velocity.z -= grav * dt; p.velocity.z -= grav * dt;
} }
p.position += p.velocity * dt; p.position += p.velocity * dt;
@ -2349,11 +2394,23 @@ void M2Renderer::renderM2Particles(const glm::mat4& view, const glm::mat4& proj)
float lifeRatio = p.life / std::max(p.maxLife, 0.001f); float lifeRatio = p.life / std::max(p.maxLife, 0.001f);
glm::vec3 color = interpFBlockVec3(em.particleColor, lifeRatio); glm::vec3 color = interpFBlockVec3(em.particleColor, lifeRatio);
float alpha = interpFBlockFloat(em.particleAlpha, lifeRatio); float alpha = std::min(interpFBlockFloat(em.particleAlpha, lifeRatio), 1.0f);
float scale = interpFBlockFloat(em.particleScale, lifeRatio); float rawScale = interpFBlockFloat(em.particleScale, lifeRatio);
// Note: blue-dominant color correction removed — it was over-brightening // FBlock colors are tint values meant to multiply a bright texture.
// water/fountain particles, making them look like spell effects. // Desaturate toward white so particles look like water spray, not neon.
color = glm::mix(color, glm::vec3(1.0f), 0.7f);
// Large-scale particles (>2.0) are volume/backdrop effects meant to be
// nearly invisible mist. Fade them heavily since we render as point sprites.
if (rawScale > 2.0f) {
alpha *= 0.02f;
}
// Reduce additive particle intensity to prevent blinding overlap
if (em.blendingType == 3 || em.blendingType == 4) {
alpha *= 0.05f;
}
float scale = std::min(rawScale, 1.5f);
GLuint tex = whiteTexture; GLuint tex = whiteTexture;
if (p.emitterIndex < static_cast<int>(gpu.particleTextures.size())) { if (p.emitterIndex < static_cast<int>(gpu.particleTextures.size())) {