Map graphics

Update map rendering for drawing pretty ocean and regions.
!Changes are made with Claude!
This commit is contained in:
legatt 2026-10-14 14:56:14 +03:00
parent 1adddd3f1c
commit 8c41f36e80
6 changed files with 977 additions and 46 deletions

View File

@ -1,10 +1,232 @@
#version 450
#extension GL_ARB_separate_shader_objects : enable
layout(location = 0) in vec3 fragColor;
layout(location = 0) in vec2 worldPos;
layout(location = 1) flat in uint instance;
layout(location = 0) out vec4 outColor;
void main() {
outColor = vec4(fragColor, 1.0);
layout(std140, binding = 0) uniform Camera {
mat4 viewProjection;
mat4 inverseViewProjection;
vec4 viewport; // x, y: framebuffer size; z: world units per screen pixel; w: 1 if target is sRGB
vec4 raster; // x, y: world position of province raster texel (0, 0) center; z, w: raster size in texels
vec4 coast; // x: map pixels per raster texel; y: raster texels per coast field texel; z, w: coast field size
uvec4 info; // x: instance index of the ocean plane
} camera;
struct Province {
uint regionId;
uint color;
};
layout(std430, binding = 1) readonly buffer Data {
Province provinces[];
};
// Province index + 1 for every raster texel, 0 for water.
layout(binding = 2) uniform usampler2D provinceIds;
// Signed distance to the coastline in raster texels: positive in water, negative on land.
layout(binding = 3) uniform sampler2D coastDistance;
// ---------------------------------------------------------------------------------------------
// Palette (sRGB)
// ---------------------------------------------------------------------------------------------
const vec3 OCEAN_SHELF_NEAR = vec3( 88.0, 148.0, 204.0) / 255.0;
const vec3 OCEAN_SHELF_MID = vec3( 60.0, 128.0, 196.0) / 255.0;
const vec3 OCEAN_SHELF_FAR = vec3( 38.0, 113.0, 189.0) / 255.0;
const vec3 OCEAN_DEEP = vec3( 20.0, 98.0, 183.0) / 255.0;
const vec3 OCEAN_ABYSS = vec3( 15.0, 86.0, 168.0) / 255.0;
// All distances below are in province raster texels ("map art pixels").
// Terraces of the coastal shelf, distance from the shore.
const float SHELF_NEAR_END = 2.5;
const float SHELF_MID_END = 4.5;
const float SHELF_FAR_FADE_START = 6.5;
const float SHELF_FAR_FADE_END = 16.0;
const float GRID_SPACING = 32.0;
const float GRID_STRENGTH_OCEAN = 0.07;
const float GRID_STRENGTH_LAND = 0.10;
const int COUNTRY_SHADE_RADIUS = 3;
const float COUNTRY_SHADE = 0.82;
// ---------------------------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------------------------
vec3 unpackColor(uint c) {
return vec3(
c & 0xFFu,
(c >> 8) & 0xFFu,
(c >> 16) & 0xFFu
) / 255.0;
}
vec3 srgbToLinear(vec3 c) {
return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c));
}
vec3 saturateColor(vec3 c, float amount) {
float luma = dot(c, vec3(0.2126, 0.7152, 0.0722));
return clamp(mix(vec3(luma), c, amount), 0.0, 1.0);
}
float hash12(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
float valueNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
float a = hash12(i);
float b = hash12(i + vec2(1.0, 0.0));
float c = hash12(i + vec2(0.0, 1.0));
float d = hash12(i + vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
for (int i = 0; i < 4; ++i) {
value += amplitude * valueNoise(p);
p = p * 2.03 + vec2(17.1, 9.7);
amplitude *= 0.5;
}
return value / 0.9375;
}
// Raster texels per screen pixel.
float artPixelSize() {
return camera.viewport.z / camera.coast.x;
}
// Map-space grid lines with a constant on-screen thickness, faded out when they get too dense.
float gridMask(vec2 art) {
float pixel = artPixelSize();
vec2 cell = abs(fract(art / GRID_SPACING + 0.5) - 0.5) * GRID_SPACING / pixel;
float line = 1.0 - smoothstep(0.6, 1.6, min(cell.x, cell.y));
float density = smoothstep(10.0, 24.0, GRID_SPACING / pixel);
return line * density;
}
uint fetchProvince(ivec2 texel) {
texel = clamp(texel, ivec2(0), ivec2(camera.raster.zw) - 1);
return texelFetch(provinceIds, texel, 0).r;
}
uint ownerColor(uint id) {
return provinces[id - 1u].color & 0xFFFFFFu;
}
// ---------------------------------------------------------------------------------------------
// Ocean
// ---------------------------------------------------------------------------------------------
vec3 shadeOcean(vec2 art) {
vec2 uv = (art / camera.coast.y + 0.5) / camera.coast.zw;
float shoreDistance = texture(coastDistance, uv).r;
// Ragged terrace edges instead of perfect offset curves of the coastline.
float d = shoreDistance + (fbm(art * 0.15) - 0.5) * 1.6;
float edge = 0.5 * max(artPixelSize(), 0.75);
// Large, soft deep-water patches far away from the shore.
float abyss = smoothstep(0.45, 0.8, fbm(art * 0.012)) * smoothstep(10.0, 30.0, shoreDistance);
vec3 color = mix(OCEAN_DEEP, OCEAN_ABYSS, abyss);
color = mix(OCEAN_SHELF_FAR, color, smoothstep(SHELF_FAR_FADE_START, SHELF_FAR_FADE_END, d));
color = mix(OCEAN_SHELF_MID, color, smoothstep(SHELF_MID_END - edge, SHELF_MID_END + edge, d));
color = mix(OCEAN_SHELF_NEAR, color, smoothstep(SHELF_NEAR_END - edge, SHELF_NEAR_END + edge, d));
color *= 1.0 + (valueNoise(art * 0.35) - 0.5) * 0.025;
color *= 1.0 - GRID_STRENGTH_OCEAN * gridMask(art);
return color;
}
// ---------------------------------------------------------------------------------------------
// Land
// ---------------------------------------------------------------------------------------------
const ivec2 DIRECTIONS[8] = ivec2[](
ivec2(1, 0), ivec2(-1, 0), ivec2(0, 1), ivec2(0, -1),
ivec2(1, 1), ivec2(-1, 1), ivec2(1, -1), ivec2(-1, -1)
);
vec3 shadeLand(vec2 art, ivec2 texel, uint ownId) {
uint ownColorBits = ownerColor(ownId);
vec3 base = unpackColor(ownColorBits);
// When zoomed out a raster texel gets smaller than a screen pixel; widen borders to stay visible.
int stride = max(1, int(artPixelSize() + 0.5));
bool coast = false;
bool provinceBorder = false;
bool countryBorder = false;
float countryDistance = 1.0e6;
for (int d = 0; d < 8; ++d) {
bool axis = d < 4;
float stepLength = axis ? 1.0 : 1.41421356;
for (int r = 1; r <= COUNTRY_SHADE_RADIUS; ++r) {
uint neighbour = fetchProvince(texel + DIRECTIONS[d] * (r * stride));
if (neighbour == ownId) continue;
if (neighbour == 0u) {
coast = coast || (axis && r == 1);
continue;
}
if (ownerColor(neighbour) != ownColorBits) {
countryDistance = min(countryDistance, float(r) * stepLength);
countryBorder = countryBorder || (axis && r == 1);
break;
}
provinceBorder = provinceBorder || (axis && r == 1);
}
}
// Subtle per-pixel grain plus low-frequency tint variation.
float grain = (hash12(vec2(texel)) - 0.5) * 0.035 + (valueNoise(art * 0.05) - 0.5) * 0.04;
vec3 color = base * (1.0 + grain);
if (provinceBorder) color = base * 0.84;
if (coast) color = saturateColor(base, 1.15) * 0.72;
color *= mix(COUNTRY_SHADE, 1.0, smoothstep(1.0, float(COUNTRY_SHADE_RADIUS) + 0.5, countryDistance));
if (countryBorder) color = saturateColor(base, 1.6) * 0.9;
color *= 1.0 - GRID_STRENGTH_LAND * gridMask(art);
return color;
}
void main() {
vec3 color;
// Position in province raster texels
vec2 art = (worldPos - camera.raster.xy) / camera.coast.x;
if (instance == camera.info.x) {
color = shadeOcean(art);
}
else {
ivec2 texel = ivec2(floor(art + 0.5));
uint ownId = fetchProvince(texel);
// Pixel-exact province coverage (see COVERAGE_EXPAND in the vertex shader).
if (ownId != instance + 1u) discard;
color = shadeLand(art, texel, ownId);
}
outColor = vec4(camera.viewport.w > 0.5 ? srgbToLinear(color) : color, 1.0);
}

View File

@ -1,35 +1,32 @@
#version 450
#extension GL_ARB_separate_shader_objects : enable
// Per-vertex data: contour point of a province polygon and its outward miter normal.
// The ocean plane is drawn through the same multi-draw with a zero normal.
layout(location = 0) in vec2 position;
layout(location = 1) in vec2 normal;
layout(binding = 0) uniform Camera {
layout(std140, binding = 0) uniform Camera {
mat4 viewProjection;
mat4 inverseViewProjection;
vec4 viewport; // x, y: framebuffer size; z: world units per screen pixel; w: 1 if target is sRGB
vec4 raster; // x, y: world position of province raster texel (0, 0) center; z, w: raster size in texels
vec4 coast; // x: map pixels per raster texel; y: raster texels per coast field texel; z, w: coast field size
uvec4 info; // x: instance index of the ocean plane
} camera;
layout(push_constant) uniform Model {
mat4 model;
} model;
struct Province {
uint regionId;
uint color;
};
layout(std430, binding = 1) readonly buffer Data {
Province provinces[];
};
layout(location = 0) out vec3 fragColor;
vec3 unpackColor(uint c) {
return vec3(
c & 0xFFu,
(c >> 8) & 0xFFu,
(c >> 16) & 0xFFu
) / 255.0;
}
layout(location = 0) out vec2 worldPos;
layout(location = 1) flat out uint instance;
void main() {
gl_Position = camera.viewProjection * vec4(position, 0.0, 1.0);
fragColor = unpackColor(provinces[gl_InstanceIndex].color);
// Province polygons are simplified pixel contours, so they may miss up to ~1.7 map pixels of
// their own area, plus up to a raster texel lost to rounding. They are pushed outwards and the
// fragment shader clips them exactly against the province raster, which also closes the seams
// between neighbouring provinces.
float coverageExpand = 2.0 + 1.75 * camera.coast.x;
vec2 world = position + normal * coverageExpand;
worldPos = world;
instance = uint(gl_InstanceIndex);
gl_Position = camera.viewProjection * vec4(world, 0.0, 1.0);
}

View File

@ -16,13 +16,40 @@ namespace ee_vulkan {
constexpr std::uint64_t TIMEOUT = std::numeric_limits<std::uint64_t>::max();
struct DrawConfig {
static constexpr vk::ClearValue CLEAR_COLOR = {.color = {std::array{1.0f, 1.0f, 1.0f, 1.0f}}};
// Deep ocean color (linear). Normally hidden by the ocean plane drawn with the provinces.
static constexpr vk::ClearValue CLEAR_COLOR = {.color = {std::array{0.007f, 0.122f, 0.474f, 1.0f}}};
static constexpr vk::ClearValue CLEAR_DEPTH = {.depthStencil = {1.0f, 0}};
};
/**
* Vertex of a province polygon. Normal is the outward miter of the province contour,
* used by the shader to slightly grow polygons before clipping them against the province raster.
*/
struct ProvinceVertex {
glm::vec2 position;
glm::vec2 normal;
};
/**
* Per-frame data of the map shaders (std140 layout, see provinces-flat shaders).
*/
struct MapUniform {
glm::mat4 viewProjection;
glm::mat4 inverseViewProjection;
/// x, y: framebuffer size; z: world units per screen pixel; w: 1 if the target is sRGB.
glm::vec4 viewport;
/// x, y: world position of province raster texel (0, 0) center; z, w: province raster size in texels.
glm::vec4 raster;
/// x: map pixels per province raster texel; y: raster texels per coast field texel; z, w: coast field size.
glm::vec4 coast;
/// x: instance index of the ocean plane.
glm::uvec4 info;
};
static_assert(sizeof(MapUniform) == 192, "MapUniform must match std140 layout of the map shaders");
struct ProvinceMesh {
lazy<vulkan_render::Buffer<
glm::vec2,
ProvinceVertex,
vk::BufferUsageFlagBits::eVertexBuffer,
vulkan_render::BufferMode::STAGING_ACCESS
>> vertices;
@ -42,6 +69,24 @@ namespace ee_vulkan {
vk::BufferUsageFlagBits::eIndirectBuffer,
vulkan_render::BufferMode::STAGING_ACCESS
>> indirectCommands;
/// Province index + 1 for every map pixel, 0 for water (R16/R32 uint).
lazy<vulkan_render::PartialTexture> provinceIds;
/// Signed distance to the coastline in province raster texels, positive in water (R16 float).
lazy<vulkan_render::PartialTexture> coastDistance;
vk::raii::Sampler nearestSampler = nullptr;
vk::raii::Sampler linearSampler = nullptr;
/// World position of the center of province raster texel (0, 0).
glm::ivec2 rasterOrigin {0, 0};
glm::uvec2 rasterSize {1, 1};
/// Map pixels per province raster texel (more than 1 for maps exceeding texture limits).
std::uint32_t rasterTexelSize = 1;
glm::uvec2 coastFieldSize {1, 1};
/// Province raster texels per coast distance field texel.
std::uint32_t coastFieldFactor = 1;
/// Index of the ocean plane in the storage buffer and in the instances of multi-draw.
std::uint32_t oceanInstance = 0;
};
}
@ -80,7 +125,7 @@ export namespace ee_vulkan {
lazy<vulkan_render::SyncManager> syncManager;
lazy<vulkan_render::Texture<vulkan_render::TextureMode::DEPTH>> depth;
lazy<vulkan_render::Uniform<glm::mat4>> viewProjectionUniform;
lazy<vulkan_render::Uniform<MapUniform>> mapUniform;
ProvinceMesh provinces;
lazy<ee_rml::RmlVulkan> rmlRenderInterface;
@ -93,6 +138,11 @@ export namespace ee_vulkan {
void LoadProvincesMaterial();
void LoadRmlMaterial();
void LoadProvinces();
void LoadMapTextures(
const std::vector<std::uint32_t> &ids,
const std::vector<float> &coastField,
std::uint32_t provincesCount
);
void UpdateData(std::uint32_t frame);
};
}

View File

@ -4,11 +4,448 @@ module;
#include <GLFW/glfw3.h>
#include <glm/glm.hpp>
#include <glm/gtc/type_ptr.hpp>
#include <glm/gtc/packing.hpp>
#include <RmlUi/Core/Core.h>
module VulkanRender;
import utils;
namespace {
/// Region id of the storage buffer element describing the ocean plane.
constexpr std::uint32_t OCEAN_REGION_ID = std::numeric_limits<std::uint32_t>::max();
/// Half-size of the ocean plane in world units, large enough to fill the view at any zoom.
constexpr float OCEAN_PLANE_EXTENT = 1.0e6f;
/// Water margin around the map in rasters (texels), so coastal effects are not cut at the map edge.
constexpr std::int32_t RASTER_PADDING = 32;
/// Distances stored in the coast distance field are clamped to this value (raster texels).
constexpr float MAX_COAST_DISTANCE = 4096.0f;
/// Upper bound of the province raster size; bigger maps are rasterized with several map pixels per texel.
constexpr std::uint64_t MAX_RASTER_TEXELS = 48ull * 1024 * 1024;
/// Upper bound of the coast distance field size; it is smooth, so it may be coarser than the province raster.
constexpr std::uint64_t MAX_COAST_FIELD_TEXELS = 12ull * 1024 * 1024;
struct Bounds {
glm::vec2 min {std::numeric_limits<float>::max()};
glm::vec2 max {std::numeric_limits<float>::lowest()};
void Add(const glm::vec2 &point) {
min = glm::min(min, point);
max = glm::max(max, point);
}
bool IsEmpty() const { return min.x > max.x || min.y > max.y; }
};
/**
* Map rasterized with texelSize x texelSize map pixels per texel.
* Texel (0, 0) center is located at world position origin, texel (x, y) at origin + (x, y) * texelSize.
*/
struct ProvinceRaster {
glm::ivec2 origin {0, 0};
glm::ivec2 size {1, 1};
std::int32_t texelSize = 1;
/// Province index + 1, 0 for water.
std::vector<std::uint32_t> ids;
ProvinceRaster(const Bounds &bounds, const std::int32_t mapPixelsPerTexel) : texelSize(mapPixelsPerTexel) {
if (!bounds.IsEmpty()) {
const glm::ivec2 low(static_cast<std::int32_t>(std::floor(bounds.min.x)), static_cast<std::int32_t>(std::floor(bounds.min.y)));
const glm::ivec2 high(static_cast<std::int32_t>(std::ceil(bounds.max.x)), static_cast<std::int32_t>(std::ceil(bounds.max.y)));
origin = low - RASTER_PADDING * texelSize;
size = (high - low) / texelSize + 1 + 2 * RASTER_PADDING;
}
ids.assign(static_cast<std::size_t>(size.x) * static_cast<std::size_t>(size.y), 0);
}
/**
* Smallest integer texel size that keeps the raster within texture limits and memory budget.
*/
static std::int32_t ChooseTexelSize(const Bounds &bounds, const std::uint32_t maxDimension, const std::uint64_t maxTexels) {
if (bounds.IsEmpty()) return 1;
const glm::vec2 extent = bounds.max - bounds.min + 1.0f;
for (std::int32_t candidate = 1; ; ++candidate) {
const float step = static_cast<float>(candidate);
const std::uint64_t width = static_cast<std::uint64_t>(std::ceil(extent.x / step)) + 2 * RASTER_PADDING + 1;
const std::uint64_t height = static_cast<std::uint64_t>(std::ceil(extent.y / step)) + 2 * RASTER_PADDING + 1;
if (width <= maxDimension && height <= maxDimension && width * height <= maxTexels)
return candidate;
}
}
/// Integer world position relative to the raster origin.
glm::ivec2 ToLocal(const glm::vec2 &world) const {
return glm::ivec2(
static_cast<std::int32_t>(std::lround(world.x)),
static_cast<std::int32_t>(std::lround(world.y))
) - origin;
}
};
std::int64_t EdgeFunction(const glm::ivec2 &a, const glm::ivec2 &b, const glm::ivec2 &p) {
return static_cast<std::int64_t>(b.x - a.x) * (p.y - a.y) - static_cast<std::int64_t>(b.y - a.y) * (p.x - a.x);
}
std::int32_t FloorDiv(const std::int32_t value, const std::int32_t divisor) {
return value >= 0 ? value / divisor : -((-value + divisor - 1) / divisor);
}
/**
* Marks texels whose centers lie inside (or on the edge of) province triangles.
* Mesh vertices are pixel centers of the source bitmap, so with texel size 1 the result closely reproduces the source map.
*/
void RasterizeProvince(
ProvinceRaster &raster,
const std::vector<glm::vec2> &vertices,
const std::vector<std::uint16_t> &indices,
const std::uint32_t id
) {
std::uint32_t *ids = raster.ids.data();
const std::int32_t width = raster.size.x;
const std::int32_t step = raster.texelSize;
for (std::size_t i = 0; i + 2 < indices.size(); i += 3) {
if (indices[i] >= vertices.size() || indices[i + 1] >= vertices.size() || indices[i + 2] >= vertices.size())
continue;
const glm::ivec2 a = raster.ToLocal(vertices[indices[i]]);
glm::ivec2 b = raster.ToLocal(vertices[indices[i + 1]]);
glm::ivec2 c = raster.ToLocal(vertices[indices[i + 2]]);
const std::int64_t area = EdgeFunction(a, b, c);
if (area == 0) continue;
if (area < 0) std::swap(b, c);
const std::int32_t minX = std::max(FloorDiv(std::min({a.x, b.x, c.x}) + step - 1, step), 0);
const std::int32_t minY = std::max(FloorDiv(std::min({a.y, b.y, c.y}) + step - 1, step), 0);
const std::int32_t maxX = std::min(FloorDiv(std::max({a.x, b.x, c.x}), step), raster.size.x - 1);
const std::int32_t maxY = std::min(FloorDiv(std::max({a.y, b.y, c.y}), step), raster.size.y - 1);
for (std::int32_t y = minY; y <= maxY; ++y) {
for (std::int32_t x = minX; x <= maxX; ++x) {
const glm::ivec2 p(x * step, y * step);
if (EdgeFunction(b, c, p) >= 0 && EdgeFunction(c, a, p) >= 0 && EdgeFunction(a, b, p) >= 0)
ids[static_cast<std::size_t>(y) * width + x] = id;
}
}
}
}
/**
* Province contours are simplified, so thin slivers between neighbouring provinces stay unassigned.
* Assigns such texels to the dominating neighbour when they are mostly surrounded by land.
*/
void CloseRasterGaps(ProvinceRaster &raster, const std::uint32_t iterations) {
const std::size_t width = static_cast<std::size_t>(raster.size.x);
const std::size_t height = static_cast<std::size_t>(raster.size.y);
std::uint32_t *ids = raster.ids.data();
std::vector<std::size_t> candidates;
std::vector<std::pair<std::size_t, std::uint32_t>> changes;
bool firstPass = true;
const auto evaluate = [&](const std::size_t i) {
if (ids[i] != 0) return;
const std::size_t x = i % width;
const std::size_t y = i / width;
if (x == 0 || y == 0 || x + 1 >= width || y + 1 >= height) return;
// 4-neighbours first, then diagonals
const std::array<std::uint32_t, 8> neighbours {
ids[i - 1], ids[i + 1], ids[i - width], ids[i + width],
ids[i - width - 1], ids[i - width + 1], ids[i + width - 1], ids[i + width + 1]
};
std::int32_t sides = 0;
std::int32_t assigned = 0;
for (std::size_t n = 0; n < neighbours.size(); ++n) {
if (neighbours[n] == 0) continue;
++assigned;
if (n < 4) ++sides;
}
if (assigned == 0) return;
const bool enclosed = (neighbours[0] != 0 && neighbours[1] != 0) || (neighbours[2] != 0 && neighbours[3] != 0);
if (sides < 3 && assigned < 5 && !enclosed) return;
std::uint32_t best = 0;
std::int32_t bestVotes = 0;
for (const std::uint32_t candidate : neighbours) {
if (candidate == 0) continue;
const std::int32_t votes = static_cast<std::int32_t>(std::ranges::count(neighbours, candidate));
if (votes > bestVotes) {
best = candidate;
bestVotes = votes;
}
}
changes.emplace_back(i, best);
};
for (std::uint32_t iteration = 0; iteration < iterations; ++iteration) {
changes.clear();
if (firstPass) {
for (std::size_t i = 0; i < raster.ids.size(); ++i) evaluate(i);
firstPass = false;
}
else {
for (const std::size_t i : candidates) evaluate(i);
}
if (changes.empty()) break;
// Apply after the scan, so every texel of an iteration sees the same state
candidates.clear();
for (const auto &[i, id] : changes) {
ids[i] = id;
for (const std::size_t n : {i - 1, i + 1, i - width, i + width, i - width - 1, i - width + 1, i + width - 1, i + width + 1})
candidates.push_back(n);
}
}
}
/**
* Province meshes have no holes, so small water pockets fully enclosed by land are leftovers
* of contour simplification. Fills them with the most common surrounding province.
*/
void FillEnclosedWater(ProvinceRaster &raster, const std::size_t maxArea) {
enum : std::uint8_t { UNVISITED, CURRENT, OPEN_WATER };
const std::size_t width = static_cast<std::size_t>(raster.size.x);
const std::size_t height = static_cast<std::size_t>(raster.size.y);
std::uint32_t *ids = raster.ids.data();
std::vector<std::uint8_t> state(raster.ids.size(), UNVISITED);
std::vector<std::size_t> component;
std::vector<std::pair<std::uint32_t, std::uint32_t>> shore;
for (std::size_t start = 0; start < raster.ids.size(); ++start) {
if (ids[start] != 0 || state[start] != UNVISITED) continue;
// Bounded flood fill: stops as soon as the pocket proves to be open water
component.assign(1, start);
state[start] = CURRENT;
shore.clear();
bool open = false;
for (std::size_t head = 0; head < component.size() && !open; ++head) {
const std::size_t i = component[head];
const std::size_t x = i % width;
const std::size_t y = i / width;
if (x == 0 || y == 0 || x + 1 >= width || y + 1 >= height) {
open = true;
break;
}
for (const std::size_t n : {i - 1, i + 1, i - width, i + width}) {
if (ids[n] != 0) {
const auto entry = std::ranges::find(shore, ids[n], &std::pair<std::uint32_t, std::uint32_t>::first);
if (entry != shore.end()) ++entry->second;
else shore.emplace_back(ids[n], 1);
}
else if (state[n] == OPEN_WATER) {
open = true;
break;
}
else if (state[n] == UNVISITED) {
state[n] = CURRENT;
component.push_back(n);
if (component.size() > maxArea) {
open = true;
break;
}
}
}
}
if (open || shore.empty()) {
for (const std::size_t i : component) state[i] = OPEN_WATER;
continue;
}
const auto dominant = std::ranges::max_element(shore, {}, &std::pair<std::uint32_t, std::uint32_t>::second);
for (const std::size_t i : component) ids[i] = dominant->first;
}
}
/**
* Felzenszwalb & Huttenlocher squared Euclidean distance transform of a 1D sampled function.
*/
void DistanceTransform1D(
const float *f, float *d, std::int32_t *v, float *z, const std::int32_t n
) {
std::int32_t k = 0;
v[0] = 0;
z[0] = -std::numeric_limits<float>::max();
z[1] = std::numeric_limits<float>::max();
const auto intersection = [f, v](const std::int32_t q, const std::int32_t index) {
const std::int32_t p = v[index];
return ((f[q] + static_cast<float>(q) * static_cast<float>(q)) - (f[p] + static_cast<float>(p) * static_cast<float>(p)))
/ (2.0f * static_cast<float>(q - p));
};
for (std::int32_t q = 1; q < n; ++q) {
float s = intersection(q, k);
// z[0] is the lowest float, so k never drops below 0
while (s <= z[k]) {
--k;
s = intersection(q, k);
}
++k;
v[k] = q;
z[k] = s;
z[k + 1] = std::numeric_limits<float>::max();
}
k = 0;
for (std::int32_t q = 0; q < n; ++q) {
while (z[k + 1] < static_cast<float>(q)) ++k;
const float delta = static_cast<float>(q - v[k]);
d[q] = delta * delta + f[v[k]];
}
}
/**
* In-place 2D squared Euclidean distance transform. Feature cells must be 0, others a large value.
*/
void DistanceTransform2D(std::vector<float> &grid, const std::int32_t width, const std::int32_t height) {
const std::int32_t length = std::max(width, height);
std::vector<float> f(length), d(length), z(length + 1);
std::vector<std::int32_t> v(length);
for (std::int32_t x = 0; x < width; ++x) {
for (std::int32_t y = 0; y < height; ++y) f[y] = grid[static_cast<std::size_t>(y) * width + x];
DistanceTransform1D(f.data(), d.data(), v.data(), z.data(), height);
for (std::int32_t y = 0; y < height; ++y) grid[static_cast<std::size_t>(y) * width + x] = d[y];
}
for (std::int32_t y = 0; y < height; ++y) {
float *row = grid.data() + static_cast<std::size_t>(y) * width;
std::copy_n(row, width, f.data());
DistanceTransform1D(f.data(), row, v.data(), z.data(), width);
}
}
/**
* Signed distance to the coastline, measured in province raster texels: positive in water, negative on land.
* The field is sampled every factor texels of the province raster to bound memory on large maps.
* The coastline is placed on the edges of the land texels.
*/
std::vector<float> MakeCoastDistanceField(const ProvinceRaster &raster, const std::int32_t factor, glm::ivec2 &fieldSize) {
constexpr float FAR = 1.0e20f;
fieldSize = (raster.size - 1) / factor + 1;
const std::size_t count = static_cast<std::size_t>(fieldSize.x) * static_cast<std::size_t>(fieldSize.y);
std::vector<std::uint8_t> land(count);
std::vector<float> toLand(count), toWater(count);
for (std::int32_t y = 0; y < fieldSize.y; ++y) {
for (std::int32_t x = 0; x < fieldSize.x; ++x) {
const std::size_t i = static_cast<std::size_t>(y) * fieldSize.x + x;
land[i] = raster.ids[static_cast<std::size_t>(y * factor) * raster.size.x + x * factor] != 0;
toLand[i] = land[i] ? 0.0f : FAR;
toWater[i] = land[i] ? FAR : 0.0f;
}
}
DistanceTransform2D(toLand, fieldSize.x, fieldSize.y);
DistanceTransform2D(toWater, fieldSize.x, fieldSize.y);
std::vector<float> field(count);
for (std::size_t i = 0; i < count; ++i) {
const float distance = land[i]
? -(std::sqrt(toWater[i]) - 0.5f)
: std::sqrt(toLand[i]) - 0.5f;
field[i] = std::clamp(distance * static_cast<float>(factor), -MAX_COAST_DISTANCE, MAX_COAST_DISTANCE);
}
return field;
}
/**
* Outward miter normals of the province outline for every vertex (zero for inner vertices).
* The outline consists of the triangle edges that are not shared by two triangles.
*/
std::vector<glm::vec2> ComputeOutlineNormals(
const std::vector<glm::vec2> &vertices,
const std::vector<std::uint16_t> &indices
) {
struct Edge {
std::uint32_t count = 0;
std::uint16_t a = 0;
std::uint16_t b = 0;
std::uint16_t opposite = 0;
};
std::unordered_map<std::uint32_t, Edge> edges;
edges.reserve(indices.size());
const auto addEdge = [&edges](const std::uint16_t a, const std::uint16_t b, const std::uint16_t opposite) {
const std::uint32_t key = a < b
? (static_cast<std::uint32_t>(a) << 16) | b
: (static_cast<std::uint32_t>(b) << 16) | a;
Edge &edge = edges[key];
++edge.count;
edge.a = a;
edge.b = b;
edge.opposite = opposite;
};
for (std::size_t i = 0; i + 2 < indices.size(); i += 3) {
const std::uint16_t a = indices[i], b = indices[i + 1], c = indices[i + 2];
if (a >= vertices.size() || b >= vertices.size() || c >= vertices.size()) continue;
addEdge(a, b, c);
addEdge(b, c, a);
addEdge(c, a, b);
}
struct OutlineEdge {
std::uint16_t a, b;
glm::vec2 normal;
};
std::vector<OutlineEdge> outline;
std::vector<glm::vec2> sums(vertices.size(), glm::vec2(0.0f));
for (const Edge &edge : edges | std::views::values) {
if (edge.count != 1) continue;
const glm::vec2 direction = vertices[edge.b] - vertices[edge.a];
if (glm::dot(direction, direction) < 1e-8f) continue;
glm::vec2 normal = glm::normalize(glm::vec2(direction.y, -direction.x));
if (glm::dot(normal, vertices[edge.opposite] - vertices[edge.a]) > 0.0f)
normal = -normal;
sums[edge.a] += normal;
sums[edge.b] += normal;
outline.push_back({edge.a, edge.b, normal});
}
std::vector<glm::vec2> normals(vertices.size(), glm::vec2(0.0f));
std::vector<float> minCos(vertices.size(), 1.0f);
for (std::size_t i = 0; i < vertices.size(); ++i) {
if (glm::dot(sums[i], sums[i]) > 1e-8f)
normals[i] = glm::normalize(sums[i]);
}
for (const auto &[a, b, normal] : outline) {
minCos[a] = std::min(minCos[a], glm::dot(normals[a], normal));
minCos[b] = std::min(minCos[b], glm::dot(normals[b], normal));
}
for (std::size_t i = 0; i < vertices.size(); ++i) {
// Miter length, limited on sharp spikes
normals[i] /= std::max(minCos[i], 0.35f);
}
return normals;
}
bool IsSrgbFormat(const vk::Format format) {
switch (format) {
case vk::Format::eB8G8R8A8Srgb:
case vk::Format::eR8G8B8A8Srgb:
case vk::Format::eA8B8G8R8SrgbPack32:
return true;
default:
return false;
}
}
}
ee_vulkan::VulkanRenderer::VulkanRenderer(GLFWwindow *window) : IRenderer(window) {
std::uint32_t glfwExtensionsCount;
const char **glfwExtensions = glfwGetRequiredInstanceExtensions(&glfwExtensionsCount);
@ -56,7 +493,7 @@ void ee_vulkan::VulkanRenderer::Init() {
};
rmlRenderInterface.emplace(rmlContext);
viewProjectionUniform.emplace(*context, MAX_FRAMES_IN_FLIGHT);
mapUniform.emplace(*context, MAX_FRAMES_IN_FLIGHT);
LoadProvinces();
@ -224,25 +661,31 @@ void ee_vulkan::VulkanRenderer::LoadProvincesMaterial() {
const vulkan_render::ShaderBindings shaderBindings {
.vertexBinding = {
.binding = 0,
.stride = sizeof(glm::vec2),
.stride = sizeof(ProvinceVertex),
.inputRate = vk::VertexInputRate::eVertex
},
.vertexAttributesCount = 1,
.vertexAttributesCount = 2,
.vertexAttributes = {
{
.location = 0,
.binding = 0,
.format = vulkan_render::ShaderAttributeFormat::VEC2,
.offset = 0
},
{
.location = 1,
.binding = 0,
.format = vulkan_render::ShaderAttributeFormat::VEC2,
.offset = sizeof(glm::vec2)
}
}
};
const vk::DescriptorSetLayoutBinding viewProjection {
const vk::DescriptorSetLayoutBinding mapData {
.binding = 0,
.descriptorType = vk::DescriptorType::eUniformBuffer,
.descriptorCount = 1,
.stageFlags = vk::ShaderStageFlagBits::eVertex,
.stageFlags = vk::Flags(vk::ShaderStageFlagBits::eVertex) | vk::ShaderStageFlagBits::eFragment,
.pImmutableSamplers = nullptr
};
const vk::DescriptorSetLayoutBinding provincesSSBO {
@ -252,6 +695,20 @@ void ee_vulkan::VulkanRenderer::LoadProvincesMaterial() {
.stageFlags = vk::Flags(vk::ShaderStageFlagBits::eVertex) | vk::ShaderStageFlagBits::eFragment,
.pImmutableSamplers = nullptr
};
const vk::DescriptorSetLayoutBinding provinceIds {
.binding = 2,
.descriptorType = vk::DescriptorType::eCombinedImageSampler,
.descriptorCount = 1,
.stageFlags = vk::ShaderStageFlagBits::eFragment,
.pImmutableSamplers = nullptr
};
const vk::DescriptorSetLayoutBinding coastDistance {
.binding = 3,
.descriptorType = vk::DescriptorType::eCombinedImageSampler,
.descriptorCount = 1,
.stageFlags = vk::ShaderStageFlagBits::eFragment,
.pImmutableSamplers = nullptr
};
const vk::PushConstantRange model {
.stageFlags = vk::ShaderStageFlagBits::eVertex,
@ -262,7 +719,7 @@ void ee_vulkan::VulkanRenderer::LoadProvincesMaterial() {
vulkan_render::PipelineConfig provincesMaterial = vulkan_render::Pipeline::MakeConfig(
surface,
shaderBindings,
{viewProjection, provincesSSBO},
{mapData, provincesSSBO, provinceIds, coastDistance},
model
);
vulkan_render::ShaderProgram provincesShader {
@ -349,27 +806,72 @@ void ee_vulkan::VulkanRenderer::LoadRmlMaterial() {
}
void ee_vulkan::VulkanRenderer::LoadProvinces() {
const auto startTime = std::chrono::steady_clock::now();
// Map extent and number of provinces
Bounds bounds;
std::uint32_t provincesCount = 0;
for (const auto &provs : mapMesh->regions | std::views::values) {
provincesCount += static_cast<std::uint32_t>(provs.size());
for (const auto &province : provs) {
for (const glm::vec2 &vertex : province.vertices) bounds.Add(vertex);
}
}
const std::uint32_t maxDimension = std::min<std::uint32_t>(
context->physicalDeviceCapabilities.properties.limits.maxImageDimension2D,
16384
);
ProvinceRaster raster(bounds, ProvinceRaster::ChooseTexelSize(bounds, maxDimension, MAX_RASTER_TEXELS));
provinces.oceanInstance = provincesCount;
std::vector<ee_engine::ProvinceSSBO> provincesData;
std::vector<glm::vec2> verticesData;
std::vector<ProvinceVertex> verticesData;
std::vector<std::uint32_t> indicesData;
std::vector<vk::DrawIndexedIndirectCommand> indirectCommandsData;
provincesData.reserve(provincesCount + 1);
indirectCommandsData.reserve(provincesCount + 1);
// Ocean plane is the first command of the multi-draw, so provinces are drawn on top of it.
// Its storage element is the last one to keep regions offsets unchanged.
verticesData.append_range(std::array<ProvinceVertex, 4>{{
{.position = {-OCEAN_PLANE_EXTENT, -OCEAN_PLANE_EXTENT}, .normal = {0.0f, 0.0f}},
{.position = { OCEAN_PLANE_EXTENT, -OCEAN_PLANE_EXTENT}, .normal = {0.0f, 0.0f}},
{.position = { OCEAN_PLANE_EXTENT, OCEAN_PLANE_EXTENT}, .normal = {0.0f, 0.0f}},
{.position = {-OCEAN_PLANE_EXTENT, OCEAN_PLANE_EXTENT}, .normal = {0.0f, 0.0f}}
}});
indicesData.append_range(std::array<std::uint32_t, 6>{0, 1, 2, 0, 2, 3});
indirectCommandsData.push_back({
.indexCount = 6,
.instanceCount = 1,
.firstIndex = 0,
.vertexOffset = 0,
.firstInstance = provinces.oceanInstance
});
std::size_t regionOffset = 0;
std::uint32_t indicesOffset = 0;
std::int32_t verticesOffset = 0;
std::uint32_t indicesOffset = static_cast<std::uint32_t>(indicesData.size());
std::int32_t verticesOffset = static_cast<std::int32_t>(verticesData.size());
for (auto& [region, provs] : mapMesh->regions) {
for (auto&[vertices, indices] : provs) {
const auto instance = static_cast<std::uint32_t>(provincesData.size());
indirectCommandsData.push_back({
.indexCount = static_cast<std::uint32_t>(indices.size()),
.instanceCount = 1,
.firstIndex = indicesOffset,
.vertexOffset = verticesOffset,
.firstInstance = static_cast<std::uint32_t>(provincesData.size())
.firstInstance = instance
});
verticesData.append_range(vertices);
const std::vector<glm::vec2> normals = ComputeOutlineNormals(vertices, indices);
for (std::size_t i = 0; i < vertices.size(); ++i) {
verticesData.push_back({.position = vertices[i], .normal = normals[i]});
}
indicesData.append_range(indices);
RasterizeProvince(raster, vertices, indices, instance + 1);
ee_engine::ProvinceSSBO data{
.regionId = region,
.color = 0xFF0000
@ -384,6 +886,11 @@ void ee_vulkan::VulkanRenderer::LoadProvinces() {
regionOffset += provs.size() * sizeof(ee_engine::ProvinceSSBO);
}
provincesData.push_back({
.regionId = OCEAN_REGION_ID,
.color = 0
});
provinces.vertices.emplace(*context, verticesData.size());
provinces.indices.emplace(*context, indicesData.size());
provinces.storage.emplace(*context, provincesData.size());
@ -398,18 +905,155 @@ void ee_vulkan::VulkanRenderer::LoadProvinces() {
renderCommands->SubmitStagingBuffer(*provinces.indices);
renderCommands->SubmitStagingBuffer(*provinces.storage);
renderCommands->SubmitStagingBuffer(*provinces.indirectCommands);
// Pixel-exact province coverage and coastal distance field for the map shaders
CloseRasterGaps(raster, 3);
FillEnclosedWater(raster, 32);
std::int32_t fieldFactor = 1;
while (
static_cast<std::uint64_t>((raster.size.x - 1) / fieldFactor + 1) *
static_cast<std::uint64_t>((raster.size.y - 1) / fieldFactor + 1) > MAX_COAST_FIELD_TEXELS
) ++fieldFactor;
glm::ivec2 fieldSize;
const std::vector<float> coastField = MakeCoastDistanceField(raster, fieldFactor, fieldSize);
provinces.rasterOrigin = raster.origin;
provinces.rasterSize = glm::uvec2(raster.size);
provinces.rasterTexelSize = static_cast<std::uint32_t>(raster.texelSize);
provinces.coastFieldSize = glm::uvec2(fieldSize);
provinces.coastFieldFactor = static_cast<std::uint32_t>(fieldFactor);
LoadMapTextures(raster.ids, coastField, provincesCount);
LOG(INFO, "Map loaded: %d provinces, raster %dx%d (%d map px per texel), coast field %dx%d, %d ms",
static_cast<int>(provincesCount),
raster.size.x, raster.size.y, raster.texelSize,
fieldSize.x, fieldSize.y,
static_cast<int>(std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - startTime
).count())
);
}
void ee_vulkan::VulkanRenderer::LoadMapTextures(
const std::vector<std::uint32_t> &ids,
const std::vector<float> &coastField,
const std::uint32_t provincesCount
) {
const glm::uvec2 size = provinces.rasterSize;
const glm::uvec2 fieldSize = provinces.coastFieldSize;
const auto upload = [this](vulkan_render::PartialTexture &texture, const std::span<const std::byte> data) {
const vulkan_render::Buffer<
std::byte,
vk::BufferUsageFlagBits::eTransferSrc,
vulkan_render::BufferMode::STATIC
> stagingBuffer(*context, data);
renderCommands->TranslateTextureLayout(
texture,
vk::ImageLayout::eUndefined,
vk::ImageLayout::eTransferDstOptimal
);
renderCommands->SubmitTexture(stagingBuffer, texture);
renderCommands->TranslateTextureLayout(
texture,
vk::ImageLayout::eTransferDstOptimal,
vk::ImageLayout::eReadOnlyOptimal
);
texture.Init(vk::ImageAspectFlagBits::eColor);
};
// Province ids, 16 bit when possible
if (provincesCount < std::numeric_limits<std::uint16_t>::max()) {
std::vector<std::uint16_t> packed(ids.size());
std::ranges::transform(ids, packed.begin(), [](const std::uint32_t id) { return static_cast<std::uint16_t>(id); });
provinces.provinceIds.emplace(*context, size.x, size.y, vk::Format::eR16Uint);
upload(*provinces.provinceIds, std::as_bytes(std::span(packed)));
}
else {
provinces.provinceIds.emplace(*context, size.x, size.y, vk::Format::eR32Uint);
upload(*provinces.provinceIds, std::as_bytes(std::span(ids)));
}
// Coast distance field, half floats
{
std::vector<std::uint16_t> packed(coastField.size());
std::ranges::transform(coastField, packed.begin(), [](const float distance) { return glm::packHalf1x16(distance); });
provinces.coastDistance.emplace(*context, fieldSize.x, fieldSize.y, vk::Format::eR16Sfloat);
upload(*provinces.coastDistance, std::as_bytes(std::span(packed)));
}
provinces.nearestSampler = context->device.handle.createSampler({
.magFilter = vk::Filter::eNearest,
.minFilter = vk::Filter::eNearest,
.mipmapMode = vk::SamplerMipmapMode::eNearest,
.addressModeU = vk::SamplerAddressMode::eClampToEdge,
.addressModeV = vk::SamplerAddressMode::eClampToEdge,
.addressModeW = vk::SamplerAddressMode::eClampToEdge,
.mipLodBias = 0.0f,
.anisotropyEnable = vk::False,
.compareEnable = vk::False,
.compareOp = vk::CompareOp::eAlways,
.minLod = 0.0f,
.maxLod = 0.0f,
.borderColor = vk::BorderColor::eIntOpaqueBlack,
.unnormalizedCoordinates = vk::False
});
provinces.linearSampler = context->device.handle.createSampler({
.magFilter = vk::Filter::eLinear,
.minFilter = vk::Filter::eLinear,
.mipmapMode = vk::SamplerMipmapMode::eNearest,
.addressModeU = vk::SamplerAddressMode::eClampToEdge,
.addressModeV = vk::SamplerAddressMode::eClampToEdge,
.addressModeW = vk::SamplerAddressMode::eClampToEdge,
.mipLodBias = 0.0f,
.anisotropyEnable = vk::False,
.compareEnable = vk::False,
.compareOp = vk::CompareOp::eAlways,
.minLod = 0.0f,
.maxLod = 0.0f,
.borderColor = vk::BorderColor::eFloatOpaqueBlack,
.unnormalizedCoordinates = vk::False
});
}
void ee_vulkan::VulkanRenderer::UpdateData(const std::uint32_t frame) {
std::span<glm::mat4> &vpBuffer = viewProjectionUniform->buffers[frame].content;
vpBuffer[0] = viewProjection;
MapUniform &mapData = mapUniform->buffers[frame].content[0];
const float projectionScale = std::abs(viewProjection[0][0]) * static_cast<float>(surface.extent.width);
mapData.viewProjection = viewProjection;
mapData.inverseViewProjection = glm::inverse(viewProjection);
mapData.viewport = glm::vec4(
static_cast<float>(surface.extent.width),
static_cast<float>(surface.extent.height),
projectionScale > 0.0f ? 2.0f / projectionScale : 1.0f,
IsSrgbFormat(surface.format.format) ? 1.0f : 0.0f
);
mapData.raster = glm::vec4(
static_cast<float>(provinces.rasterOrigin.x),
static_cast<float>(provinces.rasterOrigin.y),
static_cast<float>(provinces.rasterSize.x),
static_cast<float>(provinces.rasterSize.y)
);
mapData.coast = glm::vec4(
static_cast<float>(provinces.rasterTexelSize),
static_cast<float>(provinces.coastFieldFactor),
static_cast<float>(provinces.coastFieldSize.x),
static_cast<float>(provinces.coastFieldSize.y)
);
mapData.info = glm::uvec4(provinces.oceanInstance, 0, 0, 0);
provincesPipeline->UpdateDescriptors({
{
.info = vk::DescriptorBufferInfo{
.buffer = viewProjectionUniform->buffers[frame].handle,
.buffer = mapUniform->buffers[frame].handle,
.offset = 0,
.range = viewProjectionUniform->buffers[frame].memorySize
.range = mapUniform->buffers[frame].memorySize
},
.bindingPosition = 0,
.type = vk::DescriptorType::eUniformBuffer
@ -422,6 +1066,24 @@ void ee_vulkan::VulkanRenderer::UpdateData(const std::uint32_t frame) {
},
.bindingPosition = 1,
.type = vk::DescriptorType::eStorageBuffer
},
{
.info = vk::DescriptorImageInfo{
.sampler = provinces.nearestSampler,
.imageView = provinces.provinceIds->view,
.imageLayout = vk::ImageLayout::eReadOnlyOptimal
},
.bindingPosition = 2,
.type = vk::DescriptorType::eCombinedImageSampler
},
{
.info = vk::DescriptorImageInfo{
.sampler = provinces.linearSampler,
.imageView = provinces.coastDistance->view,
.imageLayout = vk::ImageLayout::eReadOnlyOptimal
},
.bindingPosition = 3,
.type = vk::DescriptorType::eCombinedImageSampler
}
}, frame);
}