From f6bc03f2e5afe7c2c7330278ecaa84af1ba0d984 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 16:17:18 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20implement=20Phase=206=20lighting=20?= =?UTF-8?q?&=20polish=20(6.1=E2=80=936.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6.1 Time-of-day lighting - Add LightingState class (src/render/lighting_state.hpp/cpp) that wraps GlobalLighting, handles time-of-day slot selection (Morning/Afternoon/ Evening/Night), and produces TerrainPushConstant and object lighting vectors for CPU-side consumers. - Extend UniformBufferObject with lightDirection, ambientColor, diffuseColor vec4s so basic.frag reads scene lighting from the UBO instead of hardcoded constants. - Update Renderer::updateUniformBuffer() to populate UBO lighting from LightingState when set, falling back to pre-Phase-6 defaults otherwise. - Update standard() and skinned() descriptor binding 0 stage flags to expose UBO to the fragment shader. 6.2 Shadow color - Decode GlobalLighting::shadowColor (ARGB uint32) in LightingState and expose it via TerrainPushConstant::shadowColor (vec4). - terrain.frag blends diffuse result toward shadow colour to simulate cast shadows. 6.3 Cloud shadows - Add cloud scroll/strength/time parameters to TerrainPushConstant. - terrain.vert computes fragCloudCoord (world-space xz * scale + time*scroll). - terrain.frag applies a 2-octave procedural FBM noise cloud shadow overlay without requiring an additional texture binding. - LightingState::setCloudShadow() / disableCloudShadow() / update() manage cloud animation accumulation. - TerrainRenderable::update(dt) forwards delta time to the push constant. 6.4 Minimap generation - Add MinimapGenerator (src/render/terrain/terrain_minimap.hpp/cpp) with generate() (1:1 pixel per heightmap cell) and generateScaled() (bilinear downsampling) producing RGBA8 images from HeightMap data. Tests (TDD) - tests/render/test_lighting_state.cpp – 23 tests covering defaults, setGlobalLighting, time-of-day switching, push constant generation, ARGB shadow decoding, object lighting, and cloud animation. - tests/terrain/test_terrain_minimap.cpp – 14 tests covering empty/invalid inputs, pixel count, gradient variation, bilinear scaling, edge clamping. - Fix pre-existing test regression: update TestsObjectFlagHelpers to match MapObject::shouldRender() behavior introduced in f20af65 (road/bridge points are placement markers and correctly return false from shouldRender). - Add GLFW_INCLUDE_NONE compile definition to suppress GL/gl.h inclusion in headless CI environments. All 24 test targets pass (100%). https://claude.ai/code/session_01St1oa77j4XRUuovTYQbAFn --- CMakeLists.txt | 1 + shaders/basic.frag | 26 +- shaders/terrain.frag | 57 ++++ shaders/terrain.vert | 28 ++ src/core/renderer.cpp | 12 + src/core/renderer.hpp | 9 + src/lib/gfx/pipeline.hpp | 17 +- src/render/lighting_state.cpp | 110 ++++++++ src/render/lighting_state.hpp | 111 ++++++++ src/render/terrain/terrain_minimap.cpp | 118 ++++++++ src/render/terrain/terrain_minimap.hpp | 55 ++++ src/render/terrain/terrain_renderable.cpp | 16 ++ src/render/terrain/terrain_renderable.hpp | 16 ++ tests/CMakeLists.txt | 30 ++ tests/map/test_objects_parser.cpp | 4 +- tests/render/test_lighting_state.cpp | 327 ++++++++++++++++++++++ tests/terrain/test_terrain_minimap.cpp | 209 ++++++++++++++ 17 files changed, 1134 insertions(+), 12 deletions(-) create mode 100644 src/render/lighting_state.cpp create mode 100644 src/render/lighting_state.hpp create mode 100644 src/render/terrain/terrain_minimap.cpp create mode 100644 src/render/terrain/terrain_minimap.hpp create mode 100644 tests/render/test_lighting_state.cpp create mode 100644 tests/terrain/test_terrain_minimap.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1395682..550f827 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -171,6 +171,7 @@ target_link_libraries(w3d_lib PUBLIC # Library compile definitions target_compile_definitions(w3d_lib PUBLIC VULKAN_HPP_HANDLE_ERROR_OUT_OF_DATE_AS_SUCCESS + GLFW_INCLUDE_NONE # Suppress GLFW's OpenGL header inclusion (we use Vulkan only) $<$:W3D_DEBUG> ) diff --git a/shaders/basic.frag b/shaders/basic.frag index 1f51a66..1539d33 100644 --- a/shaders/basic.frag +++ b/shaders/basic.frag @@ -7,6 +7,16 @@ layout(location = 3) in vec3 fragWorldPos; layout(location = 0) out vec4 outColor; +// UBO – now includes scene lighting (Phase 6.1) +layout(set = 0, binding = 0) uniform UniformBufferObject { + mat4 model; + mat4 view; + mat4 proj; + vec4 lightDirection; // xyz = direction toward light source (not normalised here) + vec4 ambientColor; // xyz = RGB ambient + vec4 diffuseColor; // xyz = RGB diffuse +} ubo; + // Texture sampler layout(set = 0, binding = 1) uniform sampler2D texSampler; @@ -27,11 +37,6 @@ const uint FLAG_HAS_ALPHA_TEST = 2u; const uint FLAG_TWO_SIDED = 4u; const uint FLAG_UNLIT = 8u; -// Simple directional light -const vec3 lightDir = normalize(vec3(1.0, 1.0, 1.0)); -const vec3 lightColor = vec3(1.0, 1.0, 1.0); -const float ambientStrength = 0.3; - void main() { vec3 normal = normalize(fragNormal); @@ -60,15 +65,20 @@ void main() { if ((material.flags & FLAG_UNLIT) != 0u) { result = baseColor.rgb + material.emissiveColor.rgb; } else { + // Use scene lighting from UBO (populated by LightingState / Renderer). + // lightDirection.xyz points *toward* the light, so negate for the + // diffuse dot product (which expects a vector from surface to light). + vec3 lightDir = normalize(ubo.lightDirection.xyz); + // Ambient - vec3 ambient = ambientStrength * lightColor; + vec3 ambient = ubo.ambientColor.rgb * baseColor.rgb; // Diffuse float diff = max(dot(normal, lightDir), 0.0); - vec3 diffuse = diff * lightColor; + vec3 diffuse = ubo.diffuseColor.rgb * diff * baseColor.rgb; // Combine lighting with base color - result = (ambient + diffuse) * baseColor.rgb; + result = ambient + diffuse; // Add emissive result += material.emissiveColor.rgb; diff --git a/shaders/terrain.frag b/shaders/terrain.frag index 8b742f2..66f8b3d 100644 --- a/shaders/terrain.frag +++ b/shaders/terrain.frag @@ -4,6 +4,7 @@ layout(location = 0) in vec3 fragNormal; layout(location = 1) in vec2 fragTexCoord; layout(location = 2) in vec3 fragWorldPos; layout(location = 3) in vec2 fragAtlasCoord; +layout(location = 4) in vec2 fragCloudCoord; // Phase 6.3: scrolled cloud UV layout(location = 0) out vec4 outColor; @@ -14,8 +15,44 @@ layout(push_constant) uniform TerrainMaterial { vec4 diffuseColor; vec3 lightDirection; uint useTexture; + // Phase 6.2 – shadow colour decoded from GlobalLighting::shadowColor (ARGB) + vec4 shadowColor; + // Phase 6.3 – cloud shadow animation (scroll speeds + time stored in .vert) + float cloudScrollU; + float cloudScrollV; + float cloudTime; + float cloudStrength; // 0 = disabled, 1 = full shadow } material; +// --------------------------------------------------------------------------- +// Simple hash-based 2D noise for procedural cloud shadows. +// Produces smooth values in [0, 1]. +// --------------------------------------------------------------------------- +float hash21(vec2 p) { + p = fract(p * vec2(127.1, 311.7)); + p += dot(p, p + 19.19); + return fract(p.x * p.y); +} + +float smoothNoise(vec2 uv) { + vec2 i = floor(uv); + vec2 f = fract(uv); + vec2 u = f * f * (3.0 - 2.0 * f); // smoothstep + + float a = hash21(i); + float b = hash21(i + vec2(1.0, 0.0)); + float c = hash21(i + vec2(0.0, 1.0)); + float d = hash21(i + vec2(1.0, 1.0)); + + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +// Two-octave FBM for a more cloud-like pattern. +float cloudPattern(vec2 uv) { + float v = smoothNoise(uv) * 0.6 + smoothNoise(uv * 2.1 + 4.7) * 0.4; + return v; +} + void main() { vec3 normal = normalize(fragNormal); @@ -32,12 +69,32 @@ void main() { vec3 lightDir = normalize(-material.lightDirection); + // Ambient vec3 ambient = material.ambientColor.rgb * baseColor; + // Diffuse float diff = max(dot(normal, lightDir), 0.0); vec3 diffuse = material.diffuseColor.rgb * diff * baseColor; vec3 result = ambient + diffuse; + // Phase 6.2 – shadow colour tint. + // Apply the shadow colour as a lerp based on its alpha when the surface is + // facing away from the light (diff == 0 → fully in shadow). + if (material.shadowColor.a > 0.0) { + float shadowFactor = (1.0 - diff) * material.shadowColor.a; + result = mix(result, result * material.shadowColor.rgb, shadowFactor); + } + + // Phase 6.3 – cloud shadow overlay. + // Sample a procedural cloud pattern using scrolled world-space UVs and + // darken the lit surface proportionally to cloudStrength. + if (material.cloudStrength > 0.0) { + float cloud = cloudPattern(fragCloudCoord); + // cloud ∈ [0, 1]; values > 0.5 are "under cloud", values ≤ 0.5 are "in sun". + float shadow = smoothstep(0.45, 0.65, cloud) * material.cloudStrength; + result *= (1.0 - shadow * 0.6); // attenuate by up to 60 % (matches original look) + } + outColor = vec4(result, 1.0); } diff --git a/shaders/terrain.vert b/shaders/terrain.vert index 6dfc732..9ba5a14 100644 --- a/shaders/terrain.vert +++ b/shaders/terrain.vert @@ -4,8 +4,26 @@ layout(set = 0, binding = 0) uniform UniformBufferObject { mat4 model; mat4 view; mat4 proj; + // Scene lighting fields (Phase 6.1, present in UBO but not used by the + // terrain vertex shader – consumed by the fragment shader via push constant). + vec4 lightDirection; + vec4 ambientColor; + vec4 diffuseColor; } ubo; +layout(push_constant) uniform TerrainMaterial { + vec4 ambientColor; + vec4 diffuseColor; + vec3 lightDirection; + uint useTexture; + vec4 shadowColor; + // Phase 6.3 – cloud shadow animation parameters. + float cloudScrollU; + float cloudScrollV; + float cloudTime; + float cloudStrength; +} material; + layout(location = 0) in vec3 inPosition; layout(location = 1) in vec3 inNormal; layout(location = 2) in vec2 inTexCoord; @@ -15,6 +33,11 @@ layout(location = 0) out vec3 fragNormal; layout(location = 1) out vec2 fragTexCoord; layout(location = 2) out vec3 fragWorldPos; layout(location = 3) out vec2 fragAtlasCoord; +layout(location = 4) out vec2 fragCloudCoord; // Phase 6.3: animated cloud UV + +// World-space scale for the cloud UV. Smaller values tile the cloud pattern +// more coarsely, matching the original engine's feel. +const float kCloudUVScale = 0.002; void main() { vec4 worldPos = ubo.model * vec4(inPosition, 1.0); @@ -24,4 +47,9 @@ void main() { fragTexCoord = inTexCoord; fragWorldPos = worldPos.xyz; fragAtlasCoord = inAtlasCoord; + + // Phase 6.3: derive cloud UV from world-space X/Z then animate with time. + vec2 cloudBase = worldPos.xz * kCloudUVScale; + fragCloudCoord = cloudBase + vec2(material.cloudScrollU * material.cloudTime, + material.cloudScrollV * material.cloudTime); } diff --git a/src/core/renderer.cpp b/src/core/renderer.cpp index d08a3fe..eb43611 100644 --- a/src/core/renderer.cpp +++ b/src/core/renderer.cpp @@ -109,6 +109,18 @@ void Renderer::updateUniformBuffer(uint32_t frameIndex, const Camera &camera) { 0.01f, 10000.0f); ubo.proj[1][1] *= -1; // Flip Y for Vulkan + // Phase 6.1 – scene lighting. Populate from LightingState if available, + // otherwise use the hard-coded defaults that match the pre-Phase-6 behaviour. + if (lightingState_ != nullptr) { + ubo.lightDirection = glm::vec4(lightingState_->objectLightDirection(), 0.0f); + ubo.ambientColor = glm::vec4(lightingState_->objectAmbient(), 1.0f); + ubo.diffuseColor = glm::vec4(lightingState_->objectDiffuse(), 1.0f); + } else { + ubo.lightDirection = glm::vec4(LightingState::kDefaultLightDirection, 0.0f); + ubo.ambientColor = glm::vec4(LightingState::kDefaultAmbient, 1.0f); + ubo.diffuseColor = glm::vec4(LightingState::kDefaultDiffuse, 1.0f); + } + uniformBuffers_.update(frameIndex, ubo); } diff --git a/src/core/renderer.hpp b/src/core/renderer.hpp index 677cc0e..bdfd11c 100644 --- a/src/core/renderer.hpp +++ b/src/core/renderer.hpp @@ -17,6 +17,7 @@ #include "lib/gfx/texture.hpp" #include "render/bone_buffer.hpp" #include "render/hover_detector.hpp" +#include "render/lighting_state.hpp" #include "render/material.hpp" #include "render/renderable_mesh.hpp" #include "render/skeleton_renderer.hpp" @@ -89,6 +90,13 @@ class Renderer { */ uint32_t currentFrame() const { return currentFrame_; } + /** + * Set the active scene lighting state (Phase 6.1). + * The pointer is non-owning and must outlive the Renderer. + * Pass nullptr to revert to hard-coded defaults. + */ + void setLighting(const LightingState *lighting) { lightingState_ = lighting; } + // Accessors gfx::Pipeline &pipeline() { return pipeline_; } gfx::Pipeline &skinnedPipeline() { return skinnedPipeline_; } @@ -109,6 +117,7 @@ class Renderer { ImGuiBackend *imguiBackend_ = nullptr; gfx::TextureManager *textureManager_ = nullptr; BoneMatrixBuffer *boneMatrixBuffer_ = nullptr; + const LightingState *lightingState_ = nullptr; // optional scene lighting (Phase 6.1) // Pipelines and descriptors gfx::Pipeline pipeline_; diff --git a/src/lib/gfx/pipeline.hpp b/src/lib/gfx/pipeline.hpp index dd4fee2..7186df7 100644 --- a/src/lib/gfx/pipeline.hpp +++ b/src/lib/gfx/pipeline.hpp @@ -61,6 +61,10 @@ struct UniformBufferObject { alignas(16) glm::mat4 model; alignas(16) glm::mat4 view; alignas(16) glm::mat4 proj; + // Scene lighting (populated from map GlobalLighting; defaults used when no map is loaded) + alignas(16) glm::vec4 lightDirection; // xyz = direction toward light, w = unused + alignas(16) glm::vec4 ambientColor; // xyz = RGB ambient, w = unused + alignas(16) glm::vec4 diffuseColor; // xyz = RGB diffuse, w = unused }; struct MaterialPushConstant { @@ -78,6 +82,13 @@ struct TerrainPushConstant { alignas(16) glm::vec4 diffuseColor; alignas(16) glm::vec3 lightDirection; alignas(4) uint32_t useTexture; + // Phase 6.2 – shadow color (decoded from GlobalLighting::shadowColor ARGB uint32) + alignas(16) glm::vec4 shadowColor; + // Phase 6.3 – cloud shadow animation parameters + alignas(4) float cloudScrollU; // cloud UV horizontal scroll speed (world units/sec) + alignas(4) float cloudScrollV; // cloud UV vertical scroll speed (world units/sec) + alignas(4) float cloudTime; // accumulated time for cloud UV offset + alignas(4) float cloudStrength; // 0 = no shadow, 1 = full shadow intensity }; struct WaterPushConstant { @@ -122,7 +133,8 @@ struct PipelineCreateInfo { info.descriptorBindings = { vk::DescriptorSetLayoutBinding{0, vk::DescriptorType::eUniformBuffer, 1, - vk::ShaderStageFlagBits::eVertex }, + vk::ShaderStageFlagBits::eVertex | + vk::ShaderStageFlagBits::eFragment}, vk::DescriptorSetLayoutBinding{1, vk::DescriptorType::eCombinedImageSampler, 1, vk::ShaderStageFlagBits::eFragment} }; @@ -147,7 +159,8 @@ struct PipelineCreateInfo { info.descriptorBindings = { vk::DescriptorSetLayoutBinding{0, vk::DescriptorType::eUniformBuffer, 1, - vk::ShaderStageFlagBits::eVertex }, + vk::ShaderStageFlagBits::eVertex | + vk::ShaderStageFlagBits::eFragment}, vk::DescriptorSetLayoutBinding{1, vk::DescriptorType::eCombinedImageSampler, 1, vk::ShaderStageFlagBits::eFragment}, vk::DescriptorSetLayoutBinding{2, vk::DescriptorType::eStorageBuffer, 1, diff --git a/src/render/lighting_state.cpp b/src/render/lighting_state.cpp new file mode 100644 index 0000000..6254c52 --- /dev/null +++ b/src/render/lighting_state.cpp @@ -0,0 +1,110 @@ +#include "render/lighting_state.hpp" + +#include +#include + +namespace w3d { + +// ── Static defaults ─────────────────────────────────────────────────────────── +// Match the hard-coded values previously in basic.frag so that model-viewer +// mode looks the same before any map is loaded. + +const glm::vec3 LightingState::kDefaultLightDirection{1.0f, 1.0f, 1.0f}; // toward camera-right/up +const glm::vec3 LightingState::kDefaultAmbient{0.3f, 0.3f, 0.3f}; +const glm::vec3 LightingState::kDefaultDiffuse{1.0f, 1.0f, 1.0f}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +int32_t LightingState::activeSlotIndex() const { + int32_t idx = static_cast(timeOfDay_) - 1; // TimeOfDay enum: Morning=1 → slot 0 + return std::clamp(idx, 0, map::NUM_TIME_OF_DAY_SLOTS - 1); +} + +static glm::vec4 decodeShadowColor(uint32_t argb) { + float a = static_cast((argb >> 24) & 0xFFu) / 255.0f; + float r = static_cast((argb >> 16) & 0xFFu) / 255.0f; + float g = static_cast((argb >> 8) & 0xFFu) / 255.0f; + float b = static_cast((argb) & 0xFFu) / 255.0f; + return {r, g, b, a}; +} + +// ── Public interface ────────────────────────────────────────────────────────── + +void LightingState::setGlobalLighting(const map::GlobalLighting &lighting) { + lighting_ = lighting; + hasLighting_ = true; + // Pick the time-of-day stored in the map, but fall back to Morning for Invalid. + timeOfDay_ = (lighting.currentTimeOfDay != map::TimeOfDay::Invalid) ? lighting.currentTimeOfDay + : map::TimeOfDay::Morning; +} + +void LightingState::setTimeOfDay(map::TimeOfDay tod) { + timeOfDay_ = (tod != map::TimeOfDay::Invalid) ? tod : map::TimeOfDay::Morning; +} + +gfx::TerrainPushConstant LightingState::makeTerrainPushConstant(bool hasAtlas) const { + gfx::TerrainPushConstant pc{}; + + if (hasLighting_) { + const auto &slot = lighting_.timeOfDaySlots[activeSlotIndex()]; + const auto &light = slot.terrainLights[0]; + + pc.ambientColor = glm::vec4(light.ambient, 1.0f); + pc.diffuseColor = glm::vec4(light.diffuse, 1.0f); + pc.lightDirection = light.lightPos; + pc.shadowColor = decodeShadowColor(lighting_.shadowColor); + } else { + // Hard-coded fallback matching the old shader constants + pc.ambientColor = glm::vec4(kDefaultAmbient, 1.0f); + pc.diffuseColor = glm::vec4(kDefaultDiffuse, 1.0f); + pc.lightDirection = kDefaultLightDirection; + pc.shadowColor = glm::vec4(0.0f); + } + + pc.useTexture = hasAtlas ? 1u : 0u; + + // Cloud shadow animation + pc.cloudScrollU = cloudScrollU_; + pc.cloudScrollV = cloudScrollV_; + pc.cloudTime = cloudTime_; + pc.cloudStrength = cloudStrength_; + + return pc; +} + +glm::vec3 LightingState::objectAmbient() const { + if (!hasLighting_) { + return kDefaultAmbient; + } + return lighting_.timeOfDaySlots[activeSlotIndex()].objectLights[0].ambient; +} + +glm::vec3 LightingState::objectDiffuse() const { + if (!hasLighting_) { + return kDefaultDiffuse; + } + return lighting_.timeOfDaySlots[activeSlotIndex()].objectLights[0].diffuse; +} + +glm::vec3 LightingState::objectLightDirection() const { + if (!hasLighting_) { + return kDefaultLightDirection; + } + return lighting_.timeOfDaySlots[activeSlotIndex()].objectLights[0].lightPos; +} + +void LightingState::setCloudShadow(float scrollU, float scrollV, float strength) { + cloudScrollU_ = scrollU; + cloudScrollV_ = scrollV; + cloudStrength_ = strength; +} + +void LightingState::disableCloudShadow() { + cloudStrength_ = 0.0f; +} + +void LightingState::update(float deltaSeconds) { + cloudTime_ += deltaSeconds; +} + +} // namespace w3d diff --git a/src/render/lighting_state.hpp b/src/render/lighting_state.hpp new file mode 100644 index 0000000..85fae69 --- /dev/null +++ b/src/render/lighting_state.hpp @@ -0,0 +1,111 @@ +#pragma once + +#include "lib/formats/map/types.hpp" +#include "lib/gfx/pipeline.hpp" + +#include + +namespace w3d { + +/** + * Manages the scene's active lighting state for Phase 6 – Lighting & Polish. + * + * Wraps a map::GlobalLighting struct and provides: + * - Time-of-day switching (Morning / Afternoon / Evening / Night). + * - Terrain push-constant generation (6.1 ambient/diffuse/direction + + * 6.2 shadow color + 6.3 cloud shadow animation parameters). + * - Object lighting accessors for the UBO (separate from terrain lights). + * - Per-frame update() to advance cloud shadow animation time. + * + * When no GlobalLighting has been set (e.g. in model-viewer mode) the class + * returns safe hard-coded defaults so the rest of the rendering code never + * needs to branch on "do we have a map loaded?". + */ +class LightingState { +public: + LightingState() = default; + + // ── GlobalLighting ──────────────────────────────────────────────────────── + + /** Load a parsed GlobalLighting chunk and switch to its stored time-of-day. */ + void setGlobalLighting(const map::GlobalLighting &lighting); + + /** True once setGlobalLighting() has been called at least once. */ + bool hasLighting() const { return hasLighting_; } + + // ── Time-of-day ────────────────────────────────────────────────────────── + + /** + * Change the active time-of-day slot. Clamps to Morning when the + * requested value is TimeOfDay::Invalid. + */ + void setTimeOfDay(map::TimeOfDay tod); + + map::TimeOfDay timeOfDay() const { return timeOfDay_; } + + // ── Terrain push constant (6.1 + 6.2 + 6.3) ───────────────────────────── + + /** + * Build a TerrainPushConstant for the current time-of-day that includes: + * - ambient / diffuse / lightDirection from terrainLights[0] + * - shadow color decoded from GlobalLighting::shadowColor (6.2) + * - cloud animation parameters (6.3) + * + * @param hasAtlas Sets useTexture = 1 when an atlas is bound. + */ + [[nodiscard]] gfx::TerrainPushConstant makeTerrainPushConstant(bool hasAtlas) const; + + // ── Object lighting (UBO / Phase 6.1) ──────────────────────────────────── + + /** RGB ambient for the currently active objectLights[0] slot. */ + [[nodiscard]] glm::vec3 objectAmbient() const; + + /** RGB diffuse for the currently active objectLights[0] slot. */ + [[nodiscard]] glm::vec3 objectDiffuse() const; + + /** Light-source direction (lightPos) for the currently active objectLights[0] slot. */ + [[nodiscard]] glm::vec3 objectLightDirection() const; + + // ── Cloud shadows (Phase 6.3) ───────────────────────────────────────────── + + /** + * Enable cloud shadows with the given scroll speeds and strength. + * + * @param scrollU Horizontal scroll speed in UV units per second. + * @param scrollV Vertical scroll speed in UV units per second. + * @param strength Shadow intensity [0 = none, 1 = full]. + */ + void setCloudShadow(float scrollU, float scrollV, float strength); + + /** Disable cloud shadows (sets strength to 0). */ + void disableCloudShadow(); + + /** + * Advance the cloud animation by deltaSeconds. + * Call once per frame from the game/render loop. + */ + void update(float deltaSeconds); + + // ── Defaults (used when no map is loaded) ──────────────────────────────── + + /** Hard-coded diffuse-only directional light matching the pre-map viewer defaults. */ + static const glm::vec3 kDefaultLightDirection; + static const glm::vec3 kDefaultAmbient; + static const glm::vec3 kDefaultDiffuse; + +private: + /** Returns the currently active time-of-day slot index (0–3). */ + int32_t activeSlotIndex() const; + + map::GlobalLighting lighting_{}; + bool hasLighting_ = false; + map::TimeOfDay timeOfDay_ = map::TimeOfDay::Morning; + + // Cloud shadow state + float cloudScrollU_ = 0.0f; + float cloudScrollV_ = 0.0f; + float cloudTime_ = 0.0f; + float cloudStrength_ = 0.0f; +}; + +} // namespace w3d diff --git a/src/render/terrain/terrain_minimap.cpp b/src/render/terrain/terrain_minimap.cpp new file mode 100644 index 0000000..dffec5f --- /dev/null +++ b/src/render/terrain/terrain_minimap.cpp @@ -0,0 +1,118 @@ +#include "render/terrain/terrain_minimap.hpp" + +#include +#include + +namespace w3d::terrain { + +// ── Colour palette ──────────────────────────────────────────────────────────── +// Matches the low/high colour used in the terrain fragment shader fallback so +// that the minimap looks consistent with unlit terrain previews. + +static constexpr float kLowR = 0.35f, kLowG = 0.55f, kLowB = 0.25f; +static constexpr float kHighR = 0.65f, kHighG = 0.55f, kHighB = 0.40f; + +void MinimapGenerator::heightToColor(float t, uint8_t &r, uint8_t &g, uint8_t &b) { + t = std::clamp(t, 0.0f, 1.0f); + r = static_cast(std::lround((kLowR + t * (kHighR - kLowR)) * 255.0f)); + g = static_cast(std::lround((kLowG + t * (kHighG - kLowG)) * 255.0f)); + b = static_cast(std::lround((kLowB + t * (kHighB - kLowB)) * 255.0f)); +} + +// ── Full-resolution generation ──────────────────────────────────────────────── + +MinimapGenerator::MinimapImage MinimapGenerator::generate(const map::HeightMap &heightMap) { + if (!heightMap.isValid()) { + return {}; + } + + const auto w = static_cast(heightMap.width); + const auto h = static_cast(heightMap.height); + + MinimapImage img; + img.width = w; + img.height = h; + img.pixels.resize(static_cast(w) * h * 4u); + + for (uint32_t y = 0; y < h; ++y) { + for (uint32_t x = 0; x < w; ++x) { + uint8_t rawHeight = + heightMap.data[static_cast(y) * static_cast(w) + static_cast(x)]; + float t = static_cast(rawHeight) / 255.0f; + + size_t idx = (static_cast(y) * w + x) * 4u; + heightToColor(t, img.pixels[idx + 0], img.pixels[idx + 1], img.pixels[idx + 2]); + img.pixels[idx + 3] = 255u; // fully opaque + } + } + + return img; +} + +// ── Scaled generation ───────────────────────────────────────────────────────── + +MinimapGenerator::MinimapImage MinimapGenerator::generateScaled(const map::HeightMap &heightMap, + uint32_t targetWidth, + uint32_t targetHeight) { + if (!heightMap.isValid() || targetWidth == 0 || targetHeight == 0) { + return {}; + } + + const auto srcW = static_cast(heightMap.width); + const auto srcH = static_cast(heightMap.height); + + // Clamp to source dimensions + const uint32_t outW = std::min(targetWidth, srcW); + const uint32_t outH = std::min(targetHeight, srcH); + + // If no downscaling needed, just generate at full resolution. + if (outW == srcW && outH == srcH) { + return generate(heightMap); + } + + MinimapImage img; + img.width = outW; + img.height = outH; + img.pixels.resize(static_cast(outW) * outH * 4u); + + const float scaleX = static_cast(srcW) / static_cast(outW); + const float scaleY = static_cast(srcH) / static_cast(outH); + + for (uint32_t y = 0; y < outH; ++y) { + for (uint32_t x = 0; x < outW; ++x) { + // Map output pixel centre to source coordinates (bilinear sampling) + float sx = (static_cast(x) + 0.5f) * scaleX - 0.5f; + float sy = (static_cast(y) + 0.5f) * scaleY - 0.5f; + + int32_t x0 = std::clamp(static_cast(std::floor(sx)), 0, static_cast(srcW) - 1); + int32_t y0 = std::clamp(static_cast(std::floor(sy)), 0, static_cast(srcH) - 1); + int32_t x1 = std::min(x0 + 1, static_cast(srcW) - 1); + int32_t y1 = std::min(y0 + 1, static_cast(srcH) - 1); + + float fx = sx - std::floor(sx); + float fy = sy - std::floor(sy); + + auto getH = [&](int32_t gx, int32_t gy) -> float { + return static_cast( + heightMap.data[static_cast(gy) * srcW + static_cast(gx)]) / + 255.0f; + }; + + float h00 = getH(x0, y0); + float h10 = getH(x1, y0); + float h01 = getH(x0, y1); + float h11 = getH(x1, y1); + + float h = h00 * (1.0f - fx) * (1.0f - fy) + h10 * fx * (1.0f - fy) + + h01 * (1.0f - fx) * fy + h11 * fx * fy; + + size_t idx = (static_cast(y) * outW + x) * 4u; + heightToColor(h, img.pixels[idx + 0], img.pixels[idx + 1], img.pixels[idx + 2]); + img.pixels[idx + 3] = 255u; + } + } + + return img; +} + +} // namespace w3d::terrain diff --git a/src/render/terrain/terrain_minimap.hpp b/src/render/terrain/terrain_minimap.hpp new file mode 100644 index 0000000..709cf09 --- /dev/null +++ b/src/render/terrain/terrain_minimap.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include "lib/formats/map/types.hpp" + +#include +#include + +namespace w3d::terrain { + +/** + * Generates a CPU-side top-down minimap image from heightmap data. + * + * Phase 6.4 – Minimap/preview. + * + * The generated image uses RGBA8 pixel layout and is suitable for direct + * upload to a Vulkan texture (via TextureManager::createTexture) or display + * as an ImGui image. + * + * Colour encoding: + * - Without blend data: height-based gradient (dark green → light tan). + * - generateScaled() bilinearly downscales the full-resolution result. + */ +class MinimapGenerator { +public: + struct MinimapImage { + std::vector pixels; // RGBA8, row-major (top-left origin) + uint32_t width = 0; + uint32_t height = 0; + + bool isValid() const { return !pixels.empty() && width > 0 && height > 0; } + }; + + /** + * Generate a full-resolution minimap (one pixel per heightmap cell). + * + * Returns an invalid image if the heightmap has no data. + */ + [[nodiscard]] static MinimapImage generate(const map::HeightMap &heightMap); + + /** + * Generate a scaled-down minimap at the requested output dimensions. + * + * If the requested dimensions exceed the source size, the output is clamped + * to the source dimensions. Returns an invalid image if the heightmap has + * no data or either target dimension is zero. + */ + [[nodiscard]] static MinimapImage generateScaled(const map::HeightMap &heightMap, + uint32_t targetWidth, uint32_t targetHeight); + +private: + /** Blend between two terrain colours based on normalised height [0, 1]. */ + static void heightToColor(float t, uint8_t &r, uint8_t &g, uint8_t &b); +}; + +} // namespace w3d::terrain diff --git a/src/render/terrain/terrain_renderable.cpp b/src/render/terrain/terrain_renderable.cpp index 430e19c..8308c31 100644 --- a/src/render/terrain/terrain_renderable.cpp +++ b/src/render/terrain/terrain_renderable.cpp @@ -87,6 +87,22 @@ void TerrainRenderable::setLighting(const map::GlobalLighting &lighting) { pushConstant_.diffuseColor = glm::vec4(light.diffuse, 1.0f); pushConstant_.lightDirection = light.lightPos; pushConstant_.useTexture = hasAtlas() ? 1u : 0u; + + // Phase 6.2 – shadow colour decoded from ARGB uint32 + uint32_t argb = lighting.shadowColor; + float sa = static_cast((argb >> 24) & 0xFFu) / 255.0f; + float sr = static_cast((argb >> 16) & 0xFFu) / 255.0f; + float sg = static_cast((argb >> 8) & 0xFFu) / 255.0f; + float sb = static_cast((argb) & 0xFFu) / 255.0f; + pushConstant_.shadowColor = glm::vec4(sr, sg, sb, sa); +} + +void TerrainRenderable::applyLightingState(const LightingState &lightingState) { + pushConstant_ = lightingState.makeTerrainPushConstant(hasAtlas()); +} + +void TerrainRenderable::update(float deltaSeconds) { + pushConstant_.cloudTime += deltaSeconds; } void TerrainRenderable::initPipeline(gfx::VulkanContext &context, diff --git a/src/render/terrain/terrain_renderable.hpp b/src/render/terrain/terrain_renderable.hpp index 33da52f..a934c39 100644 --- a/src/render/terrain/terrain_renderable.hpp +++ b/src/render/terrain/terrain_renderable.hpp @@ -15,6 +15,7 @@ #include "lib/gfx/frustum.hpp" #include "lib/gfx/renderable.hpp" #include "lib/gfx/texture.hpp" +#include "render/lighting_state.hpp" #include "render/terrain/terrain_atlas.hpp" #include "render/terrain/terrain_mesh.hpp" @@ -64,8 +65,23 @@ class TerrainRenderable : public gfx::IRenderable { void destroy(); + /** Apply lighting from a parsed GlobalLighting chunk (legacy helper). */ void setLighting(const map::GlobalLighting &lighting); + /** + * Apply lighting from a LightingState (Phase 6.1/6.2/6.3). + * The LightingState handles time-of-day selection, shadow colour, and cloud + * animation – so prefer this over setLighting() when a LightingState is + * available. + */ + void applyLightingState(const LightingState &lightingState); + + /** + * Advance the cloud shadow animation by deltaSeconds (Phase 6.3). + * Must be called once per frame when cloud shadows are active. + */ + void update(float deltaSeconds); + gfx::Pipeline &pipeline() { return pipeline_; } gfx::DescriptorManager &descriptorManager() { return descriptorManager_; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 117432c..09906dd 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -402,3 +402,33 @@ else() endif() add_test(NAME object_resolver_tests COMMAND object_resolver_tests) + +# LightingState tests (Phase 6.1/6.2/6.3 – no Vulkan dependency) +add_executable(lighting_state_tests + render/test_lighting_state.cpp +) + +target_link_libraries(lighting_state_tests PRIVATE w3d_lib gtest gtest_main) + +if(MSVC) + target_compile_options(lighting_state_tests PRIVATE /W4 /permissive-) +else() + target_compile_options(lighting_state_tests PRIVATE -Wall -Wextra -Wpedantic -Werror) +endif() + +add_test(NAME lighting_state_tests COMMAND lighting_state_tests) + +# Terrain minimap tests (Phase 6.4 – no Vulkan dependency) +add_executable(terrain_minimap_tests + terrain/test_terrain_minimap.cpp +) + +target_link_libraries(terrain_minimap_tests PRIVATE w3d_lib gtest gtest_main) + +if(MSVC) + target_compile_options(terrain_minimap_tests PRIVATE /W4 /permissive-) +else() + target_compile_options(terrain_minimap_tests PRIVATE -Wall -Wextra -Wpedantic -Werror) +endif() + +add_test(NAME terrain_minimap_tests COMMAND terrain_minimap_tests) diff --git a/tests/map/test_objects_parser.cpp b/tests/map/test_objects_parser.cpp index bb30b71..7d93315 100644 --- a/tests/map/test_objects_parser.cpp +++ b/tests/map/test_objects_parser.cpp @@ -358,12 +358,12 @@ TEST_F(ObjectsParserTest, TestsObjectFlagHelpers) { obj.flags = FLAG_ROAD_POINT1; EXPECT_TRUE(obj.isRoadPoint()); EXPECT_FALSE(obj.isBridgePoint()); - EXPECT_TRUE(obj.shouldRender()); + EXPECT_FALSE(obj.shouldRender()); // road points are placement markers, not renderable obj.flags = FLAG_BRIDGE_POINT2; EXPECT_FALSE(obj.isRoadPoint()); EXPECT_TRUE(obj.isBridgePoint()); - EXPECT_TRUE(obj.shouldRender()); + EXPECT_FALSE(obj.shouldRender()); // bridge points are placement markers, not renderable obj.flags = FLAG_DONT_RENDER; EXPECT_FALSE(obj.isRoadPoint()); diff --git a/tests/render/test_lighting_state.cpp b/tests/render/test_lighting_state.cpp new file mode 100644 index 0000000..fd45b71 --- /dev/null +++ b/tests/render/test_lighting_state.cpp @@ -0,0 +1,327 @@ +// tests/render/test_lighting_state.cpp +// Unit tests for the LightingState class (Phase 6.1 - time-of-day lighting). +// +// No Vulkan dependency – LightingState is pure CPU-side data management. + +#include "render/lighting_state.hpp" + +#include + +using namespace w3d; +using namespace map; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static GlobalLighting makeTestLighting() { + GlobalLighting gl; + gl.currentTimeOfDay = TimeOfDay::Morning; + + // Morning slot (index 0): bright yellow-white sun from upper-left + auto &morning = gl.timeOfDaySlots[0]; + morning.terrainLights[0].ambient = glm::vec3(0.3f, 0.3f, 0.25f); + morning.terrainLights[0].diffuse = glm::vec3(0.9f, 0.85f, 0.7f); + morning.terrainLights[0].lightPos = glm::vec3(-1.0f, -1.0f, 0.5f); + morning.objectLights[0].ambient = glm::vec3(0.25f, 0.25f, 0.2f); + morning.objectLights[0].diffuse = glm::vec3(0.85f, 0.80f, 0.65f); + morning.objectLights[0].lightPos = glm::vec3(-0.9f, -1.0f, 0.4f); + + // Afternoon slot (index 1): bright white midday sun from above + auto &afternoon = gl.timeOfDaySlots[1]; + afternoon.terrainLights[0].ambient = glm::vec3(0.4f, 0.4f, 0.4f); + afternoon.terrainLights[0].diffuse = glm::vec3(1.0f, 1.0f, 0.95f); + afternoon.terrainLights[0].lightPos = glm::vec3(0.0f, -1.0f, 0.0f); + afternoon.objectLights[0].ambient = glm::vec3(0.35f, 0.35f, 0.35f); + afternoon.objectLights[0].diffuse = glm::vec3(0.95f, 0.95f, 0.9f); + afternoon.objectLights[0].lightPos = glm::vec3(0.0f, -1.0f, 0.0f); + + // Evening slot (index 2): warm orange-red low sun + auto &evening = gl.timeOfDaySlots[2]; + evening.terrainLights[0].ambient = glm::vec3(0.2f, 0.15f, 0.1f); + evening.terrainLights[0].diffuse = glm::vec3(1.0f, 0.5f, 0.2f); + evening.terrainLights[0].lightPos = glm::vec3(1.0f, -0.5f, 0.0f); + evening.objectLights[0].ambient = glm::vec3(0.15f, 0.12f, 0.08f); + evening.objectLights[0].diffuse = glm::vec3(0.9f, 0.45f, 0.18f); + evening.objectLights[0].lightPos = glm::vec3(1.0f, -0.5f, 0.0f); + + // Night slot (index 3): dark blue moonlight + auto &night = gl.timeOfDaySlots[3]; + night.terrainLights[0].ambient = glm::vec3(0.05f, 0.05f, 0.1f); + night.terrainLights[0].diffuse = glm::vec3(0.1f, 0.1f, 0.2f); + night.terrainLights[0].lightPos = glm::vec3(0.0f, -1.0f, 0.5f); + night.objectLights[0].ambient = glm::vec3(0.04f, 0.04f, 0.08f); + night.objectLights[0].diffuse = glm::vec3(0.08f, 0.08f, 0.16f); + night.objectLights[0].lightPos = glm::vec3(0.0f, -1.0f, 0.5f); + + // Shadow color: semi-transparent grey-blue (ARGB 0x80_40_50_60) + gl.shadowColor = 0x80405060u; + + return gl; +} + +// --------------------------------------------------------------------------- +// Default state (no GlobalLighting set) +// --------------------------------------------------------------------------- + +TEST(LightingState, DefaultHasNoLighting) { + LightingState ls; + EXPECT_FALSE(ls.hasLighting()); +} + +TEST(LightingState, DefaultTimeOfDayIsMorning) { + LightingState ls; + EXPECT_EQ(ls.timeOfDay(), TimeOfDay::Morning); +} + +TEST(LightingState, DefaultTerrainPushConstantUsesHardcodedFallback) { + LightingState ls; + auto pc = ls.makeTerrainPushConstant(false); + // With no lighting set the fallback values must be usable (non-zero ambient) + EXPECT_GT(pc.ambientColor.r + pc.ambientColor.g + pc.ambientColor.b, 0.0f); +} + +TEST(LightingState, DefaultObjectAmbientIsNonZero) { + LightingState ls; + auto a = ls.objectAmbient(); + EXPECT_GT(a.r + a.g + a.b, 0.0f); +} + +// --------------------------------------------------------------------------- +// Setting GlobalLighting +// --------------------------------------------------------------------------- + +TEST(LightingState, SetGlobalLightingEnablesLighting) { + LightingState ls; + ls.setGlobalLighting(makeTestLighting()); + EXPECT_TRUE(ls.hasLighting()); +} + +TEST(LightingState, SetGlobalLightingPicksCurrentTimeOfDay) { + LightingState ls; + auto gl = makeTestLighting(); + gl.currentTimeOfDay = TimeOfDay::Evening; + ls.setGlobalLighting(gl); + EXPECT_EQ(ls.timeOfDay(), TimeOfDay::Evening); +} + +// --------------------------------------------------------------------------- +// Time-of-day switching +// --------------------------------------------------------------------------- + +TEST(LightingState, SwitchToMorningReturnsMorningLighting) { + LightingState ls; + ls.setGlobalLighting(makeTestLighting()); + ls.setTimeOfDay(TimeOfDay::Morning); + + auto pc = ls.makeTerrainPushConstant(false); + // Morning ambient is (0.3, 0.3, 0.25) + EXPECT_NEAR(pc.ambientColor.r, 0.3f, 1e-5f); + EXPECT_NEAR(pc.ambientColor.g, 0.3f, 1e-5f); + EXPECT_NEAR(pc.ambientColor.b, 0.25f, 1e-5f); +} + +TEST(LightingState, SwitchToAfternoonReturnsAfternoonLighting) { + LightingState ls; + ls.setGlobalLighting(makeTestLighting()); + ls.setTimeOfDay(TimeOfDay::Afternoon); + + auto pc = ls.makeTerrainPushConstant(false); + // Afternoon ambient is (0.4, 0.4, 0.4) + EXPECT_NEAR(pc.ambientColor.r, 0.4f, 1e-5f); + EXPECT_NEAR(pc.ambientColor.g, 0.4f, 1e-5f); + EXPECT_NEAR(pc.ambientColor.b, 0.4f, 1e-5f); +} + +TEST(LightingState, SwitchToEveningReturnsEveningDiffuse) { + LightingState ls; + ls.setGlobalLighting(makeTestLighting()); + ls.setTimeOfDay(TimeOfDay::Evening); + + auto pc = ls.makeTerrainPushConstant(false); + // Evening diffuse is (1.0, 0.5, 0.2) – orange/red warm sun + EXPECT_NEAR(pc.diffuseColor.r, 1.0f, 1e-5f); + EXPECT_NEAR(pc.diffuseColor.g, 0.5f, 1e-5f); + EXPECT_NEAR(pc.diffuseColor.b, 0.2f, 1e-5f); +} + +TEST(LightingState, SwitchToNightReturnsDimLighting) { + LightingState ls; + ls.setGlobalLighting(makeTestLighting()); + ls.setTimeOfDay(TimeOfDay::Night); + + auto pc = ls.makeTerrainPushConstant(false); + // Night ambient is (0.05, 0.05, 0.1) – very dim blue + EXPECT_NEAR(pc.ambientColor.r, 0.05f, 1e-5f); + EXPECT_NEAR(pc.ambientColor.g, 0.05f, 1e-5f); + EXPECT_NEAR(pc.ambientColor.b, 0.10f, 1e-5f); +} + +TEST(LightingState, SwitchingTimeOfDayUpdatesCachedValue) { + LightingState ls; + ls.setGlobalLighting(makeTestLighting()); + ls.setTimeOfDay(TimeOfDay::Morning); + EXPECT_EQ(ls.timeOfDay(), TimeOfDay::Morning); + ls.setTimeOfDay(TimeOfDay::Night); + EXPECT_EQ(ls.timeOfDay(), TimeOfDay::Night); +} + +// --------------------------------------------------------------------------- +// Terrain push constant generation +// --------------------------------------------------------------------------- + +TEST(LightingState, MakeTerrainPushConstantUseTextureFlag) { + LightingState ls; + ls.setGlobalLighting(makeTestLighting()); + ls.setTimeOfDay(TimeOfDay::Morning); + + auto pcNo = ls.makeTerrainPushConstant(false); + auto pcYes = ls.makeTerrainPushConstant(true); + EXPECT_EQ(pcNo.useTexture, 0u); + EXPECT_EQ(pcYes.useTexture, 1u); +} + +TEST(LightingState, MakeTerrainPushConstantLightDirectionMatchesLightPos) { + LightingState ls; + ls.setGlobalLighting(makeTestLighting()); + ls.setTimeOfDay(TimeOfDay::Morning); + + auto pc = ls.makeTerrainPushConstant(false); + // Morning terrainLights[0].lightPos = (-1, -1, 0.5) + EXPECT_NEAR(pc.lightDirection.x, -1.0f, 1e-5f); + EXPECT_NEAR(pc.lightDirection.y, -1.0f, 1e-5f); + EXPECT_NEAR(pc.lightDirection.z, 0.5f, 1e-5f); +} + +// --------------------------------------------------------------------------- +// Shadow color +// --------------------------------------------------------------------------- + +TEST(LightingState, ShadowColorDecodedCorrectly) { + LightingState ls; + auto gl = makeTestLighting(); + // shadowColor = 0x80405060 → A=0x80(128), R=0x40(64), G=0x50(80), B=0x60(96) + gl.shadowColor = 0x80405060u; + ls.setGlobalLighting(gl); + + auto pc = ls.makeTerrainPushConstant(false); + // Decoded to [0,1] range: R=64/255, G=80/255, B=96/255, A=128/255 + EXPECT_NEAR(pc.shadowColor.r, 64.0f / 255.0f, 1e-3f); + EXPECT_NEAR(pc.shadowColor.g, 80.0f / 255.0f, 1e-3f); + EXPECT_NEAR(pc.shadowColor.b, 96.0f / 255.0f, 1e-3f); + EXPECT_NEAR(pc.shadowColor.a, 128.0f / 255.0f, 1e-3f); +} + +TEST(LightingState, ZeroShadowColorProducesBlackAlphaZero) { + LightingState ls; + auto gl = makeTestLighting(); + gl.shadowColor = 0u; + ls.setGlobalLighting(gl); + + auto pc = ls.makeTerrainPushConstant(false); + EXPECT_NEAR(pc.shadowColor.r, 0.0f, 1e-5f); + EXPECT_NEAR(pc.shadowColor.g, 0.0f, 1e-5f); + EXPECT_NEAR(pc.shadowColor.b, 0.0f, 1e-5f); + EXPECT_NEAR(pc.shadowColor.a, 0.0f, 1e-5f); +} + +// --------------------------------------------------------------------------- +// Object lighting (separate from terrain) +// --------------------------------------------------------------------------- + +TEST(LightingState, ObjectAmbientMatchesMorningObjectSlot) { + LightingState ls; + ls.setGlobalLighting(makeTestLighting()); + ls.setTimeOfDay(TimeOfDay::Morning); + + auto a = ls.objectAmbient(); + // Morning objectLights[0].ambient = (0.25, 0.25, 0.2) + EXPECT_NEAR(a.r, 0.25f, 1e-5f); + EXPECT_NEAR(a.g, 0.25f, 1e-5f); + EXPECT_NEAR(a.b, 0.20f, 1e-5f); +} + +TEST(LightingState, ObjectDiffuseMatchesAfternoonObjectSlot) { + LightingState ls; + ls.setGlobalLighting(makeTestLighting()); + ls.setTimeOfDay(TimeOfDay::Afternoon); + + auto d = ls.objectDiffuse(); + // Afternoon objectLights[0].diffuse = (0.95, 0.95, 0.9) + EXPECT_NEAR(d.r, 0.95f, 1e-5f); + EXPECT_NEAR(d.g, 0.95f, 1e-5f); + EXPECT_NEAR(d.b, 0.90f, 1e-5f); +} + +TEST(LightingState, ObjectLightDirectionMatchesNightSlot) { + LightingState ls; + ls.setGlobalLighting(makeTestLighting()); + ls.setTimeOfDay(TimeOfDay::Night); + + auto dir = ls.objectLightDirection(); + // Night objectLights[0].lightPos = (0, -1, 0.5) + EXPECT_NEAR(dir.x, 0.0f, 1e-5f); + EXPECT_NEAR(dir.y, -1.0f, 1e-5f); + EXPECT_NEAR(dir.z, 0.5f, 1e-5f); +} + +TEST(LightingState, ObjectAndTerrainLightingCanDifferPerSlot) { + LightingState ls; + ls.setGlobalLighting(makeTestLighting()); + ls.setTimeOfDay(TimeOfDay::Morning); + + auto pc = ls.makeTerrainPushConstant(false); + auto objA = ls.objectAmbient(); + + // Terrain ambient (0.3, 0.3, 0.25) != object ambient (0.25, 0.25, 0.2) + EXPECT_NE(pc.ambientColor.r, objA.r); +} + +// --------------------------------------------------------------------------- +// Cloud shadow parameters +// --------------------------------------------------------------------------- + +TEST(LightingState, CloudShadowDefaultsToZeroStrength) { + LightingState ls; + auto pc = ls.makeTerrainPushConstant(false); + EXPECT_NEAR(pc.cloudStrength, 0.0f, 1e-5f); +} + +TEST(LightingState, SetCloudShadowParamsReflectedInPushConstant) { + LightingState ls; + ls.setCloudShadow(0.05f, 0.02f, 0.6f); + + auto pc = ls.makeTerrainPushConstant(false); + EXPECT_NEAR(pc.cloudScrollU, 0.05f, 1e-5f); + EXPECT_NEAR(pc.cloudScrollV, 0.02f, 1e-5f); + EXPECT_NEAR(pc.cloudStrength, 0.6f, 1e-5f); +} + +TEST(LightingState, CloudTimeAdvancedByUpdate) { + LightingState ls; + ls.setCloudShadow(1.0f, 0.0f, 0.5f); + + ls.update(2.5f); + auto pc = ls.makeTerrainPushConstant(false); + EXPECT_NEAR(pc.cloudTime, 2.5f, 1e-5f); +} + +TEST(LightingState, CloudTimeAccumulatesAcrossMultipleUpdates) { + LightingState ls; + ls.setCloudShadow(1.0f, 0.0f, 0.5f); + + ls.update(1.0f); + ls.update(0.5f); + ls.update(0.25f); + auto pc = ls.makeTerrainPushConstant(false); + EXPECT_NEAR(pc.cloudTime, 1.75f, 1e-4f); +} + +TEST(LightingState, DisabledCloudShadowHasZeroStrength) { + LightingState ls; + ls.setCloudShadow(0.1f, 0.1f, 0.8f); + ls.disableCloudShadow(); + + auto pc = ls.makeTerrainPushConstant(false); + EXPECT_NEAR(pc.cloudStrength, 0.0f, 1e-5f); +} diff --git a/tests/terrain/test_terrain_minimap.cpp b/tests/terrain/test_terrain_minimap.cpp new file mode 100644 index 0000000..9950964 --- /dev/null +++ b/tests/terrain/test_terrain_minimap.cpp @@ -0,0 +1,209 @@ +// tests/terrain/test_terrain_minimap.cpp +// Unit tests for MinimapGenerator (Phase 6.4). +// +// No Vulkan dependency – minimap generation is purely CPU-side. + +#include "render/terrain/terrain_minimap.hpp" + +#include "lib/formats/map/types.hpp" + +#include + +using namespace w3d::terrain; +using namespace map; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static HeightMap makeFlat(int32_t w, int32_t h, uint8_t heightValue = 128) { + HeightMap hm; + hm.width = w; + hm.height = h; + hm.borderSize = 0; + hm.data.assign(static_cast(w * h), heightValue); + return hm; +} + +static HeightMap makeGradient(int32_t w, int32_t h) { + HeightMap hm; + hm.width = w; + hm.height = h; + hm.borderSize = 0; + hm.data.resize(static_cast(w * h)); + for (int32_t y = 0; y < h; ++y) { + for (int32_t x = 0; x < w; ++x) { + // height increases left to right, bottom to top + hm.data[static_cast(y * w + x)] = + static_cast((x + y) * 255 / (w + h - 2)); + } + } + return hm; +} + +// --------------------------------------------------------------------------- +// Empty / invalid heightmap +// --------------------------------------------------------------------------- + +TEST(MinimapGenerator, EmptyHeightmapProducesEmptyImage) { + HeightMap hm; + auto img = MinimapGenerator::generate(hm); + EXPECT_FALSE(img.isValid()); +} + +TEST(MinimapGenerator, ZeroWidthProducesEmptyImage) { + HeightMap hm; + hm.width = 0; + hm.height = 10; + hm.data.assign(10, 128); + auto img = MinimapGenerator::generate(hm); + EXPECT_FALSE(img.isValid()); +} + +TEST(MinimapGenerator, ZeroHeightProducesEmptyImage) { + HeightMap hm; + hm.width = 10; + hm.height = 0; + auto img = MinimapGenerator::generate(hm); + EXPECT_FALSE(img.isValid()); +} + +// --------------------------------------------------------------------------- +// Image dimensions +// --------------------------------------------------------------------------- + +TEST(MinimapGenerator, OutputDimensionsMatchHeightmap) { + auto hm = makeFlat(64, 64); + auto img = MinimapGenerator::generate(hm); + ASSERT_TRUE(img.isValid()); + EXPECT_EQ(img.width, 64u); + EXPECT_EQ(img.height, 64u); +} + +TEST(MinimapGenerator, NonSquareDimensionsPreserved) { + auto hm = makeFlat(128, 64); + auto img = MinimapGenerator::generate(hm); + ASSERT_TRUE(img.isValid()); + EXPECT_EQ(img.width, 128u); + EXPECT_EQ(img.height, 64u); +} + +TEST(MinimapGenerator, PixelCountIsWidthTimesHeightTimes4) { + auto hm = makeFlat(32, 48); + auto img = MinimapGenerator::generate(hm); + ASSERT_TRUE(img.isValid()); + EXPECT_EQ(img.pixels.size(), 32u * 48u * 4u); // RGBA +} + +// --------------------------------------------------------------------------- +// Flat terrain (uniform height) +// --------------------------------------------------------------------------- + +TEST(MinimapGenerator, FlatTerrainProducesUniformColor) { + auto hm = makeFlat(8, 8, 128); // mid height + auto img = MinimapGenerator::generate(hm); + ASSERT_TRUE(img.isValid()); + + // All pixels should have the same color + uint8_t r0 = img.pixels[0]; + uint8_t g0 = img.pixels[1]; + uint8_t b0 = img.pixels[2]; + for (size_t i = 0; i + 3 < img.pixels.size(); i += 4) { + EXPECT_EQ(img.pixels[i + 0], r0) << "R differs at pixel " << i / 4; + EXPECT_EQ(img.pixels[i + 1], g0) << "G differs at pixel " << i / 4; + EXPECT_EQ(img.pixels[i + 2], b0) << "B differs at pixel " << i / 4; + } +} + +TEST(MinimapGenerator, AllPixelsFullyOpaque) { + auto hm = makeFlat(16, 16); + auto img = MinimapGenerator::generate(hm); + ASSERT_TRUE(img.isValid()); + for (size_t i = 3; i < img.pixels.size(); i += 4) { + EXPECT_EQ(img.pixels[i], 255u) << "Alpha not 255 at pixel " << i / 4; + } +} + +// --------------------------------------------------------------------------- +// Gradient terrain (varying height) +// --------------------------------------------------------------------------- + +TEST(MinimapGenerator, GradientTerrainProducesVaryingColors) { + auto hm = makeGradient(16, 16); + auto img = MinimapGenerator::generate(hm); + ASSERT_TRUE(img.isValid()); + + // The darkest pixel (bottom-left, height ~0) should be darker than the + // brightest pixel (top-right, height ~255). + auto brightness = [&](size_t pixelIndex) { + return static_cast(img.pixels[pixelIndex * 4 + 0]) + + static_cast(img.pixels[pixelIndex * 4 + 1]) + + static_cast(img.pixels[pixelIndex * 4 + 2]); + }; + + size_t topRight = static_cast((15) * 16 + 15); // y=15, x=15 → highest + size_t bottomLeft = 0; // y=0, x=0 → lowest + + EXPECT_GT(brightness(topRight), brightness(bottomLeft)); +} + +TEST(MinimapGenerator, ZeroHeightPixelIsDarkest) { + auto hm = makeFlat(4, 4, 0); // all-zero height + auto img = MinimapGenerator::generate(hm); + ASSERT_TRUE(img.isValid()); + + auto hm2 = makeFlat(4, 4, 255); // all-max height + auto img2 = MinimapGenerator::generate(hm2); + ASSERT_TRUE(img2.isValid()); + + int brightness0 = static_cast(img.pixels[0]) + static_cast(img.pixels[1]) + + static_cast(img.pixels[2]); + int brightness255 = static_cast(img2.pixels[0]) + static_cast(img2.pixels[1]) + + static_cast(img2.pixels[2]); + + EXPECT_LT(brightness0, brightness255); +} + +// --------------------------------------------------------------------------- +// Large map size +// --------------------------------------------------------------------------- + +TEST(MinimapGenerator, LargeMapGeneratesWithoutError) { + auto hm = makeGradient(256, 256); + auto img = MinimapGenerator::generate(hm); + ASSERT_TRUE(img.isValid()); + EXPECT_EQ(img.width, 256u); + EXPECT_EQ(img.height, 256u); + EXPECT_EQ(img.pixels.size(), 256u * 256u * 4u); +} + +// --------------------------------------------------------------------------- +// Downscaled variant +// --------------------------------------------------------------------------- + +TEST(MinimapGenerator, GenerateScaledReducesDimensions) { + auto hm = makeGradient(256, 256); + auto img = MinimapGenerator::generateScaled(hm, 128, 128); + ASSERT_TRUE(img.isValid()); + EXPECT_EQ(img.width, 128u); + EXPECT_EQ(img.height, 128u); + EXPECT_EQ(img.pixels.size(), 128u * 128u * 4u); +} + +TEST(MinimapGenerator, GenerateScaledSinglePixel) { + auto hm = makeFlat(32, 32, 200); + auto img = MinimapGenerator::generateScaled(hm, 1, 1); + ASSERT_TRUE(img.isValid()); + EXPECT_EQ(img.width, 1u); + EXPECT_EQ(img.height, 1u); + EXPECT_EQ(img.pixels.size(), 4u); +} + +TEST(MinimapGenerator, GenerateScaledLargerThanSourceClampsToSource) { + // Requesting a larger minimap than source should clamp to source dimensions. + auto hm = makeFlat(32, 32); + auto img = MinimapGenerator::generateScaled(hm, 256, 256); + ASSERT_TRUE(img.isValid()); + EXPECT_LE(img.width, 256u); + EXPECT_LE(img.height, 256u); +} From 02d80476bcbb845b2f199e7c62452dbfc7ec715a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Feb 2026 16:38:00 +0000 Subject: [PATCH 2/3] refactor: Apply clang-format to PR files [skip ci] --- src/lib/gfx/pipeline.hpp | 12 ++++++------ src/render/lighting_state.cpp | 2 +- src/render/lighting_state.hpp | 3 ++- src/render/terrain/terrain_minimap.cpp | 14 ++++++++------ src/render/terrain/terrain_minimap.hpp | 4 ++-- tests/terrain/test_terrain_minimap.cpp | 8 +++----- 6 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/lib/gfx/pipeline.hpp b/src/lib/gfx/pipeline.hpp index 7186df7..08d4123 100644 --- a/src/lib/gfx/pipeline.hpp +++ b/src/lib/gfx/pipeline.hpp @@ -85,9 +85,9 @@ struct TerrainPushConstant { // Phase 6.2 – shadow color (decoded from GlobalLighting::shadowColor ARGB uint32) alignas(16) glm::vec4 shadowColor; // Phase 6.3 – cloud shadow animation parameters - alignas(4) float cloudScrollU; // cloud UV horizontal scroll speed (world units/sec) - alignas(4) float cloudScrollV; // cloud UV vertical scroll speed (world units/sec) - alignas(4) float cloudTime; // accumulated time for cloud UV offset + alignas(4) float cloudScrollU; // cloud UV horizontal scroll speed (world units/sec) + alignas(4) float cloudScrollV; // cloud UV vertical scroll speed (world units/sec) + alignas(4) float cloudTime; // accumulated time for cloud UV offset alignas(4) float cloudStrength; // 0 = no shadow, 1 = full shadow intensity }; @@ -136,7 +136,7 @@ struct PipelineCreateInfo { vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment}, vk::DescriptorSetLayoutBinding{1, vk::DescriptorType::eCombinedImageSampler, 1, - vk::ShaderStageFlagBits::eFragment} + vk::ShaderStageFlagBits::eFragment } }; info.pushConstants = { @@ -162,9 +162,9 @@ struct PipelineCreateInfo { vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment}, vk::DescriptorSetLayoutBinding{1, vk::DescriptorType::eCombinedImageSampler, 1, - vk::ShaderStageFlagBits::eFragment}, + vk::ShaderStageFlagBits::eFragment }, vk::DescriptorSetLayoutBinding{2, vk::DescriptorType::eStorageBuffer, 1, - vk::ShaderStageFlagBits::eVertex } + vk::ShaderStageFlagBits::eVertex } }; info.pushConstants = { diff --git a/src/render/lighting_state.cpp b/src/render/lighting_state.cpp index 6254c52..9c6b937 100644 --- a/src/render/lighting_state.cpp +++ b/src/render/lighting_state.cpp @@ -35,7 +35,7 @@ void LightingState::setGlobalLighting(const map::GlobalLighting &lighting) { hasLighting_ = true; // Pick the time-of-day stored in the map, but fall back to Morning for Invalid. timeOfDay_ = (lighting.currentTimeOfDay != map::TimeOfDay::Invalid) ? lighting.currentTimeOfDay - : map::TimeOfDay::Morning; + : map::TimeOfDay::Morning; } void LightingState::setTimeOfDay(map::TimeOfDay tod) { diff --git a/src/render/lighting_state.hpp b/src/render/lighting_state.hpp index 85fae69..441442f 100644 --- a/src/render/lighting_state.hpp +++ b/src/render/lighting_state.hpp @@ -1,10 +1,11 @@ #pragma once -#include "lib/formats/map/types.hpp" #include "lib/gfx/pipeline.hpp" #include +#include "lib/formats/map/types.hpp" + namespace w3d { /** diff --git a/src/render/terrain/terrain_minimap.cpp b/src/render/terrain/terrain_minimap.cpp index dffec5f..98499c6 100644 --- a/src/render/terrain/terrain_minimap.cpp +++ b/src/render/terrain/terrain_minimap.cpp @@ -52,8 +52,8 @@ MinimapGenerator::MinimapImage MinimapGenerator::generate(const map::HeightMap & // ── Scaled generation ───────────────────────────────────────────────────────── MinimapGenerator::MinimapImage MinimapGenerator::generateScaled(const map::HeightMap &heightMap, - uint32_t targetWidth, - uint32_t targetHeight) { + uint32_t targetWidth, + uint32_t targetHeight) { if (!heightMap.isValid() || targetWidth == 0 || targetHeight == 0) { return {}; } @@ -84,8 +84,10 @@ MinimapGenerator::MinimapImage MinimapGenerator::generateScaled(const map::Heigh float sx = (static_cast(x) + 0.5f) * scaleX - 0.5f; float sy = (static_cast(y) + 0.5f) * scaleY - 0.5f; - int32_t x0 = std::clamp(static_cast(std::floor(sx)), 0, static_cast(srcW) - 1); - int32_t y0 = std::clamp(static_cast(std::floor(sy)), 0, static_cast(srcH) - 1); + int32_t x0 = + std::clamp(static_cast(std::floor(sx)), 0, static_cast(srcW) - 1); + int32_t y0 = + std::clamp(static_cast(std::floor(sy)), 0, static_cast(srcH) - 1); int32_t x1 = std::min(x0 + 1, static_cast(srcW) - 1); int32_t y1 = std::min(y0 + 1, static_cast(srcH) - 1); @@ -103,8 +105,8 @@ MinimapGenerator::MinimapImage MinimapGenerator::generateScaled(const map::Heigh float h01 = getH(x0, y1); float h11 = getH(x1, y1); - float h = h00 * (1.0f - fx) * (1.0f - fy) + h10 * fx * (1.0f - fy) + - h01 * (1.0f - fx) * fy + h11 * fx * fy; + float h = h00 * (1.0f - fx) * (1.0f - fy) + h10 * fx * (1.0f - fy) + h01 * (1.0f - fx) * fy + + h11 * fx * fy; size_t idx = (static_cast(y) * outW + x) * 4u; heightToColor(h, img.pixels[idx + 0], img.pixels[idx + 1], img.pixels[idx + 2]); diff --git a/src/render/terrain/terrain_minimap.hpp b/src/render/terrain/terrain_minimap.hpp index 709cf09..bc76394 100644 --- a/src/render/terrain/terrain_minimap.hpp +++ b/src/render/terrain/terrain_minimap.hpp @@ -1,10 +1,10 @@ #pragma once -#include "lib/formats/map/types.hpp" - #include #include +#include "lib/formats/map/types.hpp" + namespace w3d::terrain { /** diff --git a/tests/terrain/test_terrain_minimap.cpp b/tests/terrain/test_terrain_minimap.cpp index 9950964..63e3797 100644 --- a/tests/terrain/test_terrain_minimap.cpp +++ b/tests/terrain/test_terrain_minimap.cpp @@ -3,9 +3,8 @@ // // No Vulkan dependency – minimap generation is purely CPU-side. -#include "render/terrain/terrain_minimap.hpp" - #include "lib/formats/map/types.hpp" +#include "render/terrain/terrain_minimap.hpp" #include @@ -34,8 +33,7 @@ static HeightMap makeGradient(int32_t w, int32_t h) { for (int32_t y = 0; y < h; ++y) { for (int32_t x = 0; x < w; ++x) { // height increases left to right, bottom to top - hm.data[static_cast(y * w + x)] = - static_cast((x + y) * 255 / (w + h - 2)); + hm.data[static_cast(y * w + x)] = static_cast((x + y) * 255 / (w + h - 2)); } } return hm; @@ -142,7 +140,7 @@ TEST(MinimapGenerator, GradientTerrainProducesVaryingColors) { }; size_t topRight = static_cast((15) * 16 + 15); // y=15, x=15 → highest - size_t bottomLeft = 0; // y=0, x=0 → lowest + size_t bottomLeft = 0; // y=0, x=0 → lowest EXPECT_GT(brightness(topRight), brightness(bottomLeft)); } From 291c939b83bf321d51981ab57263ec9abc425d88 Mon Sep 17 00:00:00 2001 From: ViTeXFTW Date: Mon, 23 Feb 2026 19:31:15 +0100 Subject: [PATCH 3/3] chore: address review comments --- src/render/terrain/terrain_renderable.cpp | 22 +++------------------- src/render/terrain/terrain_renderable.hpp | 17 +++++++++-------- tests/terrain/test_terrain_atlas.cpp | 2 ++ 3 files changed, 14 insertions(+), 27 deletions(-) diff --git a/src/render/terrain/terrain_renderable.cpp b/src/render/terrain/terrain_renderable.cpp index 8308c31..80eaa2a 100644 --- a/src/render/terrain/terrain_renderable.cpp +++ b/src/render/terrain/terrain_renderable.cpp @@ -80,31 +80,15 @@ void TerrainRenderable::destroy() { } void TerrainRenderable::setLighting(const map::GlobalLighting &lighting) { - const auto ¤t = lighting.getCurrentLighting(); - const auto &light = current.terrainLights[0]; - - pushConstant_.ambientColor = glm::vec4(light.ambient, 1.0f); - pushConstant_.diffuseColor = glm::vec4(light.diffuse, 1.0f); - pushConstant_.lightDirection = light.lightPos; - pushConstant_.useTexture = hasAtlas() ? 1u : 0u; - - // Phase 6.2 – shadow colour decoded from ARGB uint32 - uint32_t argb = lighting.shadowColor; - float sa = static_cast((argb >> 24) & 0xFFu) / 255.0f; - float sr = static_cast((argb >> 16) & 0xFFu) / 255.0f; - float sg = static_cast((argb >> 8) & 0xFFu) / 255.0f; - float sb = static_cast((argb) & 0xFFu) / 255.0f; - pushConstant_.shadowColor = glm::vec4(sr, sg, sb, sa); + LightingState tempState; + tempState.setGlobalLighting(lighting); + applyLightingState(tempState); } void TerrainRenderable::applyLightingState(const LightingState &lightingState) { pushConstant_ = lightingState.makeTerrainPushConstant(hasAtlas()); } -void TerrainRenderable::update(float deltaSeconds) { - pushConstant_.cloudTime += deltaSeconds; -} - void TerrainRenderable::initPipeline(gfx::VulkanContext &context, gfx::TextureManager &textureManager, uint32_t frameCount) { pipeline_.create(context, gfx::PipelineCreateInfo::terrain()); diff --git a/src/render/terrain/terrain_renderable.hpp b/src/render/terrain/terrain_renderable.hpp index a934c39..e2af451 100644 --- a/src/render/terrain/terrain_renderable.hpp +++ b/src/render/terrain/terrain_renderable.hpp @@ -70,18 +70,19 @@ class TerrainRenderable : public gfx::IRenderable { /** * Apply lighting from a LightingState (Phase 6.1/6.2/6.3). - * The LightingState handles time-of-day selection, shadow colour, and cloud - * animation – so prefer this over setLighting() when a LightingState is + * The LightingState handles + * time-of-day selection, shadow colour, and cloud + * animation – so prefer this over + * setLighting() when a LightingState is * available. + * + * This should be called each + * frame to get the updated push constant with + * the current cloud animation time from + * LightingState. */ void applyLightingState(const LightingState &lightingState); - /** - * Advance the cloud shadow animation by deltaSeconds (Phase 6.3). - * Must be called once per frame when cloud shadows are active. - */ - void update(float deltaSeconds); - gfx::Pipeline &pipeline() { return pipeline_; } gfx::DescriptorManager &descriptorManager() { return descriptorManager_; } diff --git a/tests/terrain/test_terrain_atlas.cpp b/tests/terrain/test_terrain_atlas.cpp index 2eeb15a..1c32907 100644 --- a/tests/terrain/test_terrain_atlas.cpp +++ b/tests/terrain/test_terrain_atlas.cpp @@ -1,3 +1,5 @@ +#include + #include "render/terrain/terrain_atlas.hpp" #include