Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions shaders/water.frag
Original file line number Diff line number Diff line change
@@ -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);
}
47 changes: 47 additions & 0 deletions shaders/water.vert
Original file line number Diff line number Diff line change
@@ -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);
}
48 changes: 48 additions & 0 deletions src/lib/gfx/pipeline.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
214 changes: 214 additions & 0 deletions src/render/water/water_mesh.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
#include "render/water/water_mesh.hpp"

#include <glm/glm.hpp>

#include <algorithm>
#include <cmath>
#include <optional>

namespace w3d::water {

glm::vec3 triggerPointToWorld(const glm::ivec3 &point) {
return glm::vec3{
static_cast<float>(point.x) * map::MAP_XY_FACTOR,
static_cast<float>(point.z) * map::MAP_HEIGHT_SCALE,
static_cast<float>(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<glm::vec2> &poly2d, const std::vector<size_t> &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<uint32_t> earClipTriangulate(const std::vector<glm::vec2> &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<size_t> 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<uint32_t> 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<uint32_t>(indices[prev]));
result.push_back(static_cast<uint32_t>(indices[i]));
result.push_back(static_cast<uint32_t>(indices[next]));

indices.erase(indices.begin() + static_cast<std::ptrdiff_t>(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<uint32_t>(indices[0]));
result.push_back(static_cast<uint32_t>(indices[1]));
result.push_back(static_cast<uint32_t>(indices[2]));
}

return result;
}

std::optional<WaterPolygon> 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<float>(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<glm::vec2> 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<map::PolygonTrigger> &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
Loading