diff --git a/shaders/water.frag b/shaders/water.frag new file mode 100644 index 0000000..84f4cbb --- /dev/null +++ b/shaders/water.frag @@ -0,0 +1,42 @@ +#version 450 + +// ────────────────────────────────────────────────────────────────────────────── +// water.frag – Water surface fragment shader +// +// Samples a water texture at two scrolling UV offsets and blends them for a +// ripple effect. The final alpha is controlled by the waterColor push constant +// (alpha channel) and the WaterTransparency INI settings forwarded via the +// minOpacity push constant. +// ────────────────────────────────────────────────────────────────────────────── + +layout(location = 0) in vec2 fragUV1; +layout(location = 1) in vec2 fragUV2; + +layout(location = 0) out vec4 outColor; + +layout(set = 0, binding = 1) uniform sampler2D waterTexture; + +layout(push_constant) uniform WaterParams { + vec4 waterColor; // rgba; a = base opacity + float time; + float uScrollRate; + float vScrollRate; + float uvScale; +} params; + +void main() { + // Sample the water texture at two scrolling offsets. + vec4 sample1 = texture(waterTexture, fragUV1); + vec4 sample2 = texture(waterTexture, fragUV2); + + // Average the two layers for a cross-ripple look. + vec4 blended = mix(sample1, sample2, 0.5); + + // Tint by the configured water diffuse color. + vec3 color = blended.rgb * params.waterColor.rgb; + + // Use the push-constant alpha for overall water transparency. + float alpha = params.waterColor.a; + + outColor = vec4(color, alpha); +} diff --git a/shaders/water.vert b/shaders/water.vert new file mode 100644 index 0000000..cd45c1c --- /dev/null +++ b/shaders/water.vert @@ -0,0 +1,47 @@ +#version 450 + +// ────────────────────────────────────────────────────────────────────────────── +// water.vert – Water surface vertex shader +// +// Generates two sets of scrolling UV coordinates for the two-layer water +// texture effect used in C&C Generals: Zero Hour. +// ────────────────────────────────────────────────────────────────────────────── + +layout(set = 0, binding = 0) uniform UniformBufferObject { + mat4 model; + mat4 view; + mat4 proj; +} ubo; + +// Push constants (matched with WaterPushConstant in pipeline.hpp) +layout(push_constant) uniform WaterParams { + vec4 waterColor; // rgba; a = base opacity + float time; // elapsed seconds for UV animation + float uScrollRate; // primary layer scroll speed in U + float vScrollRate; // primary layer scroll speed in V + float uvScale; // world-units-to-UV scale (tiles per MAP_XY_FACTOR) +} params; + +layout(location = 0) in vec3 inPosition; +layout(location = 1) in vec2 inTexCoord; // normalised world-space XZ (1 unit = 1 map cell) + +layout(location = 0) out vec2 fragUV1; // primary scroll layer +layout(location = 1) out vec2 fragUV2; // secondary scroll layer (opposite direction) + +void main() { + vec4 worldPos = ubo.model * vec4(inPosition, 1.0); + gl_Position = ubo.proj * ubo.view * worldPos; + + vec2 base = inTexCoord * params.uvScale; + + // Primary layer scrolls at (uScrollRate, vScrollRate). + fragUV1 = base + vec2(params.uScrollRate * params.time, + params.vScrollRate * params.time); + + // Secondary layer scrolls at 70 % speed in the perpendicular direction to + // give a cross-ripple appearance (matches the original SAGE water look). + float s = params.uScrollRate * 0.7; + float t = params.vScrollRate * 0.7; + fragUV2 = base + vec2(-t * params.time, + s * params.time); +} diff --git a/src/lib/gfx/pipeline.hpp b/src/lib/gfx/pipeline.hpp index 5bd8e75..dd4fee2 100644 --- a/src/lib/gfx/pipeline.hpp +++ b/src/lib/gfx/pipeline.hpp @@ -80,6 +80,14 @@ struct TerrainPushConstant { alignas(4) uint32_t useTexture; }; +struct WaterPushConstant { + alignas(16) glm::vec4 waterColor; // rgb = diffuse tint, a = opacity + alignas(4) float time; // elapsed seconds for UV animation + alignas(4) float uScrollRate; // primary scroll speed in U (units/sec) + alignas(4) float vScrollRate; // primary scroll speed in V (units/sec) + alignas(4) float uvScale; // world-units-to-UV tiling scale +}; + struct PipelineConfig { bool enableBlending = false; bool alphaBlend = false; @@ -180,6 +188,46 @@ struct PipelineCreateInfo { return info; } + + // Water surface pipeline: + // vertex layout : position (vec3) + texCoord (vec2) = 20 bytes + // descriptor set: binding 0 = UBO (vert), binding 1 = water texture (frag) + // push constants: WaterPushConstant (vert + frag) + // blending : alpha blend enabled, depth writes disabled + static PipelineCreateInfo water() { + PipelineCreateInfo info; + info.vertShaderPath = "shaders/water.vert.spv"; + info.fragShaderPath = "shaders/water.frag.spv"; + + // WaterVertex: position (vec3 = 12 B) + texCoord (vec2 = 8 B) = 20 B + info.vertexInput.binding = + vk::VertexInputBindingDescription{0, 20, vk::VertexInputRate::eVertex}; + info.vertexInput.attributes = { + vk::VertexInputAttributeDescription{0, 0, vk::Format::eR32G32B32Sfloat, 0 }, + vk::VertexInputAttributeDescription{1, 0, vk::Format::eR32G32Sfloat, 12} + }; + + info.descriptorBindings = { + vk::DescriptorSetLayoutBinding{0, vk::DescriptorType::eUniformBuffer, 1, + vk::ShaderStageFlagBits::eVertex }, + vk::DescriptorSetLayoutBinding{1, vk::DescriptorType::eCombinedImageSampler, 1, + vk::ShaderStageFlagBits::eFragment} + }; + + // Push constants are needed in both stages (time/scroll in vert, color in frag). + info.pushConstants = { + vk::PushConstantRange{vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment, + 0, sizeof(WaterPushConstant)} + }; + + // Alpha blending on, depth writes off (render after terrain). + info.config.enableBlending = true; + info.config.alphaBlend = true; + info.config.depthWrite = false; + info.config.twoSided = true; // water visible from above and below + + return info; + } }; class Pipeline { diff --git a/src/render/water/water_mesh.cpp b/src/render/water/water_mesh.cpp new file mode 100644 index 0000000..5319104 --- /dev/null +++ b/src/render/water/water_mesh.cpp @@ -0,0 +1,214 @@ +#include "render/water/water_mesh.hpp" + +#include + +#include +#include +#include + +namespace w3d::water { + +glm::vec3 triggerPointToWorld(const glm::ivec3 &point) { + return glm::vec3{ + static_cast(point.x) * map::MAP_XY_FACTOR, + static_cast(point.z) * map::MAP_HEIGHT_SCALE, + static_cast(point.y) * map::MAP_XY_FACTOR, + }; +} + +namespace { + +// Signed area of a 2-D triangle (positive = CCW). +float signedTriangleArea(glm::vec2 a, glm::vec2 b, glm::vec2 c) { + return 0.5f * ((b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y)); +} + +// True if point p lies strictly inside triangle (a, b, c) (CCW winding). +bool pointInTriangle(glm::vec2 p, glm::vec2 a, glm::vec2 b, glm::vec2 c) { + float d0 = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x); + float d1 = (c.x - b.x) * (p.y - b.y) - (c.y - b.y) * (p.x - b.x); + float d2 = (a.x - c.x) * (p.y - c.y) - (a.y - c.y) * (p.x - c.x); + + bool hasNeg = (d0 < 0.0f) || (d1 < 0.0f) || (d2 < 0.0f); + bool hasPos = (d0 > 0.0f) || (d1 > 0.0f) || (d2 > 0.0f); + return !(hasNeg && hasPos); +} + +// Return true if vertex at index `i` is an ear of the polygon. +// `indices` is the current list of active vertex indices into `poly2d`. +bool isEar(const std::vector &poly2d, const std::vector &indices, size_t i) { + size_t n = indices.size(); + if (n < 3) { + return false; + } + + size_t prev = (i + n - 1) % n; + size_t next = (i + 1) % n; + + glm::vec2 a = poly2d[indices[prev]]; + glm::vec2 b = poly2d[indices[i]]; + glm::vec2 c = poly2d[indices[next]]; + + // The ear triangle must be counter-clockwise (convex vertex). + if (signedTriangleArea(a, b, c) <= 0.0f) { + return false; + } + + // No other vertex may lie inside the ear triangle. + for (size_t j = 0; j < n; ++j) { + if (j == prev || j == i || j == next) { + continue; + } + if (pointInTriangle(poly2d[indices[j]], a, b, c)) { + return false; + } + } + return true; +} + +} // namespace + +std::vector earClipTriangulate(const std::vector &poly2d) { + size_t n = poly2d.size(); + if (n < 3) { + return {}; + } + + // Ensure the polygon is CCW; if not, reverse it. + float area = 0.0f; + for (size_t i = 0; i < n; ++i) { + size_t j = (i + 1) % n; + area += poly2d[i].x * poly2d[j].y; + area -= poly2d[j].x * poly2d[i].y; + } + + std::vector indices(n); + for (size_t i = 0; i < n; ++i) { + indices[i] = i; + } + + if (area < 0.0f) { + // CW polygon: reverse to make CCW. + std::reverse(indices.begin(), indices.end()); + } + + std::vector result; + result.reserve((n - 2) * 3); + + size_t remaining = n; + size_t safetyLimit = n * n + n; // prevent infinite loops on degenerate input + size_t current = 0; + + while (remaining > 3 && safetyLimit-- > 0) { + bool earFound = false; + for (size_t i = 0; i < remaining; ++i) { + if (isEar(poly2d, indices, i)) { + size_t prev = (i + remaining - 1) % remaining; + size_t next = (i + 1) % remaining; + + result.push_back(static_cast(indices[prev])); + result.push_back(static_cast(indices[i])); + result.push_back(static_cast(indices[next])); + + indices.erase(indices.begin() + static_cast(i)); + --remaining; + earFound = true; + break; + } + } + + // If no ear was found (degenerate polygon), bail out. + if (!earFound) { + break; + } + } + + // Add the remaining triangle. + if (remaining == 3) { + result.push_back(static_cast(indices[0])); + result.push_back(static_cast(indices[1])); + result.push_back(static_cast(indices[2])); + } + + return result; +} + +std::optional generateWaterPolygon(const map::PolygonTrigger &trigger) { + if (!trigger.isWaterArea || trigger.points.size() < 3) { + return std::nullopt; + } + + WaterPolygon poly; + poly.name = trigger.name; + + // Convert trigger points to world-space vertices. + poly.vertices.reserve(trigger.points.size()); + + gfx::BoundingBox bounds; + + for (const auto &pt : trigger.points) { + glm::vec3 world = triggerPointToWorld(pt); + + // Use the average Z of all points as water height (they should be equal, but + // guard against minor inconsistencies in real map data). + poly.waterHeight += world.y; + + WaterVertex v; + v.position = world; + // UV is world-space XZ normalised by MAP_XY_FACTOR so one texel = one map cell. + v.texCoord = glm::vec2{world.x / map::MAP_XY_FACTOR, world.z / map::MAP_XY_FACTOR}; + + bounds.expand(world); + poly.vertices.push_back(v); + } + + poly.waterHeight /= static_cast(poly.vertices.size()); + + // Flatten all vertices to the averaged water height so the surface is perfectly flat. + for (auto &v : poly.vertices) { + v.position.y = poly.waterHeight; + } + + // Project vertices into 2-D (XZ plane) for triangulation. + std::vector poly2d; + poly2d.reserve(poly.vertices.size()); + for (const auto &v : poly.vertices) { + poly2d.emplace_back(v.position.x, v.position.z); + } + + poly.indices = earClipTriangulate(poly2d); + if (poly.indices.empty()) { + return std::nullopt; + } + + // Recompute bounding box with the flattened positions. + bounds = gfx::BoundingBox{}; + for (const auto &v : poly.vertices) { + bounds.expand(v.position); + } + poly.bounds = bounds; + + return poly; +} + +WaterMeshData generateWaterMeshes(const std::vector &triggers) { + WaterMeshData data; + + for (const auto &trigger : triggers) { + if (!trigger.isWaterArea) { + continue; + } + + auto poly = generateWaterPolygon(trigger); + if (!poly) { + continue; + } + + data.totalBounds.expand(poly->bounds); + data.polygons.push_back(std::move(*poly)); + } + + return data; +} + +} // namespace w3d::water diff --git a/src/render/water/water_mesh.hpp b/src/render/water/water_mesh.hpp new file mode 100644 index 0000000..362f917 --- /dev/null +++ b/src/render/water/water_mesh.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include + +#include +#include +#include +#include + +#include "lib/formats/map/types.hpp" +#include "lib/gfx/bounding_box.hpp" + +namespace w3d::water { + +// A single water surface vertex. +// texCoord stores normalized world-space XZ position for UV scrolling in the shader. +struct WaterVertex { + glm::vec3 position; + glm::vec2 texCoord; // world-space XZ / MAP_XY_FACTOR for tiling +}; + +// A single triangulated water polygon ready for GPU upload. +struct WaterPolygon { + std::vector vertices; + std::vector indices; + gfx::BoundingBox bounds; + float waterHeight = 0.0f; // world-space Y of the water surface + std::string name; // from PolygonTrigger +}; + +// All water polygons generated from a map's polygon triggers. +struct WaterMeshData { + std::vector polygons; + gfx::BoundingBox totalBounds; +}; + +// Convert a PolygonTrigger point to a world-space 3-D position. +// +// Polygon trigger coordinates are stored as raw integers in "map cell" units: +// world X = point.x * MAP_XY_FACTOR +// world Z = point.y * MAP_XY_FACTOR (map Y → 3-D Z, south direction) +// world Y = point.z * MAP_HEIGHT_SCALE (map Z → 3-D Y, vertical height) +[[nodiscard]] glm::vec3 triggerPointToWorld(const glm::ivec3 &point); + +// Triangulate a simple (possibly concave) polygon using ear-clipping. +// Returns an index list referencing the original vertex array. +// Returns an empty list if the polygon has fewer than 3 vertices. +[[nodiscard]] std::vector earClipTriangulate(const std::vector &poly2d); + +// Generate a flat water mesh for a single water PolygonTrigger. +// Returns an empty optional if the trigger is not a valid water area. +[[nodiscard]] std::optional generateWaterPolygon(const map::PolygonTrigger &trigger); + +// Generate water meshes for all water PolygonTriggers in a map file. +[[nodiscard]] WaterMeshData generateWaterMeshes(const std::vector &triggers); + +} // namespace w3d::water diff --git a/src/render/water/water_renderable.cpp b/src/render/water/water_renderable.cpp new file mode 100644 index 0000000..ac7fd3e --- /dev/null +++ b/src/render/water/water_renderable.cpp @@ -0,0 +1,145 @@ +#include "render/water/water_renderable.hpp" + +#include "lib/gfx/vulkan_context.hpp" + +namespace w3d::water { + +WaterRenderable::~WaterRenderable() { + destroy(); +} + +void WaterRenderable::load(gfx::VulkanContext &context, + const std::vector &triggers) { + destroy(); + + auto meshData = generateWaterMeshes(triggers); + if (meshData.polygons.empty()) { + return; + } + + bounds_ = meshData.totalBounds; + uploadPolygons(context, meshData); +} + +void WaterRenderable::update(float deltaSeconds) { + pushConstant_.time += deltaSeconds; +} + +void WaterRenderable::applyWaterSettings(const ini::WaterSettings &settings, ini::TimeOfDay tod) { + const auto &ws = settings.getForTimeOfDay(tod); + const auto &tr = settings.transparency; + + // Diffuse tint from the standing-water vertex color (average of 4 corners). + // RGBAColorInt stores components as int32 in [0, 255]. + auto toF = [](int32_t v) { return static_cast(v) / 255.0f; }; + + glm::vec4 avg{0.0f}; + avg.r = (toF(ws.vertex00Diffuse.r) + toF(ws.vertex10Diffuse.r) + toF(ws.vertex11Diffuse.r) + + toF(ws.vertex01Diffuse.r)) * + 0.25f; + avg.g = (toF(ws.vertex00Diffuse.g) + toF(ws.vertex10Diffuse.g) + toF(ws.vertex11Diffuse.g) + + toF(ws.vertex01Diffuse.g)) * + 0.25f; + avg.b = (toF(ws.vertex00Diffuse.b) + toF(ws.vertex10Diffuse.b) + toF(ws.vertex11Diffuse.b) + + toF(ws.vertex01Diffuse.b)) * + 0.25f; + avg.a = tr.minWaterOpacity; + + pushConstant_.waterColor = avg; + pushConstant_.uScrollRate = ws.uScrollPerMs * 1000.0f; // convert ms→s + pushConstant_.vScrollRate = ws.vScrollPerMs * 1000.0f; + // UV scale: waterRepeatCount tiles across the water texture. + pushConstant_.uvScale = + (ws.waterRepeatCount > 0) ? static_cast(ws.waterRepeatCount) : 8.0f; +} + +void WaterRenderable::initPipeline(gfx::VulkanContext &context, gfx::TextureManager &textureManager, + uint32_t frameCount) { + pipeline_.create(context, gfx::PipelineCreateInfo::water()); + descriptorManager_.create(context, pipeline_.descriptorSetLayout(), frameCount); + + // Use default white texture until a real water texture is loaded. + const auto &defaultTex = textureManager.texture(0); + for (uint32_t i = 0; i < frameCount; ++i) { + descriptorManager_.updateTexture(i, defaultTex.view, defaultTex.sampler); + } + + // Sensible defaults so water is visible without INI. + pushConstant_.waterColor = glm::vec4{0.35f, 0.55f, 0.85f, 0.75f}; + pushConstant_.uScrollRate = 0.05f; + pushConstant_.vScrollRate = 0.03f; + pushConstant_.uvScale = 8.0f; + pushConstant_.time = 0.0f; +} + +void WaterRenderable::updateDescriptors(uint32_t frameIndex, vk::Buffer uniformBuffer, + vk::DeviceSize uboSize) { + descriptorManager_.updateUniformBuffer(frameIndex, uniformBuffer, uboSize); +} + +void WaterRenderable::draw(vk::CommandBuffer cmd) { + for (const auto &poly : gpuPolygons_) { + if (poly.indexCount == 0) { + continue; + } + vk::Buffer vb = poly.vertexBuffer.buffer(); + vk::DeviceSize offset = 0; + cmd.bindVertexBuffers(0, vb, offset); + cmd.bindIndexBuffer(poly.indexBuffer.buffer(), 0, vk::IndexType::eUint32); + cmd.drawIndexed(poly.indexCount, 1, 0, 0, 0); + } +} + +void WaterRenderable::drawWithPipeline(vk::CommandBuffer cmd, uint32_t frameIndex) { + if (!hasData()) { + return; + } + + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, pipeline_.pipeline()); + cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, pipeline_.layout(), 0, + descriptorManager_.descriptorSet(frameIndex), {}); + + cmd.pushConstants(pipeline_.layout(), + vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment, 0, + sizeof(gfx::WaterPushConstant), &pushConstant_); + + draw(cmd); +} + +void WaterRenderable::destroy() { + for (auto &poly : gpuPolygons_) { + poly.destroy(); + } + gpuPolygons_.clear(); + bounds_ = gfx::BoundingBox{}; + + descriptorManager_.destroy(); + pipeline_.destroy(); + + waterTextureIndex_ = ~0u; + pushConstant_ = gfx::WaterPushConstant{}; +} + +void WaterRenderable::uploadPolygons(gfx::VulkanContext &context, const WaterMeshData &meshData) { + gpuPolygons_.resize(meshData.polygons.size()); + + for (size_t i = 0; i < meshData.polygons.size(); ++i) { + const auto &src = meshData.polygons[i]; + auto &dst = gpuPolygons_[i]; + + if (src.vertices.empty() || src.indices.empty()) { + continue; + } + + dst.vertexBuffer.create(context, src.vertices.data(), sizeof(WaterVertex) * src.vertices.size(), + vk::BufferUsageFlagBits::eVertexBuffer); + + dst.indexBuffer.create(context, src.indices.data(), sizeof(uint32_t) * src.indices.size(), + vk::BufferUsageFlagBits::eIndexBuffer); + + dst.indexCount = static_cast(src.indices.size()); + dst.bounds = src.bounds; + } +} + +} // namespace w3d::water diff --git a/src/render/water/water_renderable.hpp b/src/render/water/water_renderable.hpp new file mode 100644 index 0000000..ff87b78 --- /dev/null +++ b/src/render/water/water_renderable.hpp @@ -0,0 +1,100 @@ +#pragma once + +#include "lib/gfx/buffer.hpp" +#include "lib/gfx/pipeline.hpp" + +#include + +#include + +#include +#include +#include + +#include "lib/formats/ini/water_settings.hpp" +#include "lib/formats/map/types.hpp" +#include "lib/gfx/bounding_box.hpp" +#include "lib/gfx/renderable.hpp" +#include "lib/gfx/texture.hpp" +#include "render/water/water_mesh.hpp" + +namespace w3d::gfx { +class VulkanContext; +} // namespace w3d::gfx + +namespace w3d::water { + +// GPU representation of a single water polygon. +struct GPUWaterPolygon { + gfx::StagedBuffer vertexBuffer; + gfx::StagedBuffer indexBuffer; + uint32_t indexCount = 0; + gfx::BoundingBox bounds; + + void destroy() { + vertexBuffer.destroy(); + indexBuffer.destroy(); + indexCount = 0; + } +}; + +// Renders all water surfaces loaded from a map's PolygonTriggers. +// +// Usage: +// 1. Call load() with the map triggers. +// 2. Call initPipeline() with a VulkanContext. +// 3. Each frame: call update(deltaSeconds) then drawWithPipeline(). +class WaterRenderable : public gfx::IRenderable { +public: + WaterRenderable() = default; + ~WaterRenderable() override; + + WaterRenderable(const WaterRenderable &) = delete; + WaterRenderable &operator=(const WaterRenderable &) = delete; + + // Build GPU buffers from the polygon triggers in a map. + void load(gfx::VulkanContext &context, const std::vector &triggers); + + // Create the Vulkan pipeline (must be called before drawWithPipeline). + void initPipeline(gfx::VulkanContext &context, gfx::TextureManager &textureManager, + uint32_t frameCount); + + // Apply INI water appearance settings (scroll rates, color, opacity). + void applyWaterSettings(const ini::WaterSettings &settings, + ini::TimeOfDay tod = ini::TimeOfDay::Morning); + + // Advance animation time by deltaSeconds. + void update(float deltaSeconds); + + // Update per-frame UBO (call once per frame before drawWithPipeline). + void updateDescriptors(uint32_t frameIndex, vk::Buffer uniformBuffer, vk::DeviceSize uboSize); + + // Bind pipeline + descriptors, then emit draw calls. + void drawWithPipeline(vk::CommandBuffer cmd, uint32_t frameIndex); + + // IRenderable interface. + void draw(vk::CommandBuffer cmd) override; + const gfx::BoundingBox &bounds() const override { return bounds_; } + const char *typeName() const override { return "Water"; } + bool isValid() const override { return !gpuPolygons_.empty(); } + + bool hasData() const { return !gpuPolygons_.empty(); } + uint32_t polygonCount() const { return static_cast(gpuPolygons_.size()); } + + void destroy(); + +private: + void uploadPolygons(gfx::VulkanContext &context, const WaterMeshData &meshData); + + std::vector gpuPolygons_; + gfx::BoundingBox bounds_; + + gfx::Pipeline pipeline_; + gfx::DescriptorManager descriptorManager_; + + gfx::WaterPushConstant pushConstant_{}; + + uint32_t waterTextureIndex_ = ~0u; +}; + +} // namespace w3d::water diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ffed879..c653a5a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -348,3 +348,18 @@ else() endif() add_test(NAME terrain_blend_tests COMMAND terrain_blend_tests) + +# Water mesh generation tests (requires GLM, no Vulkan) +add_executable(water_mesh_tests + water/test_water_mesh.cpp +) + +target_link_libraries(water_mesh_tests PRIVATE w3d_lib gtest gtest_main) + +if(MSVC) + target_compile_options(water_mesh_tests PRIVATE /W4 /permissive-) +else() + target_compile_options(water_mesh_tests PRIVATE -Wall -Wextra -Wpedantic -Werror) +endif() + +add_test(NAME water_mesh_tests COMMAND water_mesh_tests) diff --git a/tests/water/test_water_mesh.cpp b/tests/water/test_water_mesh.cpp new file mode 100644 index 0000000..c3d0b8d --- /dev/null +++ b/tests/water/test_water_mesh.cpp @@ -0,0 +1,407 @@ +#include + +#include + +#include "render/water/water_mesh.hpp" + +#include + +using namespace w3d::water; +using namespace w3d; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +namespace { + +map::PolygonTrigger makeWaterTrigger(const std::string &name, const std::vector &points, + bool isWater = true) { + map::PolygonTrigger t; + t.name = name; + t.id = 1; + t.isWaterArea = isWater; + t.isRiver = false; + t.riverStart = 0; + t.points = points; + return t; +} + +// Square water area (4 corners, CCW winding when viewed from above). +map::PolygonTrigger makeSquareTrigger(int32_t x0, int32_t y0, int32_t x1, int32_t y1, + int32_t z = 10) { + return makeWaterTrigger("Square", { + {x0, y0, z}, + {x1, y0, z}, + {x1, y1, z}, + {x0, y1, z}, + }); +} + +} // namespace + +// ───────────────────────────────────────────────────────────────────────────── +// triggerPointToWorld +// ───────────────────────────────────────────────────────────────────────────── + +TEST(TriggerPointToWorld, OriginMapsToOrigin) { + auto world = triggerPointToWorld({0, 0, 0}); + EXPECT_FLOAT_EQ(world.x, 0.0f); + EXPECT_FLOAT_EQ(world.y, 0.0f); + EXPECT_FLOAT_EQ(world.z, 0.0f); +} + +TEST(TriggerPointToWorld, XYScaledByMapXYFactor) { + auto world = triggerPointToWorld({1, 0, 0}); + EXPECT_FLOAT_EQ(world.x, map::MAP_XY_FACTOR); + + auto world2 = triggerPointToWorld({0, 1, 0}); + EXPECT_FLOAT_EQ(world2.z, map::MAP_XY_FACTOR); +} + +TEST(TriggerPointToWorld, ZMapsToWorldHeight) { + auto world = triggerPointToWorld({0, 0, 16}); + // 16 * MAP_HEIGHT_SCALE = 16 * (MAP_XY_FACTOR / 16) = MAP_XY_FACTOR + EXPECT_FLOAT_EQ(world.y, map::MAP_XY_FACTOR); +} + +TEST(TriggerPointToWorld, MapYMapsToWorldZ) { + // map Y (north/south) → world Z + auto world = triggerPointToWorld({0, 5, 0}); + EXPECT_FLOAT_EQ(world.x, 0.0f); + EXPECT_FLOAT_EQ(world.z, 5.0f * map::MAP_XY_FACTOR); + EXPECT_FLOAT_EQ(world.y, 0.0f); +} + +// ───────────────────────────────────────────────────────────────────────────── +// earClipTriangulate – convex polygons +// ───────────────────────────────────────────────────────────────────────────── + +TEST(EarClipTriangulate, TriangleProducesSingleTriangle) { + std::vector tri = { + {0, 0 }, + {10, 0 }, + {5, 10} + }; + auto indices = earClipTriangulate(tri); + ASSERT_EQ(indices.size(), 3u); + // All indices must reference valid vertices. + for (uint32_t idx : indices) { + EXPECT_LT(idx, static_cast(tri.size())); + } +} + +TEST(EarClipTriangulate, SquareProducesTwoTriangles) { + // CCW square + std::vector sq = { + {0, 0 }, + {10, 0 }, + {10, 10}, + {0, 10} + }; + auto indices = earClipTriangulate(sq); + EXPECT_EQ(indices.size(), 6u); // 2 triangles × 3 indices + EXPECT_EQ(indices.size() % 3, 0u); +} + +TEST(EarClipTriangulate, PentagonProducesThreeTriangles) { + std::vector pentagon; + for (int i = 0; i < 5; ++i) { + float angle = static_cast(i) * 2.0f * 3.14159f / 5.0f; + pentagon.emplace_back(std::cos(angle), std::sin(angle)); + } + auto indices = earClipTriangulate(pentagon); + EXPECT_EQ(indices.size(), 9u); // 3 triangles + EXPECT_EQ(indices.size() % 3, 0u); +} + +TEST(EarClipTriangulate, TooFewVerticesReturnsEmpty) { + EXPECT_TRUE(earClipTriangulate({}).empty()); + EXPECT_TRUE(earClipTriangulate({ + {0, 0} + }) + .empty()); + EXPECT_TRUE(earClipTriangulate({ + {0, 0}, + {1, 0} + }) + .empty()); +} + +TEST(EarClipTriangulate, CWPolygonIsHandled) { + // CW square – should still produce valid triangles. + std::vector cw = { + {0, 0 }, + {0, 10}, + {10, 10}, + {10, 0 } + }; + auto indices = earClipTriangulate(cw); + EXPECT_EQ(indices.size(), 6u); + for (uint32_t idx : indices) { + EXPECT_LT(idx, static_cast(cw.size())); + } +} + +TEST(EarClipTriangulate, AllIndicesInBounds) { + std::vector hex; + for (int i = 0; i < 6; ++i) { + float angle = static_cast(i) * 2.0f * 3.14159f / 6.0f; + hex.emplace_back(std::cos(angle), std::sin(angle)); + } + auto indices = earClipTriangulate(hex); + ASSERT_EQ(indices.size(), 12u); // 4 triangles + for (uint32_t idx : indices) { + EXPECT_LT(idx, static_cast(hex.size())); + } +} + +TEST(EarClipTriangulate, TriangleCountIsNMinus2) { + for (int n = 3; n <= 8; ++n) { + std::vector poly; + for (int i = 0; i < n; ++i) { + float angle = static_cast(i) * 2.0f * 3.14159f / static_cast(n); + poly.emplace_back(std::cos(angle), std::sin(angle)); + } + auto indices = earClipTriangulate(poly); + EXPECT_EQ(indices.size(), static_cast((n - 2) * 3)) << "For n=" << n << " vertices"; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// generateWaterPolygon +// ───────────────────────────────────────────────────────────────────────────── + +TEST(GenerateWaterPolygon, NonWaterTriggerReturnsNullopt) { + auto trigger = makeWaterTrigger("NonWater", + { + {0, 0, 0}, + {10, 0, 0}, + {10, 10, 0} + }, + /*isWater=*/false); + auto result = generateWaterPolygon(trigger); + EXPECT_FALSE(result.has_value()); +} + +TEST(GenerateWaterPolygon, TooFewPointsReturnsNullopt) { + auto trigger = makeWaterTrigger("TwoPoints", { + {0, 0, 0}, + {10, 0, 0} + }); + auto result = generateWaterPolygon(trigger); + EXPECT_FALSE(result.has_value()); +} + +TEST(GenerateWaterPolygon, TriangleProducesValidPolygon) { + auto trigger = makeWaterTrigger("Tri", { + {0, 0, 10}, + {10, 0, 10}, + {5, 10, 10} + }); + auto result = generateWaterPolygon(trigger); + ASSERT_TRUE(result.has_value()); + + EXPECT_EQ(result->vertices.size(), 3u); + EXPECT_EQ(result->indices.size(), 3u); + EXPECT_EQ(result->name, "Tri"); +} + +TEST(GenerateWaterPolygon, SquareProducesCorrectIndexCount) { + auto trigger = makeSquareTrigger(0, 0, 10, 10, 20); + auto result = generateWaterPolygon(trigger); + ASSERT_TRUE(result.has_value()); + + EXPECT_EQ(result->vertices.size(), 4u); + EXPECT_EQ(result->indices.size(), 6u); // 2 triangles +} + +TEST(GenerateWaterPolygon, WaterSurfaceIsFlatAtHeight) { + // All points at z=20 in trigger coords → world Y = 20 * MAP_HEIGHT_SCALE + int32_t trigZ = 20; + float expected = static_cast(trigZ) * map::MAP_HEIGHT_SCALE; + + auto trigger = makeSquareTrigger(0, 0, 10, 10, trigZ); + auto result = generateWaterPolygon(trigger); + ASSERT_TRUE(result.has_value()); + + for (const auto &v : result->vertices) { + EXPECT_NEAR(v.position.y, expected, 0.001f) << "All vertices must be at the water height"; + } +} + +TEST(GenerateWaterPolygon, WaterHeightMatchesAveragedZCoord) { + // Mix of slightly different z values to test averaging. + auto trigger = + makeWaterTrigger("MixedZ", { + {0, 0, 10}, + {10, 0, 12}, + {10, 10, 10}, + {0, 10, 12} + }); + auto result = generateWaterPolygon(trigger); + ASSERT_TRUE(result.has_value()); + + float avgZ = (10.0f + 12.0f + 10.0f + 12.0f) / 4.0f; + float expected = avgZ * map::MAP_HEIGHT_SCALE; + EXPECT_NEAR(result->waterHeight, expected, 0.001f); +} + +TEST(GenerateWaterPolygon, BoundsAreValid) { + auto trigger = makeSquareTrigger(0, 0, 5, 5, 10); + auto result = generateWaterPolygon(trigger); + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE(result->bounds.valid()); +} + +TEST(GenerateWaterPolygon, BoundsContainAllVertices) { + auto trigger = makeSquareTrigger(2, 3, 8, 9, 15); + auto result = generateWaterPolygon(trigger); + ASSERT_TRUE(result.has_value()); + + const auto &bb = result->bounds; + for (const auto &v : result->vertices) { + EXPECT_GE(v.position.x, bb.min.x); + EXPECT_LE(v.position.x, bb.max.x); + EXPECT_GE(v.position.y, bb.min.y); + EXPECT_LE(v.position.y, bb.max.y); + EXPECT_GE(v.position.z, bb.min.z); + EXPECT_LE(v.position.z, bb.max.z); + } +} + +TEST(GenerateWaterPolygon, TexCoordsAreNormalisedByMapXYFactor) { + auto trigger = makeSquareTrigger(0, 0, 1, 1, 10); + auto result = generateWaterPolygon(trigger); + ASSERT_TRUE(result.has_value()); + + // For a unit square (1 map cell), the UV difference should be 1.0. + float minU = result->vertices[0].texCoord.x; + float maxU = result->vertices[0].texCoord.x; + for (const auto &v : result->vertices) { + minU = std::min(minU, v.texCoord.x); + maxU = std::max(maxU, v.texCoord.x); + } + EXPECT_NEAR(maxU - minU, 1.0f, 0.001f); +} + +TEST(GenerateWaterPolygon, IndicesReferenceValidVertices) { + auto trigger = makeSquareTrigger(0, 0, 10, 10, 5); + auto result = generateWaterPolygon(trigger); + ASSERT_TRUE(result.has_value()); + + uint32_t nv = static_cast(result->vertices.size()); + for (uint32_t idx : result->indices) { + EXPECT_LT(idx, nv); + } +} + +TEST(GenerateWaterPolygon, IndicesFormCompleteTriangles) { + auto trigger = makeSquareTrigger(0, 0, 10, 10, 5); + auto result = generateWaterPolygon(trigger); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->indices.size() % 3, 0u); +} + +// ───────────────────────────────────────────────────────────────────────────── +// generateWaterMeshes +// ───────────────────────────────────────────────────────────────────────────── + +TEST(GenerateWaterMeshes, EmptyTriggersProducesEmpty) { + auto data = generateWaterMeshes({}); + EXPECT_TRUE(data.polygons.empty()); + EXPECT_FALSE(data.totalBounds.valid()); +} + +TEST(GenerateWaterMeshes, NonWaterTriggersAreSkipped) { + std::vector triggers; + triggers.push_back(makeWaterTrigger("Land", + { + {0, 0, 0}, + {10, 0, 0}, + {5, 10, 0} + }, + /*isWater=*/false)); + auto data = generateWaterMeshes(triggers); + EXPECT_TRUE(data.polygons.empty()); +} + +TEST(GenerateWaterMeshes, SingleWaterTriggerProducesSinglePolygon) { + std::vector triggers; + triggers.push_back(makeSquareTrigger(0, 0, 10, 10)); + auto data = generateWaterMeshes(triggers); + ASSERT_EQ(data.polygons.size(), 1u); +} + +TEST(GenerateWaterMeshes, MultipleWaterTriggersAllIncluded) { + std::vector triggers; + triggers.push_back(makeSquareTrigger(0, 0, 5, 5, 10)); + triggers.push_back(makeSquareTrigger(10, 10, 15, 15, 10)); + triggers.push_back(makeSquareTrigger(20, 20, 25, 25, 10)); + auto data = generateWaterMeshes(triggers); + EXPECT_EQ(data.polygons.size(), 3u); +} + +TEST(GenerateWaterMeshes, MixedTriggersOnlyWaterOnes) { + std::vector triggers; + triggers.push_back(makeSquareTrigger(0, 0, 5, 5, 10)); + triggers.push_back(makeWaterTrigger("Land", + { + {0, 0, 0}, + {5, 0, 0}, + {5, 5, 0} + }, + false)); + triggers.push_back(makeSquareTrigger(10, 10, 15, 15, 10)); + auto data = generateWaterMeshes(triggers); + EXPECT_EQ(data.polygons.size(), 2u); +} + +TEST(GenerateWaterMeshes, TotalBoundsContainsAllPolygons) { + std::vector triggers; + triggers.push_back(makeSquareTrigger(0, 0, 5, 5, 10)); + triggers.push_back(makeSquareTrigger(10, 10, 15, 15, 20)); + auto data = generateWaterMeshes(triggers); + ASSERT_EQ(data.polygons.size(), 2u); + EXPECT_TRUE(data.totalBounds.valid()); + + for (const auto &poly : data.polygons) { + EXPECT_GE(poly.bounds.min.x, data.totalBounds.min.x); + EXPECT_GE(poly.bounds.min.z, data.totalBounds.min.z); + EXPECT_LE(poly.bounds.max.x, data.totalBounds.max.x); + EXPECT_LE(poly.bounds.max.z, data.totalBounds.max.z); + } +} + +TEST(GenerateWaterMeshes, PolygonNamesPreserved) { + std::vector triggers; + auto t1 = makeSquareTrigger(0, 0, 5, 5, 10); + t1.name = "Lake"; + auto t2 = makeSquareTrigger(10, 10, 15, 15, 10); + t2.name = "River"; + triggers.push_back(t1); + triggers.push_back(t2); + auto data = generateWaterMeshes(triggers); + ASSERT_EQ(data.polygons.size(), 2u); + EXPECT_EQ(data.polygons[0].name, "Lake"); + EXPECT_EQ(data.polygons[1].name, "River"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Coordinate consistency with terrain +// ───────────────────────────────────────────────────────────────────────────── + +TEST(WaterMesh, WaterHeightConsistentWithTerrainScale) { + // Heightmap value 128 → world height 128 * MAP_HEIGHT_SCALE. + // Water at trigger Z=128 should be at the same world Y. + float terrainHeight = 128.0f * map::MAP_HEIGHT_SCALE; + auto world = triggerPointToWorld({0, 0, 128}); + EXPECT_NEAR(world.y, terrainHeight, 0.001f); +} + +TEST(WaterMesh, XZScaleMatchesTerrainGrid) { + // Map cell (1, 1) → world position (MAP_XY_FACTOR, ?, MAP_XY_FACTOR). + auto world = triggerPointToWorld({1, 1, 0}); + EXPECT_FLOAT_EQ(world.x, map::MAP_XY_FACTOR); + EXPECT_FLOAT_EQ(world.z, map::MAP_XY_FACTOR); +}