diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 5c464b59..62f4dca3 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -117,6 +117,7 @@ uint32_t M2Renderer::gatherLocalLights(const glm::vec3& cameraPos, float distSq; glm::vec4 posRadius; glm::vec4 colorIntensity; + bool flame = false; // lamps/torches/braziers gutter; lava burns steady }; std::vector candidates; @@ -133,7 +134,8 @@ uint32_t M2Renderer::gatherLocalLights(const glm::vec3& cameraPos, const float radius = std::clamp(instance.cachedVisualRadius * 0.8f, 10.0f, 35.0f); candidates.push_back({distSq, glm::vec4(worldPos, radius), - glm::vec4(1.0f, 0.28f, 0.035f, 1.75f)}); + glm::vec4(1.0f, 0.28f, 0.035f, 1.75f), + /*flame=*/false}); } } @@ -169,7 +171,7 @@ uint32_t M2Renderer::gatherLocalLights(const glm::vec3& cameraPos, if (batch.glowTint == 1) color = glm::vec3(0.42f, 0.68f, 1.0f); else if (batch.glowTint == 2) color = glm::vec3(1.0f, 0.24f, 0.14f); candidates.push_back({distSq, glm::vec4(worldPos, radius), - glm::vec4(color, 1.35f)}); + glm::vec4(color, 1.35f), /*flame=*/true}); hasBatchLight = true; } @@ -201,7 +203,8 @@ uint32_t M2Renderer::gatherLocalLights(const glm::vec3& cameraPos, candidates.push_back({distSq, glm::vec4(worldPos, chandelier ? 10.0f : 5.0f), glm::vec4(1.0f, 0.58f, 0.22f, - chandelier ? 1.25f : 0.85f)}); + chandelier ? 1.25f : 0.85f), + /*flame=*/true}); } } } @@ -212,9 +215,18 @@ uint32_t M2Renderer::gatherLocalLights(const glm::vec3& cameraPos, if (count == 0) return 0; std::partial_sort(candidates.begin(), candidates.begin() + count, candidates.end(), [](const Candidate& a, const Candidate& b) { return a.distSq < b.distSq; }); + // Guttering is applied to the light itself, not just the glow sprite: the + // pool of light a lamp throws on the ground and nearby walls is what the eye + // actually reads as firelight, so modulating the sprite alone was too subtle + // to notice. + const float flickerSeconds = lampFlickerClockSeconds(); for (uint32_t i = 0; i < count; ++i) { outPosRadius[i] = candidates[i].posRadius; outColorIntensity[i] = candidates[i].colorIntensity; + if (candidates[i].flame) { + outColorIntensity[i].w *= lampFlicker(glm::vec3(candidates[i].posRadius), + flickerSeconds, 0.86f, 0.11f, 0.05f); + } } return count; } diff --git a/src/rendering/m2_renderer_internal.h b/src/rendering/m2_renderer_internal.h index 14f73883..8c798b33 100644 --- a/src/rendering/m2_renderer_internal.h +++ b/src/rendering/m2_renderer_internal.h @@ -330,6 +330,30 @@ inline void computeBoneMatrices(const M2ModelGPU& model, M2Instance& instance) { instance.bonesDirty[0] = instance.bonesDirty[1] = true; } +// Firelight guttering for lamps, torches and braziers. +// +// The phase is hashed from the fixture's world position so no two lights ever +// pulse together — a synchronised row of street lamps reads as a rendering +// artifact rather than firelight — and it stays stable frame to frame because +// it derives from a fixed position rather than a counter. +// +// Two detuned sines, slow enough to read as a flame breathing rather than a +// strobe, with periods that share no common multiple over any watchable span. +inline float lampFlicker(const glm::vec3& worldPos, float seconds, + float base, float slowAmp, float fastAmp) { + float h = std::sin(worldPos.x * 12.9898f + worldPos.y * 78.233f + + worldPos.z * 37.719f) * 43758.5453f; + const float phase = (h - std::floor(h)) * 6.2831853f; + return base + slowAmp * std::sin(seconds * 1.3f + phase) + + fastAmp * std::sin(seconds * 2.9f + phase * 1.7f); +} + +/// Seconds since process start, shared by the flicker animations. +inline float lampFlickerClockSeconds() { + static const auto start = std::chrono::steady_clock::now(); + return std::chrono::duration(std::chrono::steady_clock::now() - start).count(); +} + } // namespace m2_internal // Pull all symbols into the rendering namespace so existing code compiles unchanged diff --git a/src/rendering/m2_renderer_render.cpp b/src/rendering/m2_renderer_render.cpp index 37d3c58f..e0580769 100644 --- a/src/rendering/m2_renderer_render.cpp +++ b/src/rendering/m2_renderer_render.cpp @@ -1309,15 +1309,13 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const // visibly. Alpha and size move together, since a // brighter flame also looks slightly larger. { - float h = std::sin(worldPos.x * 12.9898f + - worldPos.y * 78.233f + - worldPos.z * 37.719f) * 43758.5453f; - const float phase = (h - std::floor(h)) * 6.2831853f; - const float flicker = - 0.90f + 0.07f * std::sin(lavaAnimSeconds * 1.3f + phase) - + 0.03f * std::sin(lavaAnimSeconds * 2.9f + phase * 1.7f); + // Matches the phase used for this lamp's local + // light, so the sprite and the pool of light it + // casts breathe together. + const float flicker = lampFlicker( + worldPos, lavaAnimSeconds, 0.86f, 0.11f, 0.05f); gs.color.a *= flicker; - gs.size *= 0.97f + 0.03f * flicker; + gs.size *= 0.94f + 0.06f * flicker; } glowSprites_.push_back(gs); GlowSprite halo = gs;