From 8c41f36e806c0ea9683198bbb21f32ebe2c57fbb Mon Sep 17 00:00:00 2001 From: legatt Date: Wed, 14 Oct 2026 14:56:14 +0300 Subject: [PATCH] Map graphics Update map rendering for drawing pretty ocean and regions. !Changes are made with Claude! --- .../shaders/provinces-flat/provinces.frag | 228 +++++- .../shaders/provinces-flat/provinces.vert | 47 +- assets/shaders/provinces-flat/provinces.frag | Bin 432 -> 16552 bytes assets/shaders/provinces-flat/provinces.vert | Bin 1588 -> 1512 bytes renderers/vulkan/modules/VulkanRenderer.cppm | 56 +- renderers/vulkan/src/VulkanRenderer.cpp | 692 +++++++++++++++++- 6 files changed, 977 insertions(+), 46 deletions(-) diff --git a/assets-src/shaders/provinces-flat/provinces.frag b/assets-src/shaders/provinces-flat/provinces.frag index 1730b4f..b7baedf 100644 --- a/assets-src/shaders/provinces-flat/provinces.frag +++ b/assets-src/shaders/provinces-flat/provinces.frag @@ -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; +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() { - outColor = vec4(fragColor, 1.0); -} \ No newline at end of file + 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); +} diff --git a/assets-src/shaders/provinces-flat/provinces.vert b/assets-src/shaders/provinces-flat/provinces.vert index 5d8fc93..f72fc80 100644 --- a/assets-src/shaders/provinces-flat/provinces.vert +++ b/assets-src/shaders/provinces-flat/provinces.vert @@ -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); -} \ No newline at end of file + // 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); +} diff --git a/assets/shaders/provinces-flat/provinces.frag b/assets/shaders/provinces-flat/provinces.frag index 972e9f99a6501cbbe2d78f9e8e3b7864b8b0a43d..b62ac065634761dd8c730e6061f119ad813a765b 100644 GIT binary patch literal 16552 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 I+riZT14o>$a{vGU literal 432 zcmX|+yJ`Ya7=-`qMvYmoSukDLkTM`CR0L5=8w zFPkPGjfjWHq8)kMPCpWyz^j+MTJP4w-_v0@8h@GSL|O*TbfXcEu=w5|e|%_y7U+@F zXF#mD>AwMw+RR<&%uLYl)Y#qofLk3dz$h%dF=6rQ-kMZ?a>C-Z*i^dRvz^4^(xO9_gug7{KpO3yZ;NY S-n{Sbv0$qIe~8a z8RJc_OO!K_EbC10{>{4&{Dq(mmVy$gmB7+(CRVL8JI}S^)5LGY>P5F7Uf-tj zHkusycVf-1=Htsn6Te%0=TMJ(Z#ClgWA$=waeSp{_57=aUr+o|d}pUE#PiPPMH2&k zpTycjTwmPpl2+&REWRawE!GUYgSdX1fmQkQj-Bkv--)l6y*Ki`sWo$p{wIpx(0)3Z z`E20byp8wRE_=+y7yFuQd(H>GFZ!3FU5)P>6jNu`Hv{id`C|Mw_?8;+rGTbZAKL9e zo!CxJxm+|o%>O}P_MgMO*TbU8^=9~fdYRRF;67%k-rP0<`m1v87xC?BRc$LiZ74X- me=+lW8ORkgb94TY9%9>pzH;ns?z@4vw70Y2^8TjlX7CRZ**HxA literal 1588 zcmYk6+fGwa5QbMvL5Wc`c)&!P)_Op}dICf%hgz|KTwv6w@emYlywI5F1Jny2z*q9d zSMtW__wBt#x{{xn|DRd2X3bt@q}n==QY9Tqhtpmv*RfPZNoZq*?QcEVYJd5<*Pffd zZpM)`QY4x)k;V(Z#@~9Y)@$XMvxs`<@!fxh z_=Ox-tJ9fpCiWN;4e~Bw?a6p2^XaU`xtHOtDdJbK*74jrJDrD`wPvx-Ld{xo--4R) zcJ|Hp4mYsgFXA`x?O)yfa^H%Yy_h=>T)`I#HdwKK2j9EcpL{8EXKy{ZZ_S)K+;`?T z6K7FZv1;C{iuh_c(W#cleDmitX~(**O=eHx&NSsvAe)?%&`~0!#&_TvX^DLm78ZD&gmg=MqTPVk4?}6<{NWf z<~pNC!1ym}%b#Y=-_K`qyvMVw|I9hhbB@2E*!KcoeR$tXxO(h+h3|R()?(jleD&dd kZ{X_N`Fp*^w+HJP`%WL@*d1VO7dQvs-wyc0>sSQ;0A`#@bN~PV diff --git a/renderers/vulkan/modules/VulkanRenderer.cppm b/renderers/vulkan/modules/VulkanRenderer.cppm index 846ec9b..36e139f 100644 --- a/renderers/vulkan/modules/VulkanRenderer.cppm +++ b/renderers/vulkan/modules/VulkanRenderer.cppm @@ -16,13 +16,40 @@ namespace ee_vulkan { constexpr std::uint64_t TIMEOUT = std::numeric_limits::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> 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 provinceIds; + /// Signed distance to the coastline in province raster texels, positive in water (R16 float). + lazy 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 syncManager; lazy> depth; - lazy> viewProjectionUniform; + lazy> mapUniform; ProvinceMesh provinces; lazy rmlRenderInterface; @@ -93,6 +138,11 @@ export namespace ee_vulkan { void LoadProvincesMaterial(); void LoadRmlMaterial(); void LoadProvinces(); + void LoadMapTextures( + const std::vector &ids, + const std::vector &coastField, + std::uint32_t provincesCount + ); void UpdateData(std::uint32_t frame); }; } \ No newline at end of file diff --git a/renderers/vulkan/src/VulkanRenderer.cpp b/renderers/vulkan/src/VulkanRenderer.cpp index e2af5e6..c8b437d 100644 --- a/renderers/vulkan/src/VulkanRenderer.cpp +++ b/renderers/vulkan/src/VulkanRenderer.cpp @@ -4,11 +4,448 @@ module; #include #include #include +#include #include 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::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::max()}; + glm::vec2 max {std::numeric_limits::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 ids; + + ProvinceRaster(const Bounds &bounds, const std::int32_t mapPixelsPerTexel) : texelSize(mapPixelsPerTexel) { + if (!bounds.IsEmpty()) { + const glm::ivec2 low(static_cast(std::floor(bounds.min.x)), static_cast(std::floor(bounds.min.y))); + const glm::ivec2 high(static_cast(std::ceil(bounds.max.x)), static_cast(std::ceil(bounds.max.y))); + origin = low - RASTER_PADDING * texelSize; + size = (high - low) / texelSize + 1 + 2 * RASTER_PADDING; + } + ids.assign(static_cast(size.x) * static_cast(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(candidate); + const std::uint64_t width = static_cast(std::ceil(extent.x / step)) + 2 * RASTER_PADDING + 1; + const std::uint64_t height = static_cast(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::lround(world.x)), + static_cast(std::lround(world.y)) + ) - origin; + } + }; + + std::int64_t EdgeFunction(const glm::ivec2 &a, const glm::ivec2 &b, const glm::ivec2 &p) { + return static_cast(b.x - a.x) * (p.y - a.y) - static_cast(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 &vertices, + const std::vector &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(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(raster.size.x); + const std::size_t height = static_cast(raster.size.y); + std::uint32_t *ids = raster.ids.data(); + + std::vector candidates; + std::vector> 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 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::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(raster.size.x); + const std::size_t height = static_cast(raster.size.y); + std::uint32_t *ids = raster.ids.data(); + std::vector state(raster.ids.size(), UNVISITED); + std::vector component; + std::vector> 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::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::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::max(); + z[1] = std::numeric_limits::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(q) * static_cast(q)) - (f[p] + static_cast(p) * static_cast(p))) + / (2.0f * static_cast(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::max(); + } + + k = 0; + for (std::int32_t q = 0; q < n; ++q) { + while (z[k + 1] < static_cast(q)) ++k; + const float delta = static_cast(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 &grid, const std::int32_t width, const std::int32_t height) { + const std::int32_t length = std::max(width, height); + std::vector f(length), d(length), z(length + 1); + std::vector 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(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(y) * width + x] = d[y]; + } + + for (std::int32_t y = 0; y < height; ++y) { + float *row = grid.data() + static_cast(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 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(fieldSize.x) * static_cast(fieldSize.y); + + std::vector land(count); + std::vector 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(y) * fieldSize.x + x; + land[i] = raster.ids[static_cast(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 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(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 ComputeOutlineNormals( + const std::vector &vertices, + const std::vector &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 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(a) << 16) | b + : (static_cast(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 outline; + std::vector 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 normals(vertices.size(), glm::vec2(0.0f)); + std::vector 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(provs.size()); + for (const auto &province : provs) { + for (const glm::vec2 &vertex : province.vertices) bounds.Add(vertex); + } + } + + const std::uint32_t maxDimension = std::min( + context->physicalDeviceCapabilities.properties.limits.maxImageDimension2D, + 16384 + ); + ProvinceRaster raster(bounds, ProvinceRaster::ChooseTexelSize(bounds, maxDimension, MAX_RASTER_TEXELS)); + provinces.oceanInstance = provincesCount; + std::vector provincesData; - std::vector verticesData; + std::vector verticesData; std::vector indicesData; std::vector 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{{ + {.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{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(indicesData.size()); + std::int32_t verticesOffset = static_cast(verticesData.size()); for (auto& [region, provs] : mapMesh->regions) { for (auto&[vertices, indices] : provs) { + const auto instance = static_cast(provincesData.size()); + indirectCommandsData.push_back({ .indexCount = static_cast(indices.size()), .instanceCount = 1, .firstIndex = indicesOffset, .vertexOffset = verticesOffset, - .firstInstance = static_cast(provincesData.size()) + .firstInstance = instance }); - verticesData.append_range(vertices); + const std::vector 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((raster.size.x - 1) / fieldFactor + 1) * + static_cast((raster.size.y - 1) / fieldFactor + 1) > MAX_COAST_FIELD_TEXELS + ) ++fieldFactor; + + glm::ivec2 fieldSize; + const std::vector coastField = MakeCoastDistanceField(raster, fieldFactor, fieldSize); + + provinces.rasterOrigin = raster.origin; + provinces.rasterSize = glm::uvec2(raster.size); + provinces.rasterTexelSize = static_cast(raster.texelSize); + provinces.coastFieldSize = glm::uvec2(fieldSize); + provinces.coastFieldFactor = static_cast(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(provincesCount), + raster.size.x, raster.size.y, raster.texelSize, + fieldSize.x, fieldSize.y, + static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now() - startTime + ).count()) + ); +} + +void ee_vulkan::VulkanRenderer::LoadMapTextures( + const std::vector &ids, + const std::vector &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 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::max()) { + std::vector packed(ids.size()); + std::ranges::transform(ids, packed.begin(), [](const std::uint32_t id) { return static_cast(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 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 &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(surface.extent.width); + mapData.viewProjection = viewProjection; + mapData.inverseViewProjection = glm::inverse(viewProjection); + mapData.viewport = glm::vec4( + static_cast(surface.extent.width), + static_cast(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(provinces.rasterOrigin.x), + static_cast(provinces.rasterOrigin.y), + static_cast(provinces.rasterSize.x), + static_cast(provinces.rasterSize.y) + ); + mapData.coast = glm::vec4( + static_cast(provinces.rasterTexelSize), + static_cast(provinces.coastFieldFactor), + static_cast(provinces.coastFieldSize.x), + static_cast(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); }