diff --git a/assets/shaders/water.frag.glsl b/assets/shaders/water.frag.glsl index c7dbc5b4..5d3af519 100644 --- a/assets/shaders/water.frag.glsl +++ b/assets/shaders/water.frag.glsl @@ -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 --- diff --git a/assets/shaders/water.frag.spv b/assets/shaders/water.frag.spv index 6fe7f2a6..a5b91695 100644 Binary files a/assets/shaders/water.frag.spv and b/assets/shaders/water.frag.spv differ diff --git a/assets/shaders/wmo.frag.glsl b/assets/shaders/wmo.frag.glsl index b8db595d..c04e1a93 100644 --- a/assets/shaders/wmo.frag.glsl +++ b/assets/shaders/wmo.frag.glsl @@ -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)); diff --git a/assets/shaders/wmo.frag.spv b/assets/shaders/wmo.frag.spv index a7b9ef94..2453f0ff 100644 Binary files a/assets/shaders/wmo.frag.spv and b/assets/shaders/wmo.frag.spv differ diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 62e984fd..f53fb4bf 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -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) diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 5c928571..095a354d 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -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 draws; diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 690a5234..acf2816d 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -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(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 distN(-1.0f, 1.0f); std::uniform_int_distribution 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) { diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index 5fbca940..b0454ffa 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -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++; } diff --git a/src/rendering/water_renderer.cpp b/src/rendering/water_renderer.cpp index 6a5c310d..a01cfedb 100644 --- a/src/rendering/water_renderer.cpp +++ b/src/rendering/water_renderer.cpp @@ -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(surface.width) * static_cast(surface.height); diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 5bae174f..8e72bd0d 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -596,20 +596,26 @@ 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(tex), alphaTest, unlit, isWindow }; auto& mb = batchMap[key]; if (mb.draws.empty()) { @@ -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()) {