From 5896bdb75bdec2acbae0bef5e94f96bb669d7493 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 23 Jul 2026 00:36:43 -0700 Subject: [PATCH] fix: flicker the light lamps cast, not just their glow sprite Modulating the additive sprite alone was invisible: it already saturates, so a small alpha change shows only in its faint edges. The guttering now also drives each lamp's local light intensity, which is what actually lights the ground and walls nearby and reads as firelight. Sprite and light share one position-hashed phase, so a lamp and its pool of light breathe together while neighbouring lamps stay out of step. Lava keeps a steady burn. --- src/rendering/m2_renderer.cpp | 18 +++++++++++++++--- src/rendering/m2_renderer_internal.h | 24 ++++++++++++++++++++++++ src/rendering/m2_renderer_render.cpp | 14 ++++++-------- 3 files changed, 45 insertions(+), 11 deletions(-) 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;