Ironforge Great Forge lava, magma water rendering, LavaSteam particle effects

- Add magma/slime rendering path to water shader (fbm noise, crust/molten/core coloring)
- Fix WMO liquid height filter rejecting high-altitude zones like Ironforge (Z>300)
- Allow interior WMO magma/slime MLIQ groups to load (skip only water/ocean)
- Mark LAVASTEAM.m2 as spell effect for proper additive blend, hide emission mesh
- Add isLavaModel flag for M2 ForgeLava/LavaPots UV scroll fallback
- Add isLava material detection in WMO renderer for lava texture UV animation
- Fix WMO material UBO colors for magma (was blue, now orange-red)
This commit is contained in:
Kelsi 2026-03-07 00:48:04 -08:00
parent 2c5b7cd368
commit a24fe4cc45
10 changed files with 158 additions and 12 deletions

View file

@ -155,6 +155,52 @@ void main() {
float time = fogParams.z;
float basicType = push.liquidBasicType;
// ============================================================
// Magma / Slime — self-luminous flowing surfaces, skip water path
// ============================================================
if (basicType > 1.5) {
float dist = length(viewPos.xyz - FragPos);
vec2 flowUV = FragPos.xy;
bool isMagma = basicType < 2.5;
// Multi-octave flowing noise for organic lava look
float n1 = fbmNoise(flowUV * 0.06 + vec2(time * 0.02, time * 0.03), time * 0.4);
float n2 = fbmNoise(flowUV * 0.10 + vec2(-time * 0.015, time * 0.025), time * 0.3);
float n3 = noiseValue(flowUV * 0.25 + vec2(time * 0.04, -time * 0.02));
float flow = n1 * 0.45 + n2 * 0.35 + n3 * 0.20;
// Dark crust vs bright molten core
vec3 crustColor, hotColor, coreColor;
if (isMagma) {
crustColor = vec3(0.15, 0.04, 0.01); // dark cooled rock
hotColor = vec3(1.0, 0.45, 0.05); // orange molten
coreColor = vec3(1.0, 0.85, 0.3); // bright yellow-white core
} else {
crustColor = vec3(0.05, 0.15, 0.02);
hotColor = vec3(0.3, 0.8, 0.15);
coreColor = vec3(0.5, 1.0, 0.3);
}
// Three-tier color: crust → molten → hot core
float crustMask = smoothstep(0.25, 0.50, flow);
float coreMask = smoothstep(0.60, 0.80, flow);
vec3 color = mix(crustColor, hotColor, crustMask);
color = mix(color, coreColor, coreMask);
// Subtle pulsing emissive glow
float pulse = 1.0 + 0.15 * sin(time * 1.5 + flow * 6.0);
color *= pulse;
// Emissive brightening for hot areas
color *= 1.0 + coreMask * 0.6;
float fogFactor = clamp((fogParams.y - dist) / (fogParams.y - fogParams.x), 0.0, 1.0);
color = mix(fogColor.rgb, color, fogFactor);
outColor = vec4(color, 0.97);
return;
}
vec2 screenUV = gl_FragCoord.xy / vec2(textureSize(SceneColor, 0));
// --- Normal computation ---

Binary file not shown.

View file

@ -28,6 +28,7 @@ layout(set = 1, binding = 1) uniform WMOMaterial {
int pomMaxSamples;
float heightMapVariance;
float normalMapStrength;
int isLava;
};
layout(set = 1, binding = 2) uniform sampler2D uNormalHeightMap;
@ -120,6 +121,14 @@ void main() {
// Compute final UV (with POM if enabled)
vec2 finalUV = TexCoord;
// Lava/magma: scroll UVs for flowing effect
if (isLava != 0) {
float time = fogParams.z;
// Scroll both axes — pools get horizontal flow, waterfalls get vertical flow
// (UV orientation depends on mesh, so animate both)
finalUV += vec2(time * 0.04, time * 0.06);
}
// Build TBN matrix
vec3 T = normalize(Tangent);
vec3 B = normalize(Bitangent);
@ -170,7 +179,10 @@ void main() {
shadow = mix(1.0, shadow, shadowParams.y);
}
if (unlit != 0) {
if (isLava != 0) {
// Lava is self-luminous — bright emissive, no shadows
result = texColor.rgb * 1.5;
} else if (unlit != 0) {
result = texColor.rgb * shadow;
} else if (isInterior != 0) {
vec3 mocv = max(VertColor.rgb, vec3(0.5));

Binary file not shown.

View file

@ -119,6 +119,7 @@ struct M2ModelGPU {
bool isElvenLike = false; // Model name matches elf/elven/quel (precomputed)
bool isLanternLike = false; // Model name matches lantern/lamp/light (precomputed)
bool isKoboldFlame = false; // Model name matches kobold+(candle/torch/mine) (precomputed)
bool isLavaModel = false; // Model name contains lava/molten/magma (UV scroll fallback)
bool hasTextureAnimation = false; // True if any batch has UV animation
// Particle emitter data (kept from M2Model)

View file

@ -340,7 +340,9 @@ private:
int32_t pomMaxSamples; // 36 (max ray-march steps)
float heightMapVariance; // 40 (low variance = skip POM)
float normalMapStrength; // 44 (0=flat, 1=full, 2=exaggerated)
}; // 48 bytes total
int32_t isLava; // 48 (1=lava/magma UV scroll)
float pad[3]; // 52-60 padding to 64 bytes
}; // 64 bytes total
/**
* WMO group GPU resources
@ -380,6 +382,7 @@ private:
bool unlit = false;
bool isTransparent = false; // blendMode >= 2
bool isWindow = false; // F_SIDN or F_WINDOW material
bool isLava = false; // lava/magma texture (UV scroll)
// For multi-draw: store index ranges
struct DrawRange { uint32_t firstIndex; uint32_t indexCount; };
std::vector<DrawRange> draws;

View file

@ -1131,10 +1131,32 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
(lowerName.find("hazardlight") != std::string::npos) ||
(lowerName.find("lavasplash") != std::string::npos) ||
(lowerName.find("lavabubble") != std::string::npos) ||
(lowerName.find("lavasteam") != std::string::npos) ||
(lowerName.find("wisps") != std::string::npos);
gpuModel.isSpellEffect = effectByName ||
(hasParticles && model.vertices.size() <= 200 &&
model.particleEmitters.size() >= 3);
gpuModel.isLavaModel =
(lowerName.find("forgelava") != std::string::npos) ||
(lowerName.find("lavapot") != std::string::npos) ||
(lowerName.find("lavaflow") != std::string::npos);
if (lowerName.find("lava") != std::string::npos || lowerName.find("steam") != std::string::npos) {
LOG_WARNING("M2 LAVA/STEAM: '", model.name, "' isSpellEffect=", gpuModel.isSpellEffect ? "Y" : "N",
" effectByName=", effectByName ? "Y" : "N",
" particles=", model.particleEmitters.size(),
" verts=", model.vertices.size(),
" batches=", model.batches.size(),
" texTransforms=", model.textureTransforms.size(),
" texTransformLookup=", model.textureTransformLookup.size(),
" isLavaModel=", gpuModel.isLavaModel ? "Y" : "N");
for (size_t bi = 0; bi < model.batches.size(); bi++) {
const auto& b = model.batches[bi];
uint8_t bm = (b.materialIndex < model.materials.size()) ? model.materials[b.materialIndex].blendMode : 255;
uint16_t mf = (b.materialIndex < model.materials.size()) ? model.materials[b.materialIndex].flags : 0;
LOG_WARNING(" batch[", bi, "]: blend=", (int)bm, " matFlags=0x", std::hex, mf, std::dec,
" texAnimIdx=", b.textureAnimIndex, " idxCount=", b.indexCount);
}
}
gpuModel.isInstancePortal =
(lowerName.find("instanceportal") != std::string::npos) ||
(lowerName.find("instancenewportal") != std::string::npos) ||
@ -2357,6 +2379,9 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
}
const bool foliageLikeModel = model.isFoliageLike;
// Particle-dominant spell effects: mesh is emission geometry, render dim
const bool particleDominantEffect = model.isSpellEffect &&
!model.particleEmitters.empty() && model.batches.size() <= 2;
for (const auto& batch : model.batches) {
if (batch.indexCount == 0) continue;
if (!model.isGroundDetail && batch.submeshLevel != targetLOD) continue;
@ -2421,6 +2446,12 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
}
}
}
// Lava M2 models: fallback UV scroll if no texture animation
if (model.isLavaModel && uvOffset == glm::vec2(0.0f)) {
static auto startTime = std::chrono::steady_clock::now();
float t = std::chrono::duration<float>(std::chrono::steady_clock::now() - startTime).count();
uvOffset = glm::vec2(t * 0.03f, -t * 0.08f);
}
// Foliage/card-like batches render more stably as cutout (depth-write on)
// instead of alpha-blended sorting.
@ -2498,6 +2529,10 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
pc.useBones = useBones ? 1 : 0;
pc.isFoliage = model.shadowWindFoliage ? 1 : 0;
pc.fadeAlpha = instanceFadeAlpha;
// Particle-dominant effects: mesh is emission geometry, don't render
if (particleDominantEffect && batch.blendMode <= 1) {
continue;
}
vkCmdPushConstants(cmd, pipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(pc), &pc);
vkCmdDrawIndexed(cmd, batch.indexCount, 1, batch.indexStart, 0, 0);
@ -2948,8 +2983,23 @@ void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt
std::uniform_real_distribution<float> distN(-1.0f, 1.0f);
std::uniform_int_distribution<int> distTile;
static uint32_t steamDiagCounter = 0;
bool steamDiag = (gpu.isSpellEffect && gpu.particleEmitters.size() >= 6 && steamDiagCounter < 3);
for (size_t ei = 0; ei < gpu.particleEmitters.size(); ei++) {
const auto& em = gpu.particleEmitters[ei];
if (steamDiag) {
float rate = interpFloat(em.emissionRate, inst.animTime, inst.currentSequenceIndex,
gpu.sequences, gpu.globalSequenceDurations);
float life = interpFloat(em.lifespan, inst.animTime, inst.currentSequenceIndex,
gpu.sequences, gpu.globalSequenceDurations);
LOG_WARNING("STEAM PARTICLE DIAG emitter[", ei, "]: enabled=", em.enabled ? "Y" : "N",
" rate=", rate, " life=", life,
" animTime=", inst.animTime, " seq=", inst.currentSequenceIndex,
" bone=", em.bone, " blendType=", (int)em.blendingType,
" globalSeq=", em.emissionRate.globalSequence,
" rateSeqs=", em.emissionRate.sequences.size());
}
if (!em.enabled) continue;
float rate = interpFloat(em.emissionRate, inst.animTime, inst.currentSequenceIndex,
@ -3038,6 +3088,12 @@ void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt
inst.emitterAccumulators[ei] = 0.0f;
}
}
if (steamDiag) {
LOG_WARNING("STEAM PARTICLE DIAG: totalParticles=", inst.particles.size(),
" sequences=", gpu.sequences.size(),
" globalSeqDurations=", gpu.globalSequenceDurations.size());
steamDiagCounter++;
}
}
void M2Renderer::updateParticles(M2Instance& inst, float dt) {

View file

@ -835,10 +835,24 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) {
modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.z, glm::vec3(0.0f, 0.0f, 1.0f));
modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.y, glm::vec3(0.0f, 1.0f, 0.0f));
modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.x, glm::vec3(1.0f, 0.0f, 0.0f));
for (const auto& group : wmoReady.model.groups) {
for (size_t gi = 0; gi < wmoReady.model.groups.size(); gi++) {
const auto& group = wmoReady.model.groups[gi];
if (!group.liquid.hasLiquid()) continue;
// Skip interior groups — their liquid is for indoor areas
if (group.flags & 0x2000) continue;
uint16_t lt = group.liquid.materialId;
uint8_t basicType = (lt == 0) ? 0 : ((lt - 1) % 4);
bool isInterior = (group.flags & 0x2000) != 0;
LOG_WARNING("WMO MLIQ group", gi, ": flags=0x", std::hex, group.flags, std::dec,
" materialId=", lt, " basicType=", (int)basicType,
" interior=", isInterior ? "Y" : "N",
" xVerts=", group.liquid.xVerts, " yVerts=", group.liquid.yVerts);
// Skip interior water/ocean but keep magma/slime (e.g. Ironforge lava)
if (isInterior) {
if (basicType < 2) {
LOG_WARNING(" -> SKIPPED (interior water/ocean)");
continue;
}
LOG_WARNING(" -> LOADING (interior magma/slime)");
}
waterRenderer->loadFromWMO(group.liquid, modelMatrix, wmoInstId);
loadedLiquids++;
}

View file

@ -544,9 +544,14 @@ void WaterRenderer::updateMaterialUBO(WaterSurface& surface) {
// WMO liquid material override
if (surface.wmoId != 0) {
const uint8_t basicType = (surface.liquidType == 0) ? 0 : ((surface.liquidType - 1) % 4);
if (basicType == 2 || basicType == 3) {
color = glm::vec4(0.2f, 0.4f, 0.6f, 1.0f);
alpha = 0.45f;
if (basicType == 2) {
// Magma — bright orange-red, opaque
color = glm::vec4(1.0f, 0.35f, 0.05f, 1.0f);
alpha = 0.95f;
} else if (basicType == 3) {
// Slime — green, semi-opaque
color = glm::vec4(0.2f, 0.6f, 0.1f, 1.0f);
alpha = 0.85f;
}
}
@ -935,7 +940,7 @@ void WaterRenderer::loadFromWMO([[maybe_unused]] const pipeline::WMOLiquid& liqu
surface.origin.z = adjustedZ;
surface.position.z = adjustedZ;
if (surface.origin.z > 300.0f || surface.origin.z < -100.0f) return;
if (surface.origin.z > 2000.0f || surface.origin.z < -500.0f) return;
// Build tile mask from MLIQ flags and per-vertex heights
size_t tileCount = static_cast<size_t>(surface.width) * static_cast<size_t>(surface.height);

View file

@ -596,19 +596,25 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) {
// so we additionally check for "window" or "glass" in the texture path to
// distinguish actual glass from lamp post geometry.
bool isWindow = false;
bool isLava = false;
if (batch.materialId < modelData.materialTextureIndices.size()) {
uint32_t ti = modelData.materialTextureIndices[batch.materialId];
if (ti < modelData.textureNames.size()) {
const auto& texName = modelData.textureNames[ti];
// Case-insensitive search for "window" or "glass"
// Case-insensitive search for material types
std::string texNameLower = texName;
std::transform(texNameLower.begin(), texNameLower.end(), texNameLower.begin(), ::tolower);
isWindow = (texNameLower.find("window") != std::string::npos ||
texNameLower.find("glass") != std::string::npos);
isLava = (texNameLower.find("lava") != std::string::npos ||
texNameLower.find("molten") != std::string::npos ||
texNameLower.find("magma") != std::string::npos);
if (isLava) {
LOG_WARNING("WMO LAVA BATCH: tex='", texName, "' matId=", batch.materialId,
" blend=", blendMode, " flags=0x", std::hex, matFlags, std::dec);
}
}
}
BatchKey key{ reinterpret_cast<uintptr_t>(tex), alphaTest, unlit, isWindow };
auto& mb = batchMap[key];
@ -619,6 +625,7 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) {
mb.unlit = unlit;
mb.isTransparent = (blendMode >= 2);
mb.isWindow = isWindow;
mb.isLava = isLava;
// Look up normal/height map from texture cache
if (hasTexture && tex != whiteTexture_.get()) {
for (const auto& [cacheKey, cacheEntry] : textureCache) {
@ -668,6 +675,7 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) {
}
matData.heightMapVariance = mb.heightMapVariance;
matData.normalMapStrength = normalMapStrength_;
matData.isLava = mb.isLava ? 1 : 0;
if (matBuf.info.pMappedData) {
memcpy(matBuf.info.pMappedData, &matData, sizeof(matData));
}
@ -789,6 +797,7 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) {
doodadTemplate.m2Path = m2Path;
doodadTemplate.localTransform = localTransform;
modelData.doodadTemplates.push_back(doodadTemplate);
}
if (!modelData.doodadTemplates.empty()) {