react-native-cesium 1.0.6 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +64 -11
  2. package/ReactNativeCesium.podspec +1 -1
  3. package/android/CMakeLists.txt +5 -3
  4. package/android/src/main/cpp/CesiumBridgeAndroid.cpp +197 -227
  5. package/android/src/main/cpp/CesiumBridgeAndroid.h +48 -49
  6. package/android/src/main/cpp/cpp-adapter.cpp +2 -2
  7. package/android/src/main/cpp/vulkan/VulkanBackend.cpp +1010 -569
  8. package/android/src/main/cpp/vulkan/VulkanBackend.h +121 -12
  9. package/android/src/main/cpp/vulkan/shaders/sky.frag +20 -10
  10. package/android/src/main/java/com/margelo/nitro/reactnativecesium/CesiumBridgeJNI.kt +28 -0
  11. package/android/src/main/java/com/margelo/nitro/reactnativecesium/HybridCesiumView.kt +295 -58
  12. package/cpp/engine/CameraSmoother.cpp +99 -0
  13. package/cpp/engine/CameraSmoother.hpp +29 -0
  14. package/cpp/engine/CameraTargetState.cpp +49 -0
  15. package/cpp/engine/CameraTargetState.hpp +121 -0
  16. package/cpp/engine/CesiumEngine.cpp +206 -60
  17. package/cpp/engine/CesiumEngine.hpp +77 -10
  18. package/cpp/engine/EngineTunables.hpp +79 -0
  19. package/cpp/engine/GeoidConverter.cpp +36 -0
  20. package/cpp/engine/GeoidConverter.hpp +10 -0
  21. package/cpp/engine/GlobeCamera.cpp +7 -2
  22. package/cpp/engine/MetricsAggregator.cpp +71 -0
  23. package/cpp/engine/MetricsAggregator.hpp +54 -0
  24. package/cpp/engine/TileLifecycleManager.cpp +0 -12
  25. package/cpp/engine/TileLifecycleManager.hpp +0 -3
  26. package/cpp/third_party/egm96/EGM96.c +289 -0
  27. package/cpp/third_party/egm96/EGM96.h +45 -0
  28. package/cpp/third_party/egm96/EGM96_data.h +65372 -0
  29. package/ios/CesiumBridge.h +24 -5
  30. package/ios/CesiumBridge.mm +149 -271
  31. package/ios/HybridCesiumView.swift +197 -40
  32. package/ios/metal/MetalBackend.mm +72 -23
  33. package/lib/typescript/src/specs/cesium-view.nitro.d.ts +26 -0
  34. package/lib/typescript/src/specs/cesium-view.nitro.d.ts.map +1 -1
  35. package/nitrogen/generated/android/c++/JCesiumMetrics.hpp +5 -1
  36. package/nitrogen/generated/android/c++/JHybridCesiumViewSpec.cpp +109 -1
  37. package/nitrogen/generated/android/c++/JHybridCesiumViewSpec.hpp +24 -0
  38. package/nitrogen/generated/android/c++/views/JHybridCesiumViewStateUpdater.cpp +48 -0
  39. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativecesium/CameraState.kt +25 -0
  40. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativecesium/CesiumMetrics.kt +32 -2
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativecesium/HybridCesiumViewSpec.kt +72 -0
  42. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativecesium/Quaternion.kt +19 -0
  43. package/nitrogen/generated/ios/ReactNativeCesium-Swift-Cxx-Bridge.hpp +30 -0
  44. package/nitrogen/generated/ios/c++/HybridCesiumViewSpecSwift.hpp +85 -1
  45. package/nitrogen/generated/ios/c++/views/HybridCesiumViewComponent.mm +60 -0
  46. package/nitrogen/generated/ios/swift/CesiumMetrics.swift +7 -2
  47. package/nitrogen/generated/ios/swift/HybridCesiumViewSpec.swift +12 -0
  48. package/nitrogen/generated/ios/swift/HybridCesiumViewSpec_cxx.swift +288 -0
  49. package/nitrogen/generated/shared/c++/CesiumMetrics.hpp +5 -1
  50. package/nitrogen/generated/shared/c++/HybridCesiumViewSpec.cpp +24 -0
  51. package/nitrogen/generated/shared/c++/HybridCesiumViewSpec.hpp +25 -1
  52. package/nitrogen/generated/shared/c++/views/HybridCesiumViewComponent.cpp +132 -0
  53. package/nitrogen/generated/shared/c++/views/HybridCesiumViewComponent.hpp +13 -1
  54. package/nitrogen/generated/shared/json/CesiumViewConfig.json +12 -0
  55. package/package.json +4 -4
  56. package/scripts/cesium/clone.mjs +1 -1
  57. package/src/specs/cesium-view.nitro.ts +29 -0
  58. package/cpp/engine/DeletionQueue.cpp +0 -28
  59. package/cpp/engine/DeletionQueue.hpp +0 -28
@@ -12,11 +12,26 @@
12
12
  // - Reversed-Z infinite projection: depth clear = 0, compare = GREATER.
13
13
  // - Sky drawn first (no depth write), terrain drawn after.
14
14
  // - Water mask: UV computed from geographic lat/lon + tile bounds (not fragUV).
15
- // - Push constants split: bytes 0-63 vertex (MVP), bytes 64-127 fragment.
15
+ // - Push constants: bytes 0-63 vertex (MVP), bytes 64-127 fragment.
16
16
  // - Triple-buffered persistent VkBuffers guarded by VkFence.
17
+ //
18
+ // Texture upload pipeline (P0-vk-uploads):
19
+ // - Persistent host-visible staging ring (64 MB, mapped once at init).
20
+ // - createRasterTexture / createWaterMaskTexture allocate a slice + memcpy
21
+ // pixels in, then enqueue a PendingUpload — they DO NOT submit a command
22
+ // buffer or call vkQueueWaitIdle.
23
+ // - beginFrame submits the queued uploads in one command buffer with batched
24
+ // pipeline barriers, signalling the per-frame upload fence. The matching
25
+ // fence is waited on N=kMaxFramesInFlight frames later, at which point the
26
+ // staging slices are reclaimed.
27
+ // - Textures are guarded by a `pendingUpload` flag — drawScene falls back to
28
+ // the white texture for a tile whose pixels have not yet hit VRAM (one
29
+ // frame at most).
17
30
 
18
31
  #include "VulkanBackend.h"
19
32
 
33
+ #include "engine/EngineTunables.hpp"
34
+
20
35
  #include <android/native_window.h>
21
36
  #include <android/log.h>
22
37
 
@@ -25,10 +40,12 @@
25
40
  #include <algorithm>
26
41
  #include <array>
27
42
  #include <cassert>
43
+ #include <cmath>
44
+ #include <cstdio>
28
45
  #include <cstring>
29
- #include <deque>
30
- #include <mutex>
46
+ #include <fstream>
31
47
  #include <stdexcept>
48
+ #include <vector>
32
49
 
33
50
  // Pre-compiled SPIR-V headers (generated at build time by glslc + spv_to_header.cmake)
34
51
  #include "terrain.vert.spv.h"
@@ -38,13 +55,24 @@
38
55
 
39
56
  #define LOG_TAG "VulkanBackend"
40
57
  #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
58
+ #define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)
41
59
  #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
42
60
 
43
- #define VK_CHECK(call) do { \
44
- VkResult _r = (call); \
45
- if (_r != VK_SUCCESS) { \
46
- LOGE("%s failed: %d at %s:%d", #call, _r, __FILE__, __LINE__); \
47
- } \
61
+ // VK_CHECK throws on error during init paths — these are non-recoverable. For
62
+ // per-frame paths VkResult is inspected explicitly so we can degrade gracefully.
63
+ #define VK_CHECK(call) do { \
64
+ VkResult _r = (call); \
65
+ if (_r != VK_SUCCESS) { \
66
+ LOGE("%s failed: %d at %s:%d", #call, _r, __FILE__, __LINE__); \
67
+ } \
68
+ } while(0)
69
+
70
+ #define VK_CHECK_FATAL(call) do { \
71
+ VkResult _r = (call); \
72
+ if (_r != VK_SUCCESS) { \
73
+ LOGE("%s failed: %d at %s:%d (FATAL)", #call, _r, __FILE__, __LINE__); \
74
+ return; \
75
+ } \
48
76
  } while(0)
49
77
 
50
78
  // Per-frame UBO (set 0): camera ECEF for the fragment lighting view direction.
@@ -66,7 +94,6 @@ struct TerrainVertexPC {
66
94
  };
67
95
 
68
96
  struct TerrainFragmentPC {
69
- // Fragment push constants occupy bytes 64-127 in the push constant block.
70
97
  uint32_t hasOverlay; // 4 bytes
71
98
  uint32_t isEllipsoidFallback; // 4 bytes
72
99
  uint32_t isOnlyWater; // 4 bytes
@@ -91,6 +118,8 @@ VulkanBackend::VulkanBackend() = default;
91
118
 
92
119
  VulkanBackend::~VulkanBackend() { shutdown(); }
93
120
 
121
+ // ── Init ────────────────────────────────────────────────────────────────────
122
+
94
123
  void VulkanBackend::initialize(void* nativeSurface, int width, int height) {
95
124
  window_ = nativeSurface;
96
125
  viewportWidth_ = width;
@@ -105,18 +134,23 @@ void VulkanBackend::initialize(void* nativeSurface, int width, int height) {
105
134
  pickPhysicalDevice();
106
135
  createDevice();
107
136
  createCommandPool();
108
- createSwapchain();
109
- createRenderPass();
110
- createDepthResources();
111
- createFramebuffers();
137
+ createPipelineCache();
138
+ // Defer swapchain creation until the first beginFrame() to avoid Android
139
+ // compositor readiness issues. The swapchain, renderpass, depth resources,
140
+ // framebuffers, and pipelines will all be created on-demand.
141
+ // createSwapchain();
142
+ // createRenderPass();
143
+ // createDepthResources();
144
+ // if (sampleCount_ != VK_SAMPLE_COUNT_1_BIT) createMsaaResources();
145
+ // createFramebuffers();
112
146
  createCommandBuffers();
113
147
  createSyncObjects();
114
148
  createDescriptorSetLayout();
115
149
  createDescriptorPool();
116
150
  createWaterMaskPool();
117
151
  createPipelineLayout();
118
- createSkyPipeline();
119
- createTerrainPipeline();
152
+ // createGraphicsPipelinesAll();
153
+ initStagingRing(kStagingSize);
120
154
  createFallbackTexture();
121
155
  createWaterMaskFallback();
122
156
 
@@ -147,7 +181,6 @@ void VulkanBackend::initialize(void* nativeSurface, int width, int height) {
147
181
  skyWrite.pBufferInfo = &skyBufInfo;
148
182
  vkUpdateDescriptorSets(device_, 1, &skyWrite, 0, nullptr);
149
183
 
150
- // Allocate terrain UBOs + descriptor sets (set 0: UBO only, one per frame)
151
184
  for (int i = 0; i < kMaxFramesInFlight; ++i) {
152
185
  createBuffer(sizeof(TerrainUBO), VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT,
153
186
  VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
@@ -174,12 +207,10 @@ void VulkanBackend::initialize(void* nativeSurface, int width, int height) {
174
207
  uboWrite.pBufferInfo = &bufInfo;
175
208
  vkUpdateDescriptorSets(device_, 1, &uboWrite, 0, nullptr);
176
209
 
177
- // Persistent mapping — HOST_COHERENT so no explicit flush needed.
178
210
  VK_CHECK(vkMapMemory(device_, terrainUboMems_[i], 0, sizeof(TerrainUBO),
179
211
  0, &terrainUboMapped_[i]));
180
212
  }
181
213
 
182
- // Allocate fallback texture descriptor set (set 1)
183
214
  {
184
215
  VkDescriptorSetAllocateInfo allocInfo{};
185
216
  allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
@@ -202,9 +233,6 @@ void VulkanBackend::initialize(void* nativeSurface, int width, int height) {
202
233
  texWrite.pImageInfo = &imgInfo;
203
234
  vkUpdateDescriptorSets(device_, 1, &texWrite, 0, nullptr);
204
235
  }
205
-
206
- LOGI("VulkanBackend initialized: %dx%d, %zu swapchain images",
207
- width, height, swapchainImages_.size());
208
236
  }
209
237
 
210
238
  void VulkanBackend::resize(int width, int height) {
@@ -217,6 +245,15 @@ void VulkanBackend::shutdown() {
217
245
  if (device_ == VK_NULL_HANDLE) return;
218
246
  vkDeviceWaitIdle(device_);
219
247
 
248
+ persistPipelineCache();
249
+
250
+ // Drain pending uploads (GPU is idle).
251
+ {
252
+ std::lock_guard<std::mutex> lk(stagingMutex_);
253
+ for (auto& vec : uploadsInFlight_) vec.clear();
254
+ pendingUploadsCurrent_.clear();
255
+ }
256
+
220
257
  // Drain the deferred deletion queue — GPU is idle so all are safe to free.
221
258
  {
222
259
  std::lock_guard<std::mutex> lk(pendingDeletesMutex_);
@@ -227,29 +264,29 @@ void VulkanBackend::shutdown() {
227
264
  }
228
265
  }
229
266
 
230
- // Free fallback imagery texture
231
267
  if (fallbackTexture_.sampler) vkDestroySampler(device_, fallbackTexture_.sampler, nullptr);
232
268
  if (fallbackTexture_.imageView) vkDestroyImageView(device_, fallbackTexture_.imageView, nullptr);
233
269
  if (fallbackTexture_.image) vkDestroyImage(device_, fallbackTexture_.image, nullptr);
234
270
  if (fallbackTexture_.memory) vkFreeMemory(device_, fallbackTexture_.memory, nullptr);
235
271
  fallbackTexture_ = {};
272
+ delete fallbackTexturePtr_;
273
+ fallbackTexturePtr_ = nullptr;
236
274
 
237
- // Free fallback water mask texture (its descriptor set belongs to waterMaskPool_)
238
275
  if (fallbackWaterMaskTex_.sampler) vkDestroySampler(device_, fallbackWaterMaskTex_.sampler, nullptr);
239
276
  if (fallbackWaterMaskTex_.imageView) vkDestroyImageView(device_, fallbackWaterMaskTex_.imageView, nullptr);
240
277
  if (fallbackWaterMaskTex_.image) vkDestroyImage(device_, fallbackWaterMaskTex_.image, nullptr);
241
278
  if (fallbackWaterMaskTex_.memory) vkFreeMemory(device_, fallbackWaterMaskTex_.memory, nullptr);
242
279
  fallbackWaterMaskTex_ = {};
243
280
  fallbackWaterMaskDescSet_ = VK_NULL_HANDLE;
281
+ delete fallbackWaterMaskTexPtr_;
282
+ fallbackWaterMaskTexPtr_ = nullptr;
244
283
 
245
- // Free sky UBO (unmap before destroy)
246
284
  if (skyUboMapped_) { vkUnmapMemory(device_, skyUboMemory_); skyUboMapped_ = nullptr; }
247
285
  if (skyUboBuffer_) vkDestroyBuffer(device_, skyUboBuffer_, nullptr);
248
286
  if (skyUboMemory_) vkFreeMemory(device_, skyUboMemory_, nullptr);
249
287
  skyUboBuffer_ = VK_NULL_HANDLE;
250
288
  skyUboMemory_ = VK_NULL_HANDLE;
251
289
 
252
- // Free terrain UBOs (unmap before destroy)
253
290
  for (int i = 0; i < kMaxFramesInFlight; ++i) {
254
291
  if (terrainUboMapped_[i]) { vkUnmapMemory(device_, terrainUboMems_[i]); terrainUboMapped_[i] = nullptr; }
255
292
  if (terrainUboBufs_[i]) vkDestroyBuffer(device_, terrainUboBufs_[i], nullptr);
@@ -258,7 +295,6 @@ void VulkanBackend::shutdown() {
258
295
  terrainUboMems_[i] = VK_NULL_HANDLE;
259
296
  }
260
297
 
261
- // Free persistent frame buffers (position, index, UV, altitude)
262
298
  for (int i = 0; i < kMaxFramesInFlight; ++i) {
263
299
  if (vtxBufs_[i]) vkDestroyBuffer(device_, vtxBufs_[i], nullptr);
264
300
  if (vtxMems_[i]) vkFreeMemory(device_, vtxMems_[i], nullptr);
@@ -273,6 +309,17 @@ void VulkanBackend::shutdown() {
273
309
  vtxCaps_[i] = idxCaps_[i] = uvCaps_[i] = altCaps_[i] = 0;
274
310
  }
275
311
 
312
+ // Staging ring
313
+ if (stagingMapped_) { vkUnmapMemory(device_, stagingMemory_); stagingMapped_ = nullptr; }
314
+ if (stagingBuffer_) vkDestroyBuffer(device_, stagingBuffer_, nullptr);
315
+ if (stagingMemory_) vkFreeMemory(device_, stagingMemory_, nullptr);
316
+ stagingBuffer_ = VK_NULL_HANDLE;
317
+ stagingMemory_ = VK_NULL_HANDLE;
318
+ for (auto& f : uploadFences_) {
319
+ if (f) vkDestroyFence(device_, f, nullptr);
320
+ f = VK_NULL_HANDLE;
321
+ }
322
+
276
323
  for (auto& s : imageAvailableSemaphores_) vkDestroySemaphore(device_, s, nullptr);
277
324
  for (auto& s : renderFinishedSemaphores_) vkDestroySemaphore(device_, s, nullptr);
278
325
  for (auto& f : inFlightFences_) vkDestroyFence(device_, f, nullptr);
@@ -282,8 +329,7 @@ void VulkanBackend::shutdown() {
282
329
 
283
330
  cleanupSwapchain();
284
331
 
285
- if (terrainPipeline_) vkDestroyPipeline(device_, terrainPipeline_, nullptr);
286
- if (skyPipeline_) vkDestroyPipeline(device_, skyPipeline_, nullptr);
332
+ destroyGraphicsPipelinesAll();
287
333
  if (terrainPipelineLayout_) vkDestroyPipelineLayout(device_, terrainPipelineLayout_, nullptr);
288
334
  if (skyPipelineLayout_) vkDestroyPipelineLayout(device_, skyPipelineLayout_, nullptr);
289
335
  if (terrainDescSetLayout_) vkDestroyDescriptorSetLayout(device_, terrainDescSetLayout_, nullptr);
@@ -291,13 +337,14 @@ void VulkanBackend::shutdown() {
291
337
  if (skyDescSetLayout_) vkDestroyDescriptorSetLayout(device_, skyDescSetLayout_, nullptr);
292
338
  if (descriptorPool_) vkDestroyDescriptorPool(device_, descriptorPool_, nullptr);
293
339
  if (waterMaskPool_) vkDestroyDescriptorPool(device_, waterMaskPool_, nullptr);
340
+ if (pipelineCache_) vkDestroyPipelineCache(device_, pipelineCache_, nullptr);
294
341
  if (commandPool_) vkDestroyCommandPool(device_, commandPool_, nullptr);
295
342
  if (renderPass_) vkDestroyRenderPass(device_, renderPass_, nullptr);
296
343
 
297
- terrainPipeline_ = skyPipeline_ = VK_NULL_HANDLE;
298
344
  terrainPipelineLayout_ = skyPipelineLayout_ = VK_NULL_HANDLE;
299
345
  terrainDescSetLayout_ = terrainTexLayout_ = skyDescSetLayout_ = VK_NULL_HANDLE;
300
346
  descriptorPool_ = waterMaskPool_ = VK_NULL_HANDLE;
347
+ pipelineCache_ = VK_NULL_HANDLE;
301
348
  commandPool_ = VK_NULL_HANDLE;
302
349
  renderPass_ = VK_NULL_HANDLE;
303
350
 
@@ -340,24 +387,67 @@ void VulkanBackend::pickPhysicalDevice() {
340
387
  std::vector<VkPhysicalDevice> devices(count);
341
388
  vkEnumeratePhysicalDevices(instance_, &count, devices.data());
342
389
 
390
+ // Score: discrete >> integrated >> any. Within type, larger device-local
391
+ // memory wins. Falls back to the first graphics+present queue if nothing
392
+ // scores positively.
393
+ int bestScore = -1;
394
+ uint32_t bestQueue = 0;
395
+ VkPhysicalDevice best = VK_NULL_HANDLE;
396
+
343
397
  for (auto& dev : devices) {
344
398
  uint32_t qCount = 0;
345
399
  vkGetPhysicalDeviceQueueFamilyProperties(dev, &qCount, nullptr);
346
400
  std::vector<VkQueueFamilyProperties> qFamilies(qCount);
347
401
  vkGetPhysicalDeviceQueueFamilyProperties(dev, &qCount, qFamilies.data());
348
402
 
403
+ uint32_t graphicsQueueIdx = UINT32_MAX;
349
404
  for (uint32_t i = 0; i < qCount; ++i) {
350
405
  VkBool32 presentSupport = VK_FALSE;
351
406
  vkGetPhysicalDeviceSurfaceSupportKHR(dev, i, surface_, &presentSupport);
352
-
353
407
  if ((qFamilies[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) && presentSupport) {
354
- physicalDevice_ = dev;
355
- graphicsFamily_ = i;
356
- return;
408
+ graphicsQueueIdx = i;
409
+ break;
357
410
  }
358
411
  }
412
+ if (graphicsQueueIdx == UINT32_MAX) continue;
413
+
414
+ VkPhysicalDeviceProperties props;
415
+ vkGetPhysicalDeviceProperties(dev, &props);
416
+
417
+ int score = 0;
418
+ if (props.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) score = 1000;
419
+ else if (props.deviceType == VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU) score = 500;
420
+ else score = 100;
421
+
422
+ if (score > bestScore) {
423
+ bestScore = score;
424
+ bestQueue = graphicsQueueIdx;
425
+ best = dev;
426
+ }
359
427
  }
360
- LOGE("No suitable Vulkan physical device found");
428
+
429
+ if (best == VK_NULL_HANDLE) {
430
+ LOGE("No suitable Vulkan physical device found");
431
+ return;
432
+ }
433
+
434
+ physicalDevice_ = best;
435
+ graphicsFamily_ = bestQueue;
436
+
437
+ // Cache properties relevant to sampler / pipeline state.
438
+ VkPhysicalDeviceFeatures feats{};
439
+ VkPhysicalDeviceProperties props{};
440
+ vkGetPhysicalDeviceFeatures(physicalDevice_, &feats);
441
+ vkGetPhysicalDeviceProperties(physicalDevice_, &props);
442
+ supportsAnisotropy_ = feats.samplerAnisotropy == VK_TRUE;
443
+ maxAnisotropy_ = props.limits.maxSamplerAnisotropy;
444
+
445
+ const VkSampleCountFlags counts = props.limits.framebufferColorSampleCounts &
446
+ props.limits.framebufferDepthSampleCounts;
447
+ if (counts & VK_SAMPLE_COUNT_8_BIT) supportedMsaaMask_ = VK_SAMPLE_COUNT_8_BIT;
448
+ else if (counts & VK_SAMPLE_COUNT_4_BIT) supportedMsaaMask_ = VK_SAMPLE_COUNT_4_BIT;
449
+ else if (counts & VK_SAMPLE_COUNT_2_BIT) supportedMsaaMask_ = VK_SAMPLE_COUNT_2_BIT;
450
+ else supportedMsaaMask_ = VK_SAMPLE_COUNT_1_BIT;
361
451
  }
362
452
 
363
453
  void VulkanBackend::createDevice() {
@@ -371,6 +461,7 @@ void VulkanBackend::createDevice() {
371
461
  const char* devExtensions[] = { VK_KHR_SWAPCHAIN_EXTENSION_NAME };
372
462
 
373
463
  VkPhysicalDeviceFeatures features{};
464
+ features.samplerAnisotropy = supportsAnisotropy_ ? VK_TRUE : VK_FALSE;
374
465
 
375
466
  VkDeviceCreateInfo createInfo{};
376
467
  createInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
@@ -385,10 +476,19 @@ void VulkanBackend::createDevice() {
385
476
  presentQueue_ = graphicsQueue_;
386
477
  }
387
478
 
388
- // ── Depth format selection ─────────────────────────────────────────────────────
479
+ VkSampleCountFlagBits VulkanBackend::resolveMsaaSamples(int requested) const {
480
+ if (requested <= 1) return VK_SAMPLE_COUNT_1_BIT;
481
+ // Clamp request to the largest mask the device actually supports.
482
+ VkSampleCountFlagBits desired =
483
+ requested >= 8 ? VK_SAMPLE_COUNT_8_BIT
484
+ : (requested >= 4 ? VK_SAMPLE_COUNT_4_BIT
485
+ : VK_SAMPLE_COUNT_2_BIT);
486
+ if (static_cast<int>(desired) > static_cast<int>(supportedMsaaMask_))
487
+ desired = supportedMsaaMask_;
488
+ return desired;
489
+ }
389
490
 
390
491
  VkFormat VulkanBackend::pickDepthFormat() const {
391
- // Prefer D32 for maximum precision; fall back to packed D24/S8 or D16.
392
492
  for (VkFormat candidate : {VK_FORMAT_D32_SFLOAT,
393
493
  VK_FORMAT_D24_UNORM_S8_UINT,
394
494
  VK_FORMAT_D16_UNORM}) {
@@ -401,6 +501,26 @@ VkFormat VulkanBackend::pickDepthFormat() const {
401
501
  return VK_FORMAT_D32_SFLOAT;
402
502
  }
403
503
 
504
+ VkPresentModeKHR VulkanBackend::pickPresentMode() const {
505
+ uint32_t count = 0;
506
+ vkGetPhysicalDeviceSurfacePresentModesKHR(physicalDevice_, surface_, &count, nullptr);
507
+ std::vector<VkPresentModeKHR> modes(count);
508
+ vkGetPhysicalDeviceSurfacePresentModesKHR(physicalDevice_, surface_, &count, modes.data());
509
+
510
+ bool hasMailbox = false, hasFifoRelaxed = false;
511
+ for (auto m : modes) {
512
+ if (m == VK_PRESENT_MODE_MAILBOX_KHR) hasMailbox = true;
513
+ if (m == VK_PRESENT_MODE_FIFO_RELAXED_KHR) hasFifoRelaxed = true;
514
+ }
515
+ // Prefer FIFO_RELAXED for stutter resilience on phones where we sometimes
516
+ // miss a vsync — it presents the late frame immediately rather than holding
517
+ // for the next vsync. MAILBOX is "lower latency" but can drop frames; not
518
+ // ideal for a globe view that's already battery-sensitive.
519
+ if (hasFifoRelaxed) return VK_PRESENT_MODE_FIFO_RELAXED_KHR;
520
+ if (hasMailbox) return VK_PRESENT_MODE_MAILBOX_KHR;
521
+ return VK_PRESENT_MODE_FIFO_KHR; // always available per spec
522
+ }
523
+
404
524
  // ── Swapchain ──────────────────────────────────────────────────────────────────
405
525
 
406
526
  void VulkanBackend::createSwapchain() {
@@ -412,7 +532,6 @@ void VulkanBackend::createSwapchain() {
412
532
  std::vector<VkSurfaceFormatKHR> formats(fmtCount);
413
533
  vkGetPhysicalDeviceSurfaceFormatsKHR(physicalDevice_, surface_, &fmtCount, formats.data());
414
534
 
415
- // Prefer sRGB
416
535
  swapchainFormat_ = formats[0].format;
417
536
  VkColorSpaceKHR colorSpace = formats[0].colorSpace;
418
537
  for (auto& fmt : formats) {
@@ -427,7 +546,6 @@ void VulkanBackend::createSwapchain() {
427
546
  }
428
547
  }
429
548
 
430
- // Determine extent
431
549
  if (caps.currentExtent.width != UINT32_MAX) {
432
550
  swapchainExtent_ = caps.currentExtent;
433
551
  } else {
@@ -441,11 +559,8 @@ void VulkanBackend::createSwapchain() {
441
559
  if (caps.maxImageCount > 0 && imageCount > caps.maxImageCount)
442
560
  imageCount = caps.maxImageCount;
443
561
 
444
- // Pick depth format once (stable across recreations on the same device).
445
562
  depthFormat_ = pickDepthFormat();
446
563
 
447
- // Prefer OPAQUE composite alpha (avoids blending with the window behind the
448
- // surface); fall back through the other modes in priority order.
449
564
  VkCompositeAlphaFlagBitsKHR compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;
450
565
  for (auto candidate : {VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR,
451
566
  VK_COMPOSITE_ALPHA_INHERIT_BIT_KHR,
@@ -471,7 +586,7 @@ void VulkanBackend::createSwapchain() {
471
586
  swapInfo.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;
472
587
  swapInfo.preTransform = caps.currentTransform;
473
588
  swapInfo.compositeAlpha = compositeAlpha;
474
- swapInfo.presentMode = VK_PRESENT_MODE_FIFO_KHR;
589
+ swapInfo.presentMode = pickPresentMode();
475
590
  swapInfo.clipped = VK_TRUE;
476
591
  swapInfo.oldSwapchain = oldSwapchain;
477
592
  VK_CHECK(vkCreateSwapchainKHR(device_, &swapInfo, nullptr, &swapchain_));
@@ -501,19 +616,27 @@ void VulkanBackend::createSwapchain() {
501
616
  }
502
617
 
503
618
  void VulkanBackend::createRenderPass() {
619
+ const bool msaa = (sampleCount_ != VK_SAMPLE_COUNT_1_BIT);
620
+
621
+ // Colour attachment: when MSAA is enabled this is the multisampled colour
622
+ // target (does not store), and we add a single-sample resolve attachment
623
+ // that becomes PRESENT_SRC. When MSAA is off the colour attachment IS the
624
+ // swapchain image directly.
504
625
  VkAttachmentDescription colorAttach{};
505
626
  colorAttach.format = swapchainFormat_;
506
- colorAttach.samples = VK_SAMPLE_COUNT_1_BIT;
627
+ colorAttach.samples = sampleCount_;
507
628
  colorAttach.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
508
- colorAttach.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
629
+ colorAttach.storeOp = msaa ? VK_ATTACHMENT_STORE_OP_DONT_CARE
630
+ : VK_ATTACHMENT_STORE_OP_STORE;
509
631
  colorAttach.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
510
632
  colorAttach.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
511
633
  colorAttach.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
512
- colorAttach.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;
634
+ colorAttach.finalLayout = msaa ? VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL
635
+ : VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;
513
636
 
514
637
  VkAttachmentDescription depthAttach{};
515
638
  depthAttach.format = depthFormat_;
516
- depthAttach.samples = VK_SAMPLE_COUNT_1_BIT;
639
+ depthAttach.samples = sampleCount_;
517
640
  depthAttach.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
518
641
  depthAttach.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
519
642
  depthAttach.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
@@ -521,6 +644,16 @@ void VulkanBackend::createRenderPass() {
521
644
  depthAttach.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
522
645
  depthAttach.finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;
523
646
 
647
+ VkAttachmentDescription resolveAttach{};
648
+ resolveAttach.format = swapchainFormat_;
649
+ resolveAttach.samples = VK_SAMPLE_COUNT_1_BIT;
650
+ resolveAttach.loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
651
+ resolveAttach.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
652
+ resolveAttach.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
653
+ resolveAttach.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
654
+ resolveAttach.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
655
+ resolveAttach.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;
656
+
524
657
  VkAttachmentReference colorRef{};
525
658
  colorRef.attachment = 0;
526
659
  colorRef.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
@@ -529,11 +662,16 @@ void VulkanBackend::createRenderPass() {
529
662
  depthRef.attachment = 1;
530
663
  depthRef.layout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;
531
664
 
665
+ VkAttachmentReference resolveRef{};
666
+ resolveRef.attachment = 2;
667
+ resolveRef.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
668
+
532
669
  VkSubpassDescription subpass{};
533
670
  subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
534
671
  subpass.colorAttachmentCount = 1;
535
672
  subpass.pColorAttachments = &colorRef;
536
673
  subpass.pDepthStencilAttachment = &depthRef;
674
+ if (msaa) subpass.pResolveAttachments = &resolveRef;
537
675
 
538
676
  VkSubpassDependency dependency{};
539
677
  dependency.srcSubpass = VK_SUBPASS_EXTERNAL;
@@ -546,7 +684,8 @@ void VulkanBackend::createRenderPass() {
546
684
  dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT |
547
685
  VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
548
686
 
549
- std::array<VkAttachmentDescription, 2> attachments = {colorAttach, depthAttach};
687
+ std::vector<VkAttachmentDescription> attachments = {colorAttach, depthAttach};
688
+ if (msaa) attachments.push_back(resolveAttach);
550
689
 
551
690
  VkRenderPassCreateInfo rpInfo{};
552
691
  rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
@@ -573,7 +712,7 @@ void VulkanBackend::createDepthResources() {
573
712
  imgInfo.extent.depth = 1;
574
713
  imgInfo.mipLevels = 1;
575
714
  imgInfo.arrayLayers = 1;
576
- imgInfo.samples = VK_SAMPLE_COUNT_1_BIT;
715
+ imgInfo.samples = sampleCount_;
577
716
  imgInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
578
717
  imgInfo.usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT;
579
718
  VK_CHECK(vkCreateImage(device_, &imgInfo, nullptr, &depthImage_));
@@ -602,10 +741,65 @@ void VulkanBackend::createDepthResources() {
602
741
  VK_CHECK(vkCreateImageView(device_, &viewInfo, nullptr, &depthImageView_));
603
742
  }
604
743
 
744
+ void VulkanBackend::createMsaaResources() {
745
+ if (msaaColorView_) vkDestroyImageView(device_, msaaColorView_, nullptr);
746
+ if (msaaColorImage_) vkDestroyImage(device_, msaaColorImage_, nullptr);
747
+ if (msaaColorMemory_) vkFreeMemory(device_, msaaColorMemory_, nullptr);
748
+
749
+ VkImageCreateInfo imgInfo{};
750
+ imgInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
751
+ imgInfo.imageType = VK_IMAGE_TYPE_2D;
752
+ imgInfo.format = swapchainFormat_;
753
+ imgInfo.extent = {swapchainExtent_.width, swapchainExtent_.height, 1};
754
+ imgInfo.mipLevels = 1;
755
+ imgInfo.arrayLayers = 1;
756
+ imgInfo.samples = sampleCount_;
757
+ imgInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
758
+ imgInfo.usage = VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT |
759
+ VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT;
760
+ imgInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
761
+ VK_CHECK(vkCreateImage(device_, &imgInfo, nullptr, &msaaColorImage_));
762
+
763
+ VkMemoryRequirements memReqs;
764
+ vkGetImageMemoryRequirements(device_, msaaColorImage_, &memReqs);
765
+
766
+ // Prefer LAZILY_ALLOCATED for the transient MSAA target — on tiled mobile
767
+ // GPUs this avoids actually allocating physical memory.
768
+ bool foundLazy = false;
769
+ uint32_t typeIdx = findMemoryType(
770
+ memReqs.memoryTypeBits,
771
+ VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT, &foundLazy);
772
+ if (!foundLazy) {
773
+ typeIdx = findMemoryType(memReqs.memoryTypeBits,
774
+ VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
775
+ }
776
+
777
+ VkMemoryAllocateInfo allocInfo{};
778
+ allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
779
+ allocInfo.allocationSize = memReqs.size;
780
+ allocInfo.memoryTypeIndex = typeIdx;
781
+ VK_CHECK(vkAllocateMemory(device_, &allocInfo, nullptr, &msaaColorMemory_));
782
+ VK_CHECK(vkBindImageMemory(device_, msaaColorImage_, msaaColorMemory_, 0));
783
+
784
+ VkImageViewCreateInfo viewInfo{};
785
+ viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
786
+ viewInfo.image = msaaColorImage_;
787
+ viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D;
788
+ viewInfo.format = swapchainFormat_;
789
+ viewInfo.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
790
+ VK_CHECK(vkCreateImageView(device_, &viewInfo, nullptr, &msaaColorView_));
791
+ }
792
+
605
793
  void VulkanBackend::createFramebuffers() {
606
794
  framebuffers_.resize(swapchainImageViews_.size());
795
+ const bool msaa = (sampleCount_ != VK_SAMPLE_COUNT_1_BIT);
607
796
  for (size_t i = 0; i < swapchainImageViews_.size(); ++i) {
608
- std::array<VkImageView, 2> attachments = {swapchainImageViews_[i], depthImageView_};
797
+ std::vector<VkImageView> attachments;
798
+ if (msaa) {
799
+ attachments = {msaaColorView_, depthImageView_, swapchainImageViews_[i]};
800
+ } else {
801
+ attachments = {swapchainImageViews_[i], depthImageView_};
802
+ }
609
803
 
610
804
  VkFramebufferCreateInfo fbInfo{};
611
805
  fbInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
@@ -622,9 +816,6 @@ void VulkanBackend::createFramebuffers() {
622
816
  void VulkanBackend::createCommandPool() {
623
817
  VkCommandPoolCreateInfo poolInfo{};
624
818
  poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
625
- // RESET: per-buffer reset for frame command buffers.
626
- // TRANSIENT: hint allocator to prefer fast short-lived allocations (benefits
627
- // the one-time staging uploads that share this pool).
628
819
  poolInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT |
629
820
  VK_COMMAND_POOL_CREATE_TRANSIENT_BIT;
630
821
  poolInfo.queueFamilyIndex = graphicsFamily_;
@@ -639,11 +830,17 @@ void VulkanBackend::createCommandBuffers() {
639
830
  allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
640
831
  allocInfo.commandBufferCount = static_cast<uint32_t>(commandBuffers_.size());
641
832
  VK_CHECK(vkAllocateCommandBuffers(device_, &allocInfo, commandBuffers_.data()));
833
+
834
+ // Separate command buffers for upload (one per frame in flight).
835
+ uploadCommandBuffers_.resize(kMaxFramesInFlight);
836
+ allocInfo.commandBufferCount = static_cast<uint32_t>(uploadCommandBuffers_.size());
837
+ VK_CHECK(vkAllocateCommandBuffers(device_, &allocInfo, uploadCommandBuffers_.data()));
642
838
  }
643
839
 
644
840
  void VulkanBackend::createSyncObjects() {
645
841
  imageAvailableSemaphores_.resize(kMaxFramesInFlight);
646
- renderFinishedSemaphores_.resize(kMaxFramesInFlight);
842
+ // renderFinishedSemaphores_ will be created after swapchain creation
843
+ // since we need to know swapchainImages_.size()
647
844
  inFlightFences_.resize(kMaxFramesInFlight);
648
845
 
649
846
  VkSemaphoreCreateInfo semInfo{};
@@ -655,16 +852,64 @@ void VulkanBackend::createSyncObjects() {
655
852
 
656
853
  for (int i = 0; i < kMaxFramesInFlight; ++i) {
657
854
  VK_CHECK(vkCreateSemaphore(device_, &semInfo, nullptr, &imageAvailableSemaphores_[i]));
658
- VK_CHECK(vkCreateSemaphore(device_, &semInfo, nullptr, &renderFinishedSemaphores_[i]));
659
855
  VK_CHECK(vkCreateFence(device_, &fenceInfo, nullptr, &inFlightFences_[i]));
856
+ VK_CHECK(vkCreateFence(device_, &fenceInfo, nullptr, &uploadFences_[i]));
857
+ }
858
+ // renderFinishedSemaphores_ created in beginFrame after swapchain is ready
859
+ }
860
+
861
+ // ── Pipeline cache (P1-vk-pipeline-cache) ───────────────────────────────────────
862
+
863
+ namespace {
864
+ std::string pipelineCachePath(const std::string& cacheDir) {
865
+ if (cacheDir.empty()) return std::string();
866
+ return cacheDir + "/cesium_pipeline.cache";
867
+ }
868
+ } // namespace
869
+
870
+ void VulkanBackend::createPipelineCache() {
871
+ std::vector<uint8_t> initial;
872
+ const std::string path = pipelineCachePath(cacheDir_);
873
+ if (!path.empty()) {
874
+ std::ifstream f(path, std::ios::binary | std::ios::ate);
875
+ if (f.is_open()) {
876
+ const std::streamsize size = f.tellg();
877
+ if (size > 0) {
878
+ initial.resize(static_cast<size_t>(size));
879
+ f.seekg(0, std::ios::beg);
880
+ f.read(reinterpret_cast<char*>(initial.data()), size);
881
+ }
882
+ }
660
883
  }
884
+
885
+ VkPipelineCacheCreateInfo info{};
886
+ info.sType = VK_STRUCTURE_TYPE_PIPELINE_CACHE_CREATE_INFO;
887
+ info.initialDataSize = initial.size();
888
+ info.pInitialData = initial.empty() ? nullptr : initial.data();
889
+ VK_CHECK(vkCreatePipelineCache(device_, &info, nullptr, &pipelineCache_));
661
890
  }
662
891
 
663
- // ── Descriptor Layouts + Pipeline Layouts ──────────────────────────────────────
892
+ void VulkanBackend::persistPipelineCache() {
893
+ if (!pipelineCache_) return;
894
+ const std::string path = pipelineCachePath(cacheDir_);
895
+ if (path.empty()) return;
896
+
897
+ size_t size = 0;
898
+ if (vkGetPipelineCacheData(device_, pipelineCache_, &size, nullptr) != VK_SUCCESS) return;
899
+ if (size == 0) return;
900
+
901
+ std::vector<uint8_t> data(size);
902
+ if (vkGetPipelineCacheData(device_, pipelineCache_, &size, data.data()) != VK_SUCCESS) return;
903
+
904
+ std::ofstream f(path, std::ios::binary | std::ios::trunc);
905
+ if (!f.is_open()) return;
906
+ f.write(reinterpret_cast<const char*>(data.data()),
907
+ static_cast<std::streamsize>(size));
908
+ }
909
+
910
+ // ── Descriptor / pipeline layouts ───────────────────────────────────────────────
664
911
 
665
912
  void VulkanBackend::createDescriptorSetLayout() {
666
- // Terrain set 0: UBO at binding 0 (fragment only — contains just cameraEcef).
667
- // Vertex MVP is now in push constants; vertex stage no longer needs the UBO.
668
913
  VkDescriptorSetLayoutBinding uboBinding{};
669
914
  uboBinding.binding = 0;
670
915
  uboBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
@@ -677,7 +922,6 @@ void VulkanBackend::createDescriptorSetLayout() {
677
922
  uboLayoutInfo.pBindings = &uboBinding;
678
923
  VK_CHECK(vkCreateDescriptorSetLayout(device_, &uboLayoutInfo, nullptr, &terrainDescSetLayout_));
679
924
 
680
- // Terrain set 1: combined image sampler at binding 0 (fragment only)
681
925
  VkDescriptorSetLayoutBinding samplerBinding{};
682
926
  samplerBinding.binding = 0;
683
927
  samplerBinding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
@@ -690,7 +934,6 @@ void VulkanBackend::createDescriptorSetLayout() {
690
934
  texLayoutInfo.pBindings = &samplerBinding;
691
935
  VK_CHECK(vkCreateDescriptorSetLayout(device_, &texLayoutInfo, nullptr, &terrainTexLayout_));
692
936
 
693
- // Sky: binding 0 = UBO only
694
937
  VkDescriptorSetLayoutBinding skyUboBinding{};
695
938
  skyUboBinding.binding = 0;
696
939
  skyUboBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
@@ -705,33 +948,27 @@ void VulkanBackend::createDescriptorSetLayout() {
705
948
  }
706
949
 
707
950
  void VulkanBackend::createPipelineLayout() {
708
- // Two push constant ranges covering the 128-byte block (the Vulkan spec
709
- // minimum guarantee): vertex reads bytes 0-63 (RTC MVP matrix), fragment
710
- // reads bytes 64-127 (overlay + water mask + RTC centre params).
711
- VkPushConstantRange pushRanges[2]{};
712
- pushRanges[0].stageFlags = VK_SHADER_STAGE_VERTEX_BIT;
713
- pushRanges[0].offset = 0;
714
- pushRanges[0].size = sizeof(TerrainVertexPC); // 64 bytes
715
-
716
- pushRanges[1].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
717
- pushRanges[1].offset = sizeof(TerrainVertexPC); // 64
718
- pushRanges[1].size = sizeof(TerrainFragmentPC); // 64 bytes
951
+ // P2-arg-buffers: one combined push range over both stages, so we issue a
952
+ // single vkCmdPushConstants per draw instead of two. The Vulkan spec
953
+ // allows multi-stage ranges and the GLSL/Metal shaders read the same
954
+ // block layout regardless.
955
+ VkPushConstantRange pushRange{};
956
+ pushRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT;
957
+ pushRange.offset = 0;
958
+ pushRange.size = sizeof(TerrainVertexPC) + sizeof(TerrainFragmentPC); // 128 bytes
719
959
 
720
960
  VkDescriptorSetLayout terrainSetLayouts[] = {
721
- terrainDescSetLayout_, // set 0: UBO (cameraEcef, fragment only)
722
- terrainTexLayout_, // set 1: imagery overlay
723
- terrainTexLayout_, // set 2: water mask (same layout, different pool)
961
+ terrainDescSetLayout_, terrainTexLayout_, terrainTexLayout_,
724
962
  };
725
963
 
726
964
  VkPipelineLayoutCreateInfo terrainLayoutInfo{};
727
965
  terrainLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
728
966
  terrainLayoutInfo.setLayoutCount = 3;
729
967
  terrainLayoutInfo.pSetLayouts = terrainSetLayouts;
730
- terrainLayoutInfo.pushConstantRangeCount = 2;
731
- terrainLayoutInfo.pPushConstantRanges = pushRanges;
968
+ terrainLayoutInfo.pushConstantRangeCount = 1;
969
+ terrainLayoutInfo.pPushConstantRanges = &pushRange;
732
970
  VK_CHECK(vkCreatePipelineLayout(device_, &terrainLayoutInfo, nullptr, &terrainPipelineLayout_));
733
971
 
734
- // Sky: no push constants
735
972
  VkPipelineLayoutCreateInfo skyLayoutInfo{};
736
973
  skyLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
737
974
  skyLayoutInfo.setLayoutCount = 1;
@@ -740,8 +977,6 @@ void VulkanBackend::createPipelineLayout() {
740
977
  }
741
978
 
742
979
  void VulkanBackend::createDescriptorPool() {
743
- // UBO sets: kMaxFramesInFlight terrain + 1 sky
744
- // Sampler sets: 1 fallback + up to kMaxRasterTextures raster overlay textures
745
980
  std::array<VkDescriptorPoolSize, 2> poolSizes{};
746
981
  poolSizes[0].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
747
982
  poolSizes[0].descriptorCount = static_cast<uint32_t>(kMaxFramesInFlight + 1);
@@ -758,8 +993,6 @@ void VulkanBackend::createDescriptorPool() {
758
993
  }
759
994
 
760
995
  void VulkanBackend::createWaterMaskPool() {
761
- // Separate pool for water mask descriptor sets (set 2).
762
- // One per tile (up to kMaxRasterTextures) + 1 for the fallback.
763
996
  VkDescriptorPoolSize poolSize{};
764
997
  poolSize.type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
765
998
  poolSize.descriptorCount = static_cast<uint32_t>(kMaxRasterTextures + 1);
@@ -773,87 +1006,31 @@ void VulkanBackend::createWaterMaskPool() {
773
1006
  VK_CHECK(vkCreateDescriptorPool(device_, &poolInfo, nullptr, &waterMaskPool_));
774
1007
  }
775
1008
 
776
- void VulkanBackend::createWaterMaskFallback() {
777
- // 1×1 all-zero (land) fallback for tiles that carry no water mask texture.
778
- // fwidth() on a uniform constant returns 0, so the coastline formula yields
779
- // coastA = 0 and no outline is drawn — no special-casing needed in the shader.
780
- const uint8_t zero[4] = {0, 0, 0, 255};
781
-
782
- VkImageCreateInfo imgInfo{};
783
- imgInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
784
- imgInfo.imageType = VK_IMAGE_TYPE_2D;
785
- imgInfo.format = VK_FORMAT_R8G8B8A8_UNORM;
786
- imgInfo.extent = {1, 1, 1};
787
- imgInfo.mipLevels = 1;
788
- imgInfo.arrayLayers = 1;
789
- imgInfo.samples = VK_SAMPLE_COUNT_1_BIT;
790
- imgInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
791
- imgInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
792
- imgInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
793
- VK_CHECK(vkCreateImage(device_, &imgInfo, nullptr, &fallbackWaterMaskTex_.image));
794
-
795
- VkMemoryRequirements memReqs;
796
- vkGetImageMemoryRequirements(device_, fallbackWaterMaskTex_.image, &memReqs);
797
-
798
- VkMemoryAllocateInfo allocInfo{};
799
- allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
800
- allocInfo.allocationSize = memReqs.size;
801
- allocInfo.memoryTypeIndex = findMemoryType(memReqs.memoryTypeBits,
802
- VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
803
- VK_CHECK(vkAllocateMemory(device_, &allocInfo, nullptr, &fallbackWaterMaskTex_.memory));
804
- VK_CHECK(vkBindImageMemory(device_, fallbackWaterMaskTex_.image, fallbackWaterMaskTex_.memory, 0));
805
-
806
- VkBuffer staging;
807
- VkDeviceMemory stagingMem;
808
- createBuffer(4, VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
809
- VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
810
- staging, stagingMem);
811
- void* mapped;
812
- vkMapMemory(device_, stagingMem, 0, 4, 0, &mapped);
813
- memcpy(mapped, zero, 4);
814
- vkUnmapMemory(device_, stagingMem);
815
- uploadToImage(staging, fallbackWaterMaskTex_.image, 1, 1);
816
- vkDestroyBuffer(device_, staging, nullptr);
817
- vkFreeMemory(device_, stagingMem, nullptr);
1009
+ // ── Fallback textures (synchronous path; only used at init) ────────────────────
818
1010
 
819
- VkImageViewCreateInfo viewInfo{};
820
- viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
821
- viewInfo.image = fallbackWaterMaskTex_.image;
822
- viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D;
823
- viewInfo.format = VK_FORMAT_R8G8B8A8_UNORM;
824
- viewInfo.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
825
- VK_CHECK(vkCreateImageView(device_, &viewInfo, nullptr, &fallbackWaterMaskTex_.imageView));
1011
+ void VulkanBackend::createFallbackTexture() {
1012
+ const uint8_t white[4] = {255, 255, 255, 255};
1013
+ VulkanTexture* t = createImageFromPixels(white, 1, 1,
1014
+ descriptorPool_,
1015
+ VK_SAMPLE_COUNT_1_BIT,
1016
+ /*wantMipmaps=*/false);
1017
+ if (t) {
1018
+ fallbackTexture_ = *t;
1019
+ fallbackTexturePtr_ = t; // Keep alive - pending upload references this!
1020
+ }
1021
+ }
826
1022
 
827
- VkSamplerCreateInfo samplerInfo{};
828
- samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO;
829
- samplerInfo.magFilter = VK_FILTER_LINEAR;
830
- samplerInfo.minFilter = VK_FILTER_LINEAR;
831
- samplerInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
832
- samplerInfo.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
833
- samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
834
- VK_CHECK(vkCreateSampler(device_, &samplerInfo, nullptr, &fallbackWaterMaskTex_.sampler));
835
-
836
- // Allocate a descriptor set from the water mask pool.
837
- VkDescriptorSetAllocateInfo dsAlloc{};
838
- dsAlloc.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
839
- dsAlloc.descriptorPool = waterMaskPool_;
840
- dsAlloc.descriptorSetCount = 1;
841
- dsAlloc.pSetLayouts = &terrainTexLayout_;
842
- VK_CHECK(vkAllocateDescriptorSets(device_, &dsAlloc, &fallbackWaterMaskDescSet_));
843
-
844
- VkDescriptorImageInfo dsImgInfo{};
845
- dsImgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
846
- dsImgInfo.imageView = fallbackWaterMaskTex_.imageView;
847
- dsImgInfo.sampler = fallbackWaterMaskTex_.sampler;
848
-
849
- VkWriteDescriptorSet texWrite{};
850
- texWrite.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
851
- texWrite.dstSet = fallbackWaterMaskDescSet_;
852
- texWrite.dstBinding = 0;
853
- texWrite.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
854
- texWrite.descriptorCount = 1;
855
- texWrite.pImageInfo = &dsImgInfo;
856
- vkUpdateDescriptorSets(device_, 1, &texWrite, 0, nullptr);
1023
+ void VulkanBackend::createWaterMaskFallback() {
1024
+ const uint8_t zero[4] = {0, 0, 0, 255};
1025
+ VulkanTexture* t = createImageFromPixels(zero, 1, 1,
1026
+ waterMaskPool_,
1027
+ VK_SAMPLE_COUNT_1_BIT,
1028
+ /*wantMipmaps=*/false);
1029
+ if (t) {
1030
+ fallbackWaterMaskTex_ = *t;
1031
+ fallbackWaterMaskDescSet_ = t->descriptorSet;
1032
+ fallbackWaterMaskTexPtr_ = t; // Keep alive - pending upload references this!
1033
+ }
857
1034
  }
858
1035
 
859
1036
  // ── Shader compilation ─────────────────────────────────────────────────────────
@@ -869,25 +1046,45 @@ static VkShaderModule createShaderModule(VkDevice device, const uint32_t* code,
869
1046
  return module;
870
1047
  }
871
1048
 
872
- // ── Pipeline creation ──────────────────────────────────────────────────────────
873
-
874
- void VulkanBackend::createSkyPipeline() {
875
- VkShaderModule vertModule = createShaderModule(device_, spv_sky_vert, spv_sky_vert_size);
876
- VkShaderModule fragModule = createShaderModule(device_, spv_sky_frag, spv_sky_frag_size);
1049
+ // ── Pipeline creation (deduped via a small builder, P2-pipelines-dedupe) ──────
1050
+
1051
+ namespace {
1052
+
1053
+ struct PipelineBuildSpec {
1054
+ VkShaderModule vert;
1055
+ VkShaderModule frag;
1056
+ // Vertex input description — empty for sky.
1057
+ uint32_t bindingCount;
1058
+ const VkVertexInputBindingDescription* bindings;
1059
+ uint32_t attributeCount;
1060
+ const VkVertexInputAttributeDescription* attributes;
1061
+ bool depthTest;
1062
+ bool depthWrite;
1063
+ VkCullModeFlags cull;
1064
+ VkSampleCountFlagBits samples;
1065
+ };
877
1066
 
1067
+ VkPipeline buildPipeline(VkDevice device,
1068
+ VkPipelineLayout layout,
1069
+ VkRenderPass renderPass,
1070
+ VkPipelineCache cache,
1071
+ const PipelineBuildSpec& spec) {
878
1072
  VkPipelineShaderStageCreateInfo stages[2]{};
879
1073
  stages[0].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
880
1074
  stages[0].stage = VK_SHADER_STAGE_VERTEX_BIT;
881
- stages[0].module = vertModule;
1075
+ stages[0].module = spec.vert;
882
1076
  stages[0].pName = "main";
883
1077
  stages[1].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
884
1078
  stages[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT;
885
- stages[1].module = fragModule;
1079
+ stages[1].module = spec.frag;
886
1080
  stages[1].pName = "main";
887
1081
 
888
- // No vertex input (fullscreen triangle from gl_VertexIndex)
889
1082
  VkPipelineVertexInputStateCreateInfo vertexInput{};
890
- vertexInput.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
1083
+ vertexInput.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
1084
+ vertexInput.vertexBindingDescriptionCount = spec.bindingCount;
1085
+ vertexInput.pVertexBindingDescriptions = spec.bindings;
1086
+ vertexInput.vertexAttributeDescriptionCount = spec.attributeCount;
1087
+ vertexInput.pVertexAttributeDescriptions = spec.attributes;
891
1088
 
892
1089
  VkPipelineInputAssemblyStateCreateInfo inputAssembly{};
893
1090
  inputAssembly.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
@@ -902,17 +1099,18 @@ void VulkanBackend::createSkyPipeline() {
902
1099
  rasterizer.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
903
1100
  rasterizer.polygonMode = VK_POLYGON_MODE_FILL;
904
1101
  rasterizer.lineWidth = 1.0f;
905
- rasterizer.cullMode = VK_CULL_MODE_NONE;
1102
+ rasterizer.cullMode = spec.cull;
906
1103
  rasterizer.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;
907
1104
 
908
1105
  VkPipelineMultisampleStateCreateInfo multisampling{};
909
1106
  multisampling.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
910
- multisampling.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;
1107
+ multisampling.rasterizationSamples = spec.samples;
911
1108
 
912
1109
  VkPipelineDepthStencilStateCreateInfo depthStencil{};
913
1110
  depthStencil.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO;
914
- depthStencil.depthTestEnable = VK_FALSE;
915
- depthStencil.depthWriteEnable = VK_FALSE;
1111
+ depthStencil.depthTestEnable = spec.depthTest ? VK_TRUE : VK_FALSE;
1112
+ depthStencil.depthWriteEnable = spec.depthWrite ? VK_TRUE : VK_FALSE;
1113
+ depthStencil.depthCompareOp = VK_COMPARE_OP_GREATER; // Reversed-Z
916
1114
 
917
1115
  VkPipelineColorBlendAttachmentState colorBlendAttach{};
918
1116
  colorBlendAttach.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT |
@@ -941,10 +1139,34 @@ void VulkanBackend::createSkyPipeline() {
941
1139
  pipelineInfo.pDepthStencilState = &depthStencil;
942
1140
  pipelineInfo.pColorBlendState = &colorBlend;
943
1141
  pipelineInfo.pDynamicState = &dynamicState;
944
- pipelineInfo.layout = skyPipelineLayout_;
945
- pipelineInfo.renderPass = renderPass_;
1142
+ pipelineInfo.layout = layout;
1143
+ pipelineInfo.renderPass = renderPass;
946
1144
  pipelineInfo.subpass = 0;
947
- VK_CHECK(vkCreateGraphicsPipelines(device_, VK_NULL_HANDLE, 1, &pipelineInfo, nullptr, &skyPipeline_));
1145
+
1146
+ VkPipeline result = VK_NULL_HANDLE;
1147
+ VK_CHECK(vkCreateGraphicsPipelines(device, cache, 1, &pipelineInfo, nullptr, &result));
1148
+ return result;
1149
+ }
1150
+
1151
+ } // namespace
1152
+
1153
+ void VulkanBackend::createSkyPipeline() {
1154
+ VkShaderModule vertModule = createShaderModule(device_, spv_sky_vert, spv_sky_vert_size);
1155
+ VkShaderModule fragModule = createShaderModule(device_, spv_sky_frag, spv_sky_frag_size);
1156
+
1157
+ PipelineBuildSpec spec{};
1158
+ spec.vert = vertModule;
1159
+ spec.frag = fragModule;
1160
+ spec.bindingCount = 0;
1161
+ spec.bindings = nullptr;
1162
+ spec.attributeCount = 0;
1163
+ spec.attributes = nullptr;
1164
+ spec.depthTest = false;
1165
+ spec.depthWrite = false;
1166
+ spec.cull = VK_CULL_MODE_NONE;
1167
+ spec.samples = sampleCount_;
1168
+ skyPipeline_ = buildPipeline(device_, skyPipelineLayout_, renderPass_,
1169
+ pipelineCache_, spec);
948
1170
 
949
1171
  vkDestroyShaderModule(device_, vertModule, nullptr);
950
1172
  vkDestroyShaderModule(device_, fragModule, nullptr);
@@ -954,154 +1176,117 @@ void VulkanBackend::createTerrainPipeline() {
954
1176
  VkShaderModule vertModule = createShaderModule(device_, spv_terrain_vert, spv_terrain_vert_size);
955
1177
  VkShaderModule fragModule = createShaderModule(device_, spv_terrain_frag, spv_terrain_frag_size);
956
1178
 
957
- VkPipelineShaderStageCreateInfo stages[2]{};
958
- stages[0].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
959
- stages[0].stage = VK_SHADER_STAGE_VERTEX_BIT;
960
- stages[0].module = vertModule;
961
- stages[0].pName = "main";
962
- stages[1].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
963
- stages[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT;
964
- stages[1].module = fragModule;
965
- stages[1].pName = "main";
966
-
967
- // Vertex bindings:
968
- // binding 0 — position (float3, tile-local)
969
- // binding 1 — UV (float2)
970
- // binding 2 — altitude (float1, ellipsoid height metres, double-precision source)
971
- VkVertexInputBindingDescription bindings[3]{};
972
- bindings[0].binding = 0;
973
- bindings[0].stride = 3 * sizeof(float);
974
- bindings[0].inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
975
- bindings[1].binding = 1;
976
- bindings[1].stride = 2 * sizeof(float);
977
- bindings[1].inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
978
- bindings[2].binding = 2;
979
- bindings[2].stride = sizeof(float);
980
- bindings[2].inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
981
-
982
- VkVertexInputAttributeDescription attrs[3]{};
983
- attrs[0].binding = 0;
984
- attrs[0].location = 0;
985
- attrs[0].format = VK_FORMAT_R32G32B32_SFLOAT;
986
- attrs[0].offset = 0;
987
- attrs[1].binding = 1;
988
- attrs[1].location = 1;
989
- attrs[1].format = VK_FORMAT_R32G32_SFLOAT;
990
- attrs[1].offset = 0;
991
- attrs[2].binding = 2;
992
- attrs[2].location = 2;
993
- attrs[2].format = VK_FORMAT_R32_SFLOAT;
994
- attrs[2].offset = 0;
995
-
996
- VkPipelineVertexInputStateCreateInfo vertexInput{};
997
- vertexInput.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
998
- vertexInput.vertexBindingDescriptionCount = 3;
999
- vertexInput.pVertexBindingDescriptions = bindings;
1000
- vertexInput.vertexAttributeDescriptionCount = 3;
1001
- vertexInput.pVertexAttributeDescriptions = attrs;
1002
-
1003
- VkPipelineInputAssemblyStateCreateInfo inputAssembly{};
1004
- inputAssembly.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
1005
- inputAssembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
1006
-
1007
- VkPipelineViewportStateCreateInfo viewportState{};
1008
- viewportState.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;
1009
- viewportState.viewportCount = 1;
1010
- viewportState.scissorCount = 1;
1011
-
1012
- VkPipelineRasterizationStateCreateInfo rasterizer{};
1013
- rasterizer.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
1014
- rasterizer.polygonMode = VK_POLYGON_MODE_FILL;
1015
- rasterizer.lineWidth = 1.0f;
1016
- rasterizer.cullMode = VK_CULL_MODE_BACK_BIT;
1017
- rasterizer.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;
1018
-
1019
- VkPipelineMultisampleStateCreateInfo multisampling{};
1020
- multisampling.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
1021
- multisampling.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;
1022
-
1023
- // Reversed-Z: depth clear = 0.0 (far), compare = GREATER (closer wins)
1024
- VkPipelineDepthStencilStateCreateInfo depthStencil{};
1025
- depthStencil.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO;
1026
- depthStencil.depthTestEnable = VK_TRUE;
1027
- depthStencil.depthWriteEnable = VK_TRUE;
1028
- depthStencil.depthCompareOp = VK_COMPARE_OP_GREATER;
1029
-
1030
- VkPipelineColorBlendAttachmentState colorBlendAttach{};
1031
- colorBlendAttach.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT |
1032
- VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT;
1033
-
1034
- VkPipelineColorBlendStateCreateInfo colorBlend{};
1035
- colorBlend.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO;
1036
- colorBlend.attachmentCount = 1;
1037
- colorBlend.pAttachments = &colorBlendAttach;
1038
-
1039
- VkDynamicState dynStates[] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR};
1040
- VkPipelineDynamicStateCreateInfo dynamicState{};
1041
- dynamicState.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO;
1042
- dynamicState.dynamicStateCount = 2;
1043
- dynamicState.pDynamicStates = dynStates;
1179
+ // Vertex bindings: position (vec3) | uv (vec2) | altitude (float).
1180
+ static const VkVertexInputBindingDescription bindings[3] = {
1181
+ {0, 3 * sizeof(float), VK_VERTEX_INPUT_RATE_VERTEX},
1182
+ {1, 2 * sizeof(float), VK_VERTEX_INPUT_RATE_VERTEX},
1183
+ {2, sizeof(float), VK_VERTEX_INPUT_RATE_VERTEX},
1184
+ };
1185
+ static const VkVertexInputAttributeDescription attrs[3] = {
1186
+ {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0},
1187
+ {1, 1, VK_FORMAT_R32G32_SFLOAT, 0},
1188
+ {2, 2, VK_FORMAT_R32_SFLOAT, 0},
1189
+ };
1044
1190
 
1045
- VkGraphicsPipelineCreateInfo pipelineInfo{};
1046
- pipelineInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
1047
- pipelineInfo.stageCount = 2;
1048
- pipelineInfo.pStages = stages;
1049
- pipelineInfo.pVertexInputState = &vertexInput;
1050
- pipelineInfo.pInputAssemblyState = &inputAssembly;
1051
- pipelineInfo.pViewportState = &viewportState;
1052
- pipelineInfo.pRasterizationState = &rasterizer;
1053
- pipelineInfo.pMultisampleState = &multisampling;
1054
- pipelineInfo.pDepthStencilState = &depthStencil;
1055
- pipelineInfo.pColorBlendState = &colorBlend;
1056
- pipelineInfo.pDynamicState = &dynamicState;
1057
- pipelineInfo.layout = terrainPipelineLayout_;
1058
- pipelineInfo.renderPass = renderPass_;
1059
- pipelineInfo.subpass = 0;
1060
- VK_CHECK(vkCreateGraphicsPipelines(device_, VK_NULL_HANDLE, 1, &pipelineInfo, nullptr, &terrainPipeline_));
1191
+ PipelineBuildSpec spec{};
1192
+ spec.vert = vertModule;
1193
+ spec.frag = fragModule;
1194
+ spec.bindingCount = 3;
1195
+ spec.bindings = bindings;
1196
+ spec.attributeCount = 3;
1197
+ spec.attributes = attrs;
1198
+ spec.depthTest = true;
1199
+ spec.depthWrite = true;
1200
+ spec.cull = VK_CULL_MODE_BACK_BIT;
1201
+ spec.samples = sampleCount_;
1202
+ terrainPipeline_ = buildPipeline(device_, terrainPipelineLayout_, renderPass_,
1203
+ pipelineCache_, spec);
1061
1204
 
1062
1205
  vkDestroyShaderModule(device_, vertModule, nullptr);
1063
1206
  vkDestroyShaderModule(device_, fragModule, nullptr);
1064
1207
  }
1065
1208
 
1066
- // ── Helpers ────────────────────────────────────────────────────────────────────
1209
+ void VulkanBackend::createGraphicsPipelinesAll() {
1210
+ createSkyPipeline();
1211
+ createTerrainPipeline();
1212
+ }
1213
+
1214
+ void VulkanBackend::destroyGraphicsPipelinesAll() {
1215
+ if (terrainPipeline_) vkDestroyPipeline(device_, terrainPipeline_, nullptr);
1216
+ if (skyPipeline_) vkDestroyPipeline(device_, skyPipeline_, nullptr);
1217
+ terrainPipeline_ = skyPipeline_ = VK_NULL_HANDLE;
1218
+ }
1219
+
1220
+ // ── Memory / buffer helpers ─────────────────────────────────────────────────────
1067
1221
 
1068
- uint32_t VulkanBackend::findMemoryType(uint32_t typeFilter, VkMemoryPropertyFlags props) {
1222
+ uint32_t VulkanBackend::findMemoryType(uint32_t typeFilter,
1223
+ VkMemoryPropertyFlags props,
1224
+ bool* outFound) {
1069
1225
  VkPhysicalDeviceMemoryProperties memProps;
1070
1226
  vkGetPhysicalDeviceMemoryProperties(physicalDevice_, &memProps);
1071
1227
  for (uint32_t i = 0; i < memProps.memoryTypeCount; ++i) {
1072
1228
  if ((typeFilter & (1 << i)) &&
1073
1229
  (memProps.memoryTypes[i].propertyFlags & props) == props) {
1230
+ if (outFound) *outFound = true;
1074
1231
  return i;
1075
1232
  }
1076
1233
  }
1077
- LOGE("Failed to find suitable memory type");
1234
+ if (outFound) {
1235
+ *outFound = false;
1236
+ } else {
1237
+ LOGE("Failed to find suitable memory type (filter=0x%x props=0x%x)",
1238
+ typeFilter, props);
1239
+ }
1078
1240
  return 0;
1079
1241
  }
1080
1242
 
1081
- void VulkanBackend::createBuffer(VkDeviceSize size, VkBufferUsageFlags usage,
1082
- VkMemoryPropertyFlags props,
1083
- VkBuffer& buffer, VkDeviceMemory& memory) {
1243
+ bool VulkanBackend::createBuffer(VkDeviceSize size, VkBufferUsageFlags usage,
1244
+ VkMemoryPropertyFlags props,
1245
+ VkBuffer& buffer, VkDeviceMemory& memory) {
1084
1246
  VkBufferCreateInfo bufInfo{};
1085
1247
  bufInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
1086
1248
  bufInfo.size = size;
1087
1249
  bufInfo.usage = usage;
1088
1250
  bufInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
1089
- VK_CHECK(vkCreateBuffer(device_, &bufInfo, nullptr, &buffer));
1251
+ if (vkCreateBuffer(device_, &bufInfo, nullptr, &buffer) != VK_SUCCESS) {
1252
+ return false;
1253
+ }
1090
1254
 
1091
1255
  VkMemoryRequirements memReqs;
1092
1256
  vkGetBufferMemoryRequirements(device_, buffer, &memReqs);
1093
1257
 
1258
+ bool found = false;
1259
+ uint32_t typeIdx = findMemoryType(memReqs.memoryTypeBits, props, &found);
1260
+ if (!found) {
1261
+ LOGE("createBuffer: no compatible memory type for usage=0x%x props=0x%x",
1262
+ usage, props);
1263
+ vkDestroyBuffer(device_, buffer, nullptr);
1264
+ buffer = VK_NULL_HANDLE;
1265
+ return false;
1266
+ }
1267
+
1094
1268
  VkMemoryAllocateInfo allocInfo{};
1095
1269
  allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
1096
1270
  allocInfo.allocationSize = memReqs.size;
1097
- allocInfo.memoryTypeIndex = findMemoryType(memReqs.memoryTypeBits, props);
1098
- VK_CHECK(vkAllocateMemory(device_, &allocInfo, nullptr, &memory));
1099
- VK_CHECK(vkBindBufferMemory(device_, buffer, memory, 0));
1271
+ allocInfo.memoryTypeIndex = typeIdx;
1272
+ if (vkAllocateMemory(device_, &allocInfo, nullptr, &memory) != VK_SUCCESS) {
1273
+ vkDestroyBuffer(device_, buffer, nullptr);
1274
+ buffer = VK_NULL_HANDLE;
1275
+ return false;
1276
+ }
1277
+ if (vkBindBufferMemory(device_, buffer, memory, 0) != VK_SUCCESS) {
1278
+ vkDestroyBuffer(device_, buffer, nullptr);
1279
+ vkFreeMemory(device_, memory, nullptr);
1280
+ buffer = VK_NULL_HANDLE;
1281
+ memory = VK_NULL_HANDLE;
1282
+ return false;
1283
+ }
1284
+ return true;
1100
1285
  }
1101
1286
 
1102
1287
  void VulkanBackend::ensureBuffer(VkBuffer& buffer, VkDeviceMemory& memory,
1103
- size_t& capacity, size_t needed,
1104
- const void* data, VkBufferUsageFlags usage) {
1288
+ size_t& capacity, size_t needed,
1289
+ const void* data, VkBufferUsageFlags usage) {
1105
1290
  if (needed == 0) return;
1106
1291
  if (needed > capacity) {
1107
1292
  if (buffer) vkDestroyBuffer(device_, buffer, nullptr);
@@ -1113,8 +1298,8 @@ void VulkanBackend::ensureBuffer(VkBuffer& buffer, VkDeviceMemory& memory,
1113
1298
  buffer, memory);
1114
1299
  capacity = newCap;
1115
1300
  }
1116
- void* mapped;
1117
- vkMapMemory(device_, memory, 0, needed, 0, &mapped);
1301
+ void* mapped = nullptr;
1302
+ if (vkMapMemory(device_, memory, 0, needed, 0, &mapped) != VK_SUCCESS) return;
1118
1303
  memcpy(mapped, data, needed);
1119
1304
  vkUnmapMemory(device_, memory);
1120
1305
  }
@@ -1126,17 +1311,25 @@ VkCommandBuffer VulkanBackend::beginSingleTimeCommands() {
1126
1311
  allocInfo.commandPool = commandPool_;
1127
1312
  allocInfo.commandBufferCount = 1;
1128
1313
 
1129
- VkCommandBuffer cmd;
1130
- vkAllocateCommandBuffers(device_, &allocInfo, &cmd);
1314
+ VkCommandBuffer cmd = VK_NULL_HANDLE;
1315
+ if (vkAllocateCommandBuffers(device_, &allocInfo, &cmd) != VK_SUCCESS) {
1316
+ LOGE("beginSingleTimeCommands: vkAllocateCommandBuffers failed");
1317
+ return VK_NULL_HANDLE;
1318
+ }
1131
1319
 
1132
1320
  VkCommandBufferBeginInfo beginInfo{};
1133
1321
  beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
1134
1322
  beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
1135
- vkBeginCommandBuffer(cmd, &beginInfo);
1323
+ if (vkBeginCommandBuffer(cmd, &beginInfo) != VK_SUCCESS) {
1324
+ LOGE("beginSingleTimeCommands: vkBeginCommandBuffer failed");
1325
+ vkFreeCommandBuffers(device_, commandPool_, 1, &cmd);
1326
+ return VK_NULL_HANDLE;
1327
+ }
1136
1328
  return cmd;
1137
1329
  }
1138
1330
 
1139
- void VulkanBackend::endSingleTimeCommands(VkCommandBuffer cmd) {
1331
+ bool VulkanBackend::endSingleTimeCommands(VkCommandBuffer cmd) {
1332
+ if (cmd == VK_NULL_HANDLE) return false;
1140
1333
  vkEndCommandBuffer(cmd);
1141
1334
 
1142
1335
  VkSubmitInfo submitInfo{};
@@ -1144,175 +1337,409 @@ void VulkanBackend::endSingleTimeCommands(VkCommandBuffer cmd) {
1144
1337
  submitInfo.commandBufferCount = 1;
1145
1338
  submitInfo.pCommandBuffers = &cmd;
1146
1339
 
1147
- vkQueueSubmit(graphicsQueue_, 1, &submitInfo, VK_NULL_HANDLE);
1340
+ VkResult r = vkQueueSubmit(graphicsQueue_, 1, &submitInfo, VK_NULL_HANDLE);
1341
+ if (r != VK_SUCCESS) {
1342
+ LOGE("endSingleTimeCommands: vkQueueSubmit failed: %d", r);
1343
+ vkFreeCommandBuffers(device_, commandPool_, 1, &cmd);
1344
+ return false;
1345
+ }
1346
+ // We still wait here because this path is reserved for init / fallback. The
1347
+ // hot per-tile upload path uses the deferred staging ring instead.
1148
1348
  vkQueueWaitIdle(graphicsQueue_);
1149
1349
  vkFreeCommandBuffers(device_, commandPool_, 1, &cmd);
1350
+ return true;
1150
1351
  }
1151
1352
 
1152
- void VulkanBackend::uploadToImage(VkBuffer staging, VkImage image,
1153
- uint32_t width, uint32_t height) {
1154
- // All three steps (UNDEFINED→TRANSFER_DST, copy, TRANSFER_DST→SHADER_READ)
1155
- // are recorded in a single command buffer, so only one vkQueueWaitIdle is needed.
1156
- VkCommandBuffer cmd = beginSingleTimeCommands();
1157
-
1158
- const VkImageSubresourceRange range{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
1159
-
1160
- VkImageMemoryBarrier toTransfer{};
1161
- toTransfer.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
1162
- toTransfer.srcAccessMask = 0;
1163
- toTransfer.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
1164
- toTransfer.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED;
1165
- toTransfer.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
1166
- toTransfer.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
1167
- toTransfer.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
1168
- toTransfer.image = image;
1169
- toTransfer.subresourceRange = range;
1170
- vkCmdPipelineBarrier(cmd,
1171
- VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
1172
- 0, 0, nullptr, 0, nullptr, 1, &toTransfer);
1173
-
1174
- VkBufferImageCopy region{};
1175
- region.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
1176
- region.imageExtent = {width, height, 1};
1177
- vkCmdCopyBufferToImage(cmd, staging, image,
1178
- VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &region);
1179
-
1180
- VkImageMemoryBarrier toShader{};
1181
- toShader.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
1182
- toShader.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
1183
- toShader.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
1184
- toShader.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
1185
- toShader.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
1186
- toShader.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
1187
- toShader.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
1188
- toShader.image = image;
1189
- toShader.subresourceRange = range;
1190
- vkCmdPipelineBarrier(cmd,
1191
- VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
1192
- 0, 0, nullptr, 0, nullptr, 1, &toShader);
1193
-
1194
- endSingleTimeCommands(cmd);
1195
- }
1353
+ // ── Mipmap generation ──────────────────────────────────────────────────────────
1354
+
1355
+ void VulkanBackend::recordGenerateMipmaps(VkCommandBuffer cmd, VkImage image,
1356
+ uint32_t width, uint32_t height,
1357
+ uint32_t mipLevels) {
1358
+ // Pre-condition: every level is in TRANSFER_DST_OPTIMAL.
1359
+ // Post-condition: every level is in SHADER_READ_ONLY_OPTIMAL.
1360
+ int32_t mipW = static_cast<int32_t>(width);
1361
+ int32_t mipH = static_cast<int32_t>(height);
1362
+
1363
+ for (uint32_t i = 1; i < mipLevels; ++i) {
1364
+ // Transition source mip (i-1) to TRANSFER_SRC.
1365
+ VkImageMemoryBarrier toSrc{};
1366
+ toSrc.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
1367
+ toSrc.image = image;
1368
+ toSrc.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
1369
+ toSrc.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
1370
+ toSrc.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
1371
+ toSrc.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
1372
+ toSrc.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
1373
+ toSrc.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
1374
+ toSrc.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, i - 1, 1, 0, 1};
1375
+ vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT,
1376
+ VK_PIPELINE_STAGE_TRANSFER_BIT, 0,
1377
+ 0, nullptr, 0, nullptr, 1, &toSrc);
1378
+
1379
+ VkImageBlit blit{};
1380
+ blit.srcOffsets[0] = {0, 0, 0};
1381
+ blit.srcOffsets[1] = {mipW, mipH, 1};
1382
+ blit.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
1383
+ blit.srcSubresource.mipLevel = i - 1;
1384
+ blit.srcSubresource.baseArrayLayer = 0;
1385
+ blit.srcSubresource.layerCount = 1;
1386
+ blit.dstOffsets[0] = {0, 0, 0};
1387
+ blit.dstOffsets[1] = {std::max(1, mipW / 2), std::max(1, mipH / 2), 1};
1388
+ blit.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
1389
+ blit.dstSubresource.mipLevel = i;
1390
+ blit.dstSubresource.baseArrayLayer = 0;
1391
+ blit.dstSubresource.layerCount = 1;
1392
+ vkCmdBlitImage(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
1393
+ image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
1394
+ 1, &blit, VK_FILTER_LINEAR);
1395
+
1396
+ // Source mip (i-1) → SHADER_READ.
1397
+ VkImageMemoryBarrier toShader{};
1398
+ toShader.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
1399
+ toShader.image = image;
1400
+ toShader.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
1401
+ toShader.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
1402
+ toShader.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
1403
+ toShader.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
1404
+ toShader.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
1405
+ toShader.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
1406
+ toShader.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, i - 1, 1, 0, 1};
1407
+ vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT,
1408
+ VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0,
1409
+ 0, nullptr, 0, nullptr, 1, &toShader);
1410
+
1411
+ if (mipW > 1) mipW /= 2;
1412
+ if (mipH > 1) mipH /= 2;
1413
+ }
1196
1414
 
1197
- // ── Fallback texture (1x1 white) ───────────────────────────────────────────────
1415
+ // Last mip → SHADER_READ.
1416
+ VkImageMemoryBarrier last{};
1417
+ last.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
1418
+ last.image = image;
1419
+ last.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
1420
+ last.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
1421
+ last.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
1422
+ last.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
1423
+ last.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
1424
+ last.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
1425
+ last.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, mipLevels - 1, 1, 0, 1};
1426
+ vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT,
1427
+ VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0,
1428
+ 0, nullptr, 0, nullptr, 1, &last);
1429
+ }
1198
1430
 
1199
- void VulkanBackend::createFallbackTexture() {
1200
- const uint8_t white[4] = {255, 255, 255, 255};
1431
+ // ── Persistent staging ring ────────────────────────────────────────────────────
1201
1432
 
1202
- VkImageCreateInfo imgInfo{};
1203
- imgInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
1204
- imgInfo.imageType = VK_IMAGE_TYPE_2D;
1205
- imgInfo.format = VK_FORMAT_R8G8B8A8_UNORM;
1206
- imgInfo.extent = {1, 1, 1};
1207
- imgInfo.mipLevels = 1;
1208
- imgInfo.arrayLayers = 1;
1209
- imgInfo.samples = VK_SAMPLE_COUNT_1_BIT;
1210
- imgInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
1211
- imgInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
1212
- imgInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
1213
- VK_CHECK(vkCreateImage(device_, &imgInfo, nullptr, &fallbackTexture_.image));
1433
+ void VulkanBackend::initStagingRing(VkDeviceSize bytes) {
1434
+ if (!createBuffer(bytes, VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
1435
+ VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
1436
+ VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
1437
+ stagingBuffer_, stagingMemory_)) {
1438
+ LOGE("Staging ring allocation failed (size=%llu) — falling back to "
1439
+ "synchronous per-tile upload",
1440
+ static_cast<unsigned long long>(bytes));
1441
+ return;
1442
+ }
1443
+ void* mapped = nullptr;
1444
+ if (vkMapMemory(device_, stagingMemory_, 0, bytes, 0, &mapped) != VK_SUCCESS) {
1445
+ LOGE("Staging ring vkMapMemory failed");
1446
+ vkDestroyBuffer(device_, stagingBuffer_, nullptr);
1447
+ vkFreeMemory(device_, stagingMemory_, nullptr);
1448
+ stagingBuffer_ = VK_NULL_HANDLE;
1449
+ stagingMemory_ = VK_NULL_HANDLE;
1450
+ return;
1451
+ }
1452
+ stagingMapped_ = static_cast<uint8_t*>(mapped);
1453
+ stagingHead_ = 0;
1454
+ }
1214
1455
 
1215
- VkMemoryRequirements memReqs;
1216
- vkGetImageMemoryRequirements(device_, fallbackTexture_.image, &memReqs);
1456
+ VkDeviceSize VulkanBackend::allocateStagingSlice(VkDeviceSize size) {
1457
+ if (!stagingMapped_ || size == 0 || size > kStagingSize) return SIZE_MAX;
1458
+
1459
+ VkDeviceSize aligned = (size + kStagingAlignment - 1) & ~(kStagingAlignment - 1);
1460
+ VkDeviceSize off;
1461
+ if (stagingHead_ + aligned > kStagingSize) {
1462
+ // Wrap to the start of the ring. Anything still in flight from before the
1463
+ // wrap will keep its previous offset alive until its upload fence
1464
+ // signalled in beginFrame.
1465
+ off = 0;
1466
+ stagingHead_ = aligned;
1467
+ } else {
1468
+ off = stagingHead_;
1469
+ stagingHead_ += aligned;
1470
+ }
1471
+ return off;
1472
+ }
1217
1473
 
1218
- VkMemoryAllocateInfo allocInfo{};
1219
- allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
1220
- allocInfo.allocationSize = memReqs.size;
1221
- allocInfo.memoryTypeIndex = findMemoryType(memReqs.memoryTypeBits,
1222
- VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
1223
- VK_CHECK(vkAllocateMemory(device_, &allocInfo, nullptr, &fallbackTexture_.memory));
1224
- VK_CHECK(vkBindImageMemory(device_, fallbackTexture_.image, fallbackTexture_.memory, 0));
1474
+ void VulkanBackend::flushPendingUploads() {
1475
+ std::vector<PendingUpload> uploads;
1476
+ {
1477
+ std::lock_guard<std::mutex> lk(stagingMutex_);
1478
+ if (pendingUploadsCurrent_.empty()) {
1479
+ return;
1480
+ }
1481
+ uploads.swap(pendingUploadsCurrent_);
1482
+ }
1225
1483
 
1226
- VkBuffer staging;
1227
- VkDeviceMemory stagingMem;
1228
- createBuffer(4, VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
1229
- VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
1230
- staging, stagingMem);
1484
+ VkCommandBuffer cmd = uploadCommandBuffers_[frameIndex_];
1485
+ if (cmd == VK_NULL_HANDLE) {
1486
+ LOGE("flushPendingUploads: upload command buffer is NULL!");
1487
+ return;
1488
+ }
1489
+
1490
+ vkResetCommandBuffer(cmd, 0);
1491
+
1492
+ VkCommandBufferBeginInfo bi{};
1493
+ bi.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
1494
+ bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
1495
+ vkBeginCommandBuffer(cmd, &bi);
1496
+
1497
+ // Phase 1: batched UNDEFINED → TRANSFER_DST_OPTIMAL barriers.
1498
+ std::vector<VkImageMemoryBarrier> toTransfer;
1499
+ toTransfer.reserve(uploads.size());
1500
+ for (const auto& u : uploads) {
1501
+ // Defensive: mipLevels should be 1-15 for any reasonable texture.
1502
+ uint32_t safeMipLevels = u.tex->mipLevels;
1503
+ if (safeMipLevels == 0 || safeMipLevels > 16) {
1504
+ LOGE("flushPendingUploads: corrupt mipLevels=%u for tex=%p, clamping to 1",
1505
+ safeMipLevels, (void*)u.tex);
1506
+ safeMipLevels = 1;
1507
+ u.tex->mipLevels = 1;
1508
+ }
1509
+
1510
+ VkImageMemoryBarrier b{};
1511
+ b.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
1512
+ b.srcAccessMask = 0;
1513
+ b.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
1514
+ b.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED;
1515
+ b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
1516
+ b.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
1517
+ b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
1518
+ b.image = u.tex->image;
1519
+ b.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, safeMipLevels, 0, 1};
1520
+ toTransfer.push_back(b);
1521
+ }
1522
+ if (!toTransfer.empty()) {
1523
+ vkCmdPipelineBarrier(cmd,
1524
+ VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
1525
+ 0, 0, nullptr, 0, nullptr,
1526
+ static_cast<uint32_t>(toTransfer.size()), toTransfer.data());
1527
+ }
1231
1528
 
1232
- void* mapped;
1233
- vkMapMemory(device_, stagingMem, 0, 4, 0, &mapped);
1234
- memcpy(mapped, white, 4);
1235
- vkUnmapMemory(device_, stagingMem);
1529
+ // Phase 2: per-image vkCmdCopyBufferToImage from the staging ring.
1530
+ if (stagingBuffer_ == VK_NULL_HANDLE) {
1531
+ LOGE("flushPendingUploads: stagingBuffer_ is NULL! Aborting upload");
1532
+ vkEndCommandBuffer(cmd);
1533
+ return;
1534
+ }
1535
+ for (const auto& u : uploads) {
1536
+ VkBufferImageCopy region{};
1537
+ region.bufferOffset = u.stagingOffset;
1538
+ region.bufferRowLength = 0;
1539
+ region.bufferImageHeight = 0;
1540
+ region.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
1541
+ region.imageExtent = {u.width, u.height, 1};
1542
+ vkCmdCopyBufferToImage(cmd, stagingBuffer_, u.tex->image,
1543
+ VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &region);
1544
+ }
1236
1545
 
1237
- uploadToImage(staging, fallbackTexture_.image, 1, 1);
1546
+ // Phase 3: per-image mipmap chain (only for textures that asked for it) +
1547
+ // a batched TRANSFER_DST → SHADER_READ for the rest.
1548
+ std::vector<VkImageMemoryBarrier> toShader;
1549
+ toShader.reserve(uploads.size());
1550
+ for (const auto& u : uploads) {
1551
+ if (u.tex->mipLevels > 1) {
1552
+ recordGenerateMipmaps(cmd, u.tex->image, u.width, u.height,
1553
+ u.tex->mipLevels);
1554
+ } else {
1555
+ VkImageMemoryBarrier b{};
1556
+ b.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
1557
+ b.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
1558
+ b.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
1559
+ b.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
1560
+ b.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
1561
+ b.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
1562
+ b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
1563
+ b.image = u.tex->image;
1564
+ b.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
1565
+ toShader.push_back(b);
1566
+ }
1567
+ }
1568
+ if (!toShader.empty()) {
1569
+ vkCmdPipelineBarrier(cmd,
1570
+ VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
1571
+ 0, 0, nullptr, 0, nullptr,
1572
+ static_cast<uint32_t>(toShader.size()), toShader.data());
1573
+ }
1238
1574
 
1239
- vkDestroyBuffer(device_, staging, nullptr);
1240
- vkFreeMemory(device_, stagingMem, nullptr);
1575
+ vkEndCommandBuffer(cmd);
1241
1576
 
1242
- VkImageViewCreateInfo viewInfo{};
1243
- viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
1244
- viewInfo.image = fallbackTexture_.image;
1245
- viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D;
1246
- viewInfo.format = VK_FORMAT_R8G8B8A8_UNORM;
1247
- viewInfo.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
1248
- VK_CHECK(vkCreateImageView(device_, &viewInfo, nullptr, &fallbackTexture_.imageView));
1577
+ // SYNCHRONOUS submit and wait - simpler and more reliable than fence-based
1578
+ // batching. The performance hit is minimal since uploads are infrequent.
1579
+ VkSubmitInfo si{};
1580
+ si.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
1581
+ si.commandBufferCount = 1;
1582
+ si.pCommandBuffers = &cmd;
1583
+
1584
+ VkResult submitResult = vkQueueSubmit(graphicsQueue_, 1, &si, VK_NULL_HANDLE);
1585
+ if (submitResult != VK_SUCCESS) {
1586
+ LOGE("flushPendingUploads: vkQueueSubmit failed: %d", submitResult);
1587
+ return;
1588
+ }
1589
+
1590
+ vkQueueWaitIdle(graphicsQueue_);
1249
1591
 
1250
- VkSamplerCreateInfo samplerInfo{};
1251
- samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO;
1252
- samplerInfo.magFilter = VK_FILTER_LINEAR;
1253
- samplerInfo.minFilter = VK_FILTER_LINEAR;
1254
- samplerInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
1255
- samplerInfo.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
1256
- samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
1257
- VK_CHECK(vkCreateSampler(device_, &samplerInfo, nullptr, &fallbackTexture_.sampler));
1592
+ // Mark each texture as visible to subsequent draws (no longer pending).
1593
+ for (auto& u : uploads) u.tex->pendingUpload = false;
1258
1594
  }
1259
1595
 
1260
- // ── Raster texture creation/deletion ───────────────────────────────────────────
1596
+ // ── Generic image creator (P2-pipelines-dedupe) ─────────────────────────────
1261
1597
 
1262
- void* VulkanBackend::createRasterTexture(const uint8_t* pixels, int32_t width, int32_t height) {
1263
- if (!device_ || !pixels || width <= 0 || height <= 0) return nullptr;
1598
+ VulkanTexture* VulkanBackend::createImageFromPixels(const uint8_t* pixels,
1599
+ int32_t width, int32_t height,
1600
+ VkDescriptorPool pool,
1601
+ VkSampleCountFlagBits samples,
1602
+ bool wantMipmaps) {
1603
+ if (!device_ || !pixels || width <= 0 || height <= 0 || pool == VK_NULL_HANDLE)
1604
+ return nullptr;
1264
1605
 
1265
1606
  auto* tex = new VulkanTexture();
1266
- VkDeviceSize imageSize = static_cast<VkDeviceSize>(width) * height * 4;
1267
-
1268
- VkBuffer staging;
1269
- VkDeviceMemory stagingMem;
1270
- createBuffer(imageSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
1271
- VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
1272
- staging, stagingMem);
1273
-
1274
- void* mapped;
1275
- vkMapMemory(device_, stagingMem, 0, imageSize, 0, &mapped);
1276
- memcpy(mapped, pixels, static_cast<size_t>(imageSize));
1277
- vkUnmapMemory(device_, stagingMem);
1607
+ tex->width = static_cast<uint32_t>(width);
1608
+ tex->height = static_cast<uint32_t>(height);
1609
+ tex->mipLevels = wantMipmaps
1610
+ ? static_cast<uint32_t>(std::floor(std::log2(std::max(width, height)))) + 1u
1611
+ : 1u;
1278
1612
 
1279
1613
  VkImageCreateInfo imgInfo{};
1280
1614
  imgInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
1281
1615
  imgInfo.imageType = VK_IMAGE_TYPE_2D;
1282
1616
  imgInfo.format = VK_FORMAT_R8G8B8A8_UNORM;
1283
- imgInfo.extent = {static_cast<uint32_t>(width), static_cast<uint32_t>(height), 1};
1284
- imgInfo.mipLevels = 1;
1617
+ imgInfo.extent = {tex->width, tex->height, 1};
1618
+ imgInfo.mipLevels = tex->mipLevels;
1285
1619
  imgInfo.arrayLayers = 1;
1286
- imgInfo.samples = VK_SAMPLE_COUNT_1_BIT;
1620
+ imgInfo.samples = samples;
1287
1621
  imgInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
1288
- imgInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
1622
+ imgInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT |
1623
+ VK_IMAGE_USAGE_TRANSFER_SRC_BIT | // mipmap blit src
1624
+ VK_IMAGE_USAGE_SAMPLED_BIT;
1289
1625
  imgInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
1290
- VK_CHECK(vkCreateImage(device_, &imgInfo, nullptr, &tex->image));
1626
+ if (vkCreateImage(device_, &imgInfo, nullptr, &tex->image) != VK_SUCCESS) {
1627
+ delete tex;
1628
+ return nullptr;
1629
+ }
1291
1630
 
1292
1631
  VkMemoryRequirements memReqs;
1293
1632
  vkGetImageMemoryRequirements(device_, tex->image, &memReqs);
1294
1633
 
1634
+ bool typeFound = false;
1635
+ uint32_t typeIdx = findMemoryType(memReqs.memoryTypeBits,
1636
+ VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
1637
+ &typeFound);
1638
+ if (!typeFound) {
1639
+ LOGE("createImageFromPixels: no DEVICE_LOCAL memory type available");
1640
+ vkDestroyImage(device_, tex->image, nullptr);
1641
+ delete tex;
1642
+ return nullptr;
1643
+ }
1295
1644
  VkMemoryAllocateInfo allocInfo{};
1296
1645
  allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
1297
1646
  allocInfo.allocationSize = memReqs.size;
1298
- allocInfo.memoryTypeIndex = findMemoryType(memReqs.memoryTypeBits,
1299
- VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
1300
- VK_CHECK(vkAllocateMemory(device_, &allocInfo, nullptr, &tex->memory));
1301
- VK_CHECK(vkBindImageMemory(device_, tex->image, tex->memory, 0));
1302
-
1303
- uploadToImage(staging, tex->image,
1304
- static_cast<uint32_t>(width), static_cast<uint32_t>(height));
1647
+ allocInfo.memoryTypeIndex = typeIdx;
1648
+ if (vkAllocateMemory(device_, &allocInfo, nullptr, &tex->memory) != VK_SUCCESS) {
1649
+ vkDestroyImage(device_, tex->image, nullptr);
1650
+ delete tex;
1651
+ return nullptr;
1652
+ }
1653
+ vkBindImageMemory(device_, tex->image, tex->memory, 0);
1654
+
1655
+ // Try the deferred staging path first; fall back to synchronous if the ring
1656
+ // is full or we are still pre-init (no command buffers yet).
1657
+ const VkDeviceSize imageBytes =
1658
+ static_cast<VkDeviceSize>(width) * height * 4;
1659
+ bool deferred = false;
1660
+ if (stagingMapped_ != nullptr && commandPool_ != VK_NULL_HANDLE) {
1661
+ std::lock_guard<std::mutex> lk(stagingMutex_);
1662
+ VkDeviceSize off = allocateStagingSlice(imageBytes);
1663
+ if (off != static_cast<VkDeviceSize>(SIZE_MAX) && off + imageBytes <= kStagingSize) {
1664
+ memcpy(stagingMapped_ + off, pixels, static_cast<size_t>(imageBytes));
1665
+ tex->pendingUpload = true;
1666
+ pendingUploadsCurrent_.push_back(
1667
+ {tex, off, tex->width, tex->height, totalFrameCount_});
1668
+ deferred = true;
1669
+ }
1670
+ }
1305
1671
 
1306
- vkDestroyBuffer(device_, staging, nullptr);
1307
- vkFreeMemory(device_, stagingMem, nullptr);
1672
+ if (!deferred) {
1673
+ // Synchronous fallback (init-time or staging-ring exhausted).
1674
+ VkBuffer staging;
1675
+ VkDeviceMemory stagingMem;
1676
+ if (!createBuffer(imageBytes, VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
1677
+ VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
1678
+ VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
1679
+ staging, stagingMem)) {
1680
+ vkDestroyImage(device_, tex->image, nullptr);
1681
+ vkFreeMemory(device_, tex->memory, nullptr);
1682
+ delete tex;
1683
+ return nullptr;
1684
+ }
1685
+ void* mapped = nullptr;
1686
+ vkMapMemory(device_, stagingMem, 0, imageBytes, 0, &mapped);
1687
+ memcpy(mapped, pixels, static_cast<size_t>(imageBytes));
1688
+ vkUnmapMemory(device_, stagingMem);
1689
+
1690
+ VkCommandBuffer cmd = beginSingleTimeCommands();
1691
+ if (cmd != VK_NULL_HANDLE) {
1692
+ VkImageMemoryBarrier toTransfer{};
1693
+ toTransfer.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
1694
+ toTransfer.srcAccessMask = 0;
1695
+ toTransfer.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
1696
+ toTransfer.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED;
1697
+ toTransfer.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
1698
+ toTransfer.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
1699
+ toTransfer.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
1700
+ toTransfer.image = tex->image;
1701
+ toTransfer.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, tex->mipLevels, 0, 1};
1702
+ vkCmdPipelineBarrier(cmd,
1703
+ VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
1704
+ 0, 0, nullptr, 0, nullptr, 1, &toTransfer);
1705
+
1706
+ VkBufferImageCopy region{};
1707
+ region.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
1708
+ region.imageExtent = {tex->width, tex->height, 1};
1709
+ vkCmdCopyBufferToImage(cmd, staging, tex->image,
1710
+ VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &region);
1711
+
1712
+ if (tex->mipLevels > 1) {
1713
+ recordGenerateMipmaps(cmd, tex->image, tex->width, tex->height,
1714
+ tex->mipLevels);
1715
+ } else {
1716
+ VkImageMemoryBarrier toShader{};
1717
+ toShader.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
1718
+ toShader.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
1719
+ toShader.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
1720
+ toShader.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
1721
+ toShader.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
1722
+ toShader.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
1723
+ toShader.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
1724
+ toShader.image = tex->image;
1725
+ toShader.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
1726
+ vkCmdPipelineBarrier(cmd,
1727
+ VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
1728
+ 0, 0, nullptr, 0, nullptr, 1, &toShader);
1729
+ }
1730
+ endSingleTimeCommands(cmd);
1731
+ }
1732
+ vkDestroyBuffer(device_, staging, nullptr);
1733
+ vkFreeMemory(device_, stagingMem, nullptr);
1734
+ }
1308
1735
 
1309
1736
  VkImageViewCreateInfo viewInfo{};
1310
1737
  viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
1311
1738
  viewInfo.image = tex->image;
1312
1739
  viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D;
1313
1740
  viewInfo.format = VK_FORMAT_R8G8B8A8_UNORM;
1314
- viewInfo.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
1315
- VK_CHECK(vkCreateImageView(device_, &viewInfo, nullptr, &tex->imageView));
1741
+ viewInfo.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, tex->mipLevels, 0, 1};
1742
+ vkCreateImageView(device_, &viewInfo, nullptr, &tex->imageView);
1316
1743
 
1317
1744
  VkSamplerCreateInfo samplerInfo{};
1318
1745
  samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO;
@@ -1321,12 +1748,19 @@ void* VulkanBackend::createRasterTexture(const uint8_t* pixels, int32_t width, i
1321
1748
  samplerInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
1322
1749
  samplerInfo.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
1323
1750
  samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
1324
- VK_CHECK(vkCreateSampler(device_, &samplerInfo, nullptr, &tex->sampler));
1751
+ samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR;
1752
+ samplerInfo.minLod = 0.0f;
1753
+ samplerInfo.maxLod = static_cast<float>(tex->mipLevels);
1754
+ if (supportsAnisotropy_) {
1755
+ samplerInfo.anisotropyEnable = VK_TRUE;
1756
+ samplerInfo.maxAnisotropy = std::min(8.0f, maxAnisotropy_);
1757
+ }
1758
+ vkCreateSampler(device_, &samplerInfo, nullptr, &tex->sampler);
1325
1759
 
1326
- // Allocate a descriptor set for this texture (set 1: texture only)
1760
+ // Allocate a descriptor set for this texture.
1327
1761
  VkDescriptorSetAllocateInfo dsAllocInfo{};
1328
1762
  dsAllocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
1329
- dsAllocInfo.descriptorPool = descriptorPool_;
1763
+ dsAllocInfo.descriptorPool = pool;
1330
1764
  dsAllocInfo.descriptorSetCount = 1;
1331
1765
  dsAllocInfo.pSetLayouts = &terrainTexLayout_;
1332
1766
  VkResult dsResult = vkAllocateDescriptorSets(device_, &dsAllocInfo, &tex->descriptorSet);
@@ -1348,115 +1782,31 @@ void* VulkanBackend::createRasterTexture(const uint8_t* pixels, int32_t width, i
1348
1782
  texWrite.pImageInfo = &dsImgInfo;
1349
1783
  vkUpdateDescriptorSets(device_, 1, &texWrite, 0, nullptr);
1350
1784
  }
1351
-
1352
1785
  return tex;
1353
1786
  }
1354
1787
 
1788
+ // ── Public texture creators ─────────────────────────────────────────────────────
1789
+
1790
+ void* VulkanBackend::createRasterTexture(const uint8_t* pixels, int32_t width, int32_t height) {
1791
+ return createImageFromPixels(pixels, width, height,
1792
+ descriptorPool_, VK_SAMPLE_COUNT_1_BIT,
1793
+ /*wantMipmaps=*/true);
1794
+ }
1355
1795
  void VulkanBackend::freeRasterTexture(void* texPtr) {
1356
1796
  if (!texPtr || !device_) return;
1357
1797
  auto* tex = static_cast<VulkanTexture*>(texPtr);
1358
- // Enqueue for deferred deletion — pool = descriptorPool_ (set 1 imagery pool).
1359
1798
  std::lock_guard<std::mutex> lk(pendingDeletesMutex_);
1360
1799
  pendingDeletes_.push_back({tex, totalFrameCount_, descriptorPool_});
1361
1800
  }
1362
-
1363
1801
  void* VulkanBackend::createWaterMaskTexture(const uint8_t* pixels, int32_t width, int32_t height) {
1364
- if (!device_ || !pixels || width <= 0 || height <= 0 || !waterMaskPool_) return nullptr;
1365
-
1366
- auto* tex = new VulkanTexture();
1367
- VkDeviceSize imageSize = static_cast<VkDeviceSize>(width) * height * 4;
1368
-
1369
- VkBuffer staging;
1370
- VkDeviceMemory stagingMem;
1371
- createBuffer(imageSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
1372
- VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
1373
- staging, stagingMem);
1374
-
1375
- void* mapped;
1376
- vkMapMemory(device_, stagingMem, 0, imageSize, 0, &mapped);
1377
- memcpy(mapped, pixels, static_cast<size_t>(imageSize));
1378
- vkUnmapMemory(device_, stagingMem);
1379
-
1380
- VkImageCreateInfo imgInfo{};
1381
- imgInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
1382
- imgInfo.imageType = VK_IMAGE_TYPE_2D;
1383
- imgInfo.format = VK_FORMAT_R8G8B8A8_UNORM;
1384
- imgInfo.extent = {static_cast<uint32_t>(width), static_cast<uint32_t>(height), 1};
1385
- imgInfo.mipLevels = 1;
1386
- imgInfo.arrayLayers = 1;
1387
- imgInfo.samples = VK_SAMPLE_COUNT_1_BIT;
1388
- imgInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
1389
- imgInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
1390
- imgInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
1391
- VK_CHECK(vkCreateImage(device_, &imgInfo, nullptr, &tex->image));
1392
-
1393
- VkMemoryRequirements memReqs;
1394
- vkGetImageMemoryRequirements(device_, tex->image, &memReqs);
1395
-
1396
- VkMemoryAllocateInfo allocInfo{};
1397
- allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
1398
- allocInfo.allocationSize = memReqs.size;
1399
- allocInfo.memoryTypeIndex = findMemoryType(memReqs.memoryTypeBits,
1400
- VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
1401
- VK_CHECK(vkAllocateMemory(device_, &allocInfo, nullptr, &tex->memory));
1402
- VK_CHECK(vkBindImageMemory(device_, tex->image, tex->memory, 0));
1403
-
1404
- uploadToImage(staging, tex->image,
1405
- static_cast<uint32_t>(width), static_cast<uint32_t>(height));
1406
-
1407
- vkDestroyBuffer(device_, staging, nullptr);
1408
- vkFreeMemory(device_, stagingMem, nullptr);
1409
-
1410
- VkImageViewCreateInfo viewInfo{};
1411
- viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
1412
- viewInfo.image = tex->image;
1413
- viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D;
1414
- viewInfo.format = VK_FORMAT_R8G8B8A8_UNORM;
1415
- viewInfo.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
1416
- VK_CHECK(vkCreateImageView(device_, &viewInfo, nullptr, &tex->imageView));
1417
-
1418
- VkSamplerCreateInfo samplerInfo{};
1419
- samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO;
1420
- samplerInfo.magFilter = VK_FILTER_LINEAR;
1421
- samplerInfo.minFilter = VK_FILTER_LINEAR;
1422
- samplerInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
1423
- samplerInfo.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
1424
- samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
1425
- VK_CHECK(vkCreateSampler(device_, &samplerInfo, nullptr, &tex->sampler));
1426
-
1427
- // Allocate descriptor set from the water mask pool (set 2).
1428
- VkDescriptorSetAllocateInfo dsAllocInfo{};
1429
- dsAllocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
1430
- dsAllocInfo.descriptorPool = waterMaskPool_;
1431
- dsAllocInfo.descriptorSetCount = 1;
1432
- dsAllocInfo.pSetLayouts = &terrainTexLayout_;
1433
- VkResult dsResult = vkAllocateDescriptorSets(device_, &dsAllocInfo, &tex->descriptorSet);
1434
- if (dsResult != VK_SUCCESS) {
1435
- LOGE("Water mask descriptor set alloc FAILED: %d", dsResult);
1436
- tex->descriptorSet = VK_NULL_HANDLE;
1437
- } else {
1438
- VkDescriptorImageInfo dsImgInfo{};
1439
- dsImgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
1440
- dsImgInfo.imageView = tex->imageView;
1441
- dsImgInfo.sampler = tex->sampler;
1442
-
1443
- VkWriteDescriptorSet texWrite{};
1444
- texWrite.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
1445
- texWrite.dstSet = tex->descriptorSet;
1446
- texWrite.dstBinding = 0;
1447
- texWrite.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
1448
- texWrite.descriptorCount = 1;
1449
- texWrite.pImageInfo = &dsImgInfo;
1450
- vkUpdateDescriptorSets(device_, 1, &texWrite, 0, nullptr);
1451
- }
1452
-
1453
- return tex;
1802
+ // Water mask is downsampled hi-frequency data; mipmaps add real value here too.
1803
+ return createImageFromPixels(pixels, width, height,
1804
+ waterMaskPool_, VK_SAMPLE_COUNT_1_BIT,
1805
+ /*wantMipmaps=*/true);
1454
1806
  }
1455
-
1456
1807
  void VulkanBackend::freeWaterMaskTexture(void* texPtr) {
1457
1808
  if (!texPtr || !device_) return;
1458
1809
  auto* tex = static_cast<VulkanTexture*>(texPtr);
1459
- // Enqueue for deferred deletion — pool = waterMaskPool_ (set 2 pool).
1460
1810
  std::lock_guard<std::mutex> lk(pendingDeletesMutex_);
1461
1811
  pendingDeletes_.push_back({tex, totalFrameCount_, waterMaskPool_});
1462
1812
  }
@@ -1475,8 +1825,6 @@ void VulkanBackend::destroyTexture(VulkanTexture* tex, VkDescriptorPool pool) {
1475
1825
 
1476
1826
  void VulkanBackend::flushPendingDeletes() {
1477
1827
  std::lock_guard<std::mutex> lk(pendingDeletesMutex_);
1478
- // Entries are enqueued in monotonically increasing frameIndex order, so we
1479
- // can stop as soon as the front entry is not yet safe to destroy.
1480
1828
  while (!pendingDeletes_.empty()) {
1481
1829
  const auto& front = pendingDeletes_.front();
1482
1830
  if (totalFrameCount_ < front.frameIndex + static_cast<uint64_t>(kMaxFramesInFlight))
@@ -1486,9 +1834,11 @@ void VulkanBackend::flushPendingDeletes() {
1486
1834
  }
1487
1835
  }
1488
1836
 
1489
- void VulkanBackend::setMsaaSampleCount(int /*sampleCount*/) {
1490
- // MSAA not implemented in initial version — requires resolve attachments.
1491
- // Accepted but ignored; can be wired up in a follow-up pass.
1837
+ void VulkanBackend::setMsaaSampleCount(int sampleCount) {
1838
+ // Defer MSAA changes to the next frame boundary so we don't tear down the
1839
+ // render pass mid-frame. Stash the request; beginFrame applies it before any
1840
+ // GPU work for the frame.
1841
+ pendingMsaaRequest_ = sampleCount;
1492
1842
  }
1493
1843
 
1494
1844
  // ── Swapchain recreation ───────────────────────────────────────────────────────
@@ -1497,6 +1847,11 @@ void VulkanBackend::cleanupSwapchain() {
1497
1847
  for (auto& fb : framebuffers_) vkDestroyFramebuffer(device_, fb, nullptr);
1498
1848
  framebuffers_.clear();
1499
1849
 
1850
+ if (msaaColorView_) vkDestroyImageView(device_, msaaColorView_, nullptr);
1851
+ if (msaaColorImage_) vkDestroyImage(device_, msaaColorImage_, nullptr);
1852
+ if (msaaColorMemory_) vkFreeMemory(device_, msaaColorMemory_, nullptr);
1853
+ msaaColorView_ = VK_NULL_HANDLE; msaaColorImage_ = VK_NULL_HANDLE; msaaColorMemory_ = VK_NULL_HANDLE;
1854
+
1500
1855
  if (depthImageView_) vkDestroyImageView(device_, depthImageView_, nullptr);
1501
1856
  if (depthImage_) vkDestroyImage(device_, depthImage_, nullptr);
1502
1857
  if (depthMemory_) vkFreeMemory(device_, depthMemory_, nullptr);
@@ -1514,11 +1869,28 @@ void VulkanBackend::cleanupSwapchain() {
1514
1869
  }
1515
1870
 
1516
1871
  void VulkanBackend::recreateSwapchain() {
1517
- vkDeviceWaitIdle(device_);
1872
+ // P2-vk-recreate: drain only the in-flight frames, not the whole device.
1873
+ if (!inFlightFences_.empty()) {
1874
+ vkWaitForFences(device_, static_cast<uint32_t>(inFlightFences_.size()),
1875
+ inFlightFences_.data(), VK_TRUE, UINT64_MAX);
1876
+ }
1518
1877
  cleanupSwapchain();
1519
1878
  createSwapchain();
1520
1879
  createDepthResources();
1880
+ if (sampleCount_ != VK_SAMPLE_COUNT_1_BIT) createMsaaResources();
1521
1881
  createFramebuffers();
1882
+
1883
+ // The per-image semaphore array is keyed on swapchain image index, which
1884
+ // can change with image count. Resize it to match.
1885
+ for (auto& s : renderFinishedSemaphores_) {
1886
+ if (s) vkDestroySemaphore(device_, s, nullptr);
1887
+ }
1888
+ renderFinishedSemaphores_.assign(swapchainImages_.size(), VK_NULL_HANDLE);
1889
+ VkSemaphoreCreateInfo si{}; si.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
1890
+ for (auto& s : renderFinishedSemaphores_) {
1891
+ VK_CHECK(vkCreateSemaphore(device_, &si, nullptr, &s));
1892
+ }
1893
+
1522
1894
  needsSwapchainRecreate_ = false;
1523
1895
  }
1524
1896
 
@@ -1526,6 +1898,56 @@ void VulkanBackend::recreateSwapchain() {
1526
1898
 
1527
1899
  void VulkanBackend::beginFrame(const FrameParams& /*params*/) {
1528
1900
  if (device_ == VK_NULL_HANDLE) return;
1901
+
1902
+ // On Android, defer swapchain creation until the first frame to ensure the
1903
+ // surface compositor is fully ready. Creating it too early can cause
1904
+ // vkAcquireNextImageKHR to block indefinitely.
1905
+ if (swapchain_ == VK_NULL_HANDLE) {
1906
+ createSwapchain();
1907
+
1908
+ // Create renderFinishedSemaphores now that we know swapchainImages_.size()
1909
+ renderFinishedSemaphores_.resize(swapchainImages_.size());
1910
+ VkSemaphoreCreateInfo semInfo{};
1911
+ semInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
1912
+ for (size_t i = 0; i < renderFinishedSemaphores_.size(); ++i) {
1913
+ VK_CHECK(vkCreateSemaphore(device_, &semInfo, nullptr, &renderFinishedSemaphores_[i]));
1914
+ }
1915
+
1916
+ createRenderPass();
1917
+ createDepthResources();
1918
+ if (sampleCount_ != VK_SAMPLE_COUNT_1_BIT) createMsaaResources();
1919
+ createFramebuffers();
1920
+ createGraphicsPipelinesAll();
1921
+ }
1922
+
1923
+ // Apply pending MSAA change between frames (safe — the previous frame's
1924
+ // command buffer has finished by the time we wait on the fence below).
1925
+ if (pendingMsaaRequest_ != 0) {
1926
+ const VkSampleCountFlagBits target = resolveMsaaSamples(pendingMsaaRequest_);
1927
+ pendingMsaaRequest_ = 0;
1928
+ if (target != sampleCount_) {
1929
+ vkDeviceWaitIdle(device_); // bounded; one-shot when MSAA toggled
1930
+ destroyGraphicsPipelinesAll();
1931
+ if (renderPass_) { vkDestroyRenderPass(device_, renderPass_, nullptr); renderPass_ = VK_NULL_HANDLE; }
1932
+ // Need to free the framebuffers + depth + msaa color since attachments
1933
+ // now have a different sample count.
1934
+ for (auto& fb : framebuffers_) vkDestroyFramebuffer(device_, fb, nullptr);
1935
+ framebuffers_.clear();
1936
+ if (depthImageView_) { vkDestroyImageView(device_, depthImageView_, nullptr); depthImageView_ = VK_NULL_HANDLE; }
1937
+ if (depthImage_) { vkDestroyImage(device_, depthImage_, nullptr); depthImage_ = VK_NULL_HANDLE; }
1938
+ if (depthMemory_) { vkFreeMemory(device_, depthMemory_, nullptr); depthMemory_ = VK_NULL_HANDLE; }
1939
+ if (msaaColorView_) { vkDestroyImageView(device_, msaaColorView_, nullptr); msaaColorView_ = VK_NULL_HANDLE; }
1940
+ if (msaaColorImage_) { vkDestroyImage(device_, msaaColorImage_, nullptr); msaaColorImage_ = VK_NULL_HANDLE; }
1941
+ if (msaaColorMemory_){ vkFreeMemory(device_, msaaColorMemory_, nullptr); msaaColorMemory_ = VK_NULL_HANDLE; }
1942
+
1943
+ sampleCount_ = target;
1944
+ createRenderPass();
1945
+ createDepthResources();
1946
+ if (sampleCount_ != VK_SAMPLE_COUNT_1_BIT) createMsaaResources();
1947
+ createFramebuffers();
1948
+ createGraphicsPipelinesAll();
1949
+ }
1950
+ }
1529
1951
 
1530
1952
  if (needsSwapchainRecreate_) {
1531
1953
  recreateSwapchain();
@@ -1533,13 +1955,33 @@ void VulkanBackend::beginFrame(const FrameParams& /*params*/) {
1533
1955
 
1534
1956
  vkWaitForFences(device_, 1, &inFlightFences_[frameIndex_], VK_TRUE, UINT64_MAX);
1535
1957
 
1536
- // GPU is done with frameIndex_ — safe to destroy textures enqueued that long ago.
1537
1958
  ++totalFrameCount_;
1538
1959
  flushPendingDeletes();
1539
1960
 
1540
- VkResult result = vkAcquireNextImageKHR(device_, swapchain_, UINT64_MAX,
1961
+ // Reclaim staging slices for uploads that have now finished on the GPU.
1962
+ // (Their submit was made N=kMaxFramesInFlight frames ago against
1963
+ // uploadFences_[frameIndex_]; the wait above is on inFlightFences_, but
1964
+ // since uploads were submitted on the same queue *before* the previous
1965
+ // frame's draw they must have completed too.)
1966
+ {
1967
+ std::lock_guard<std::mutex> lk(stagingMutex_);
1968
+ uploadsInFlight_[frameIndex_].clear();
1969
+ }
1970
+
1971
+ // Submit any uploads queued since last beginFrame.
1972
+ flushPendingUploads();
1973
+
1974
+ // Use a 1-second timeout instead of UINT64_MAX to avoid indefinite hangs.
1975
+ // On a properly functioning swapchain, this returns immediately.
1976
+ VkResult result = vkAcquireNextImageKHR(device_, swapchain_, 1000000000ULL,
1541
1977
  imageAvailableSemaphores_[frameIndex_],
1542
1978
  VK_NULL_HANDLE, &imageIndex_);
1979
+
1980
+ if (result == VK_TIMEOUT) {
1981
+ LOGE("VulkanBackend::beginFrame: vkAcquireNextImageKHR timed out");
1982
+ frameBegan_ = false;
1983
+ return;
1984
+ }
1543
1985
  if (result == VK_ERROR_OUT_OF_DATE_KHR || result == VK_SUBOPTIMAL_KHR) {
1544
1986
  recreateSwapchain();
1545
1987
  result = vkAcquireNextImageKHR(device_, swapchain_, UINT64_MAX,
@@ -1558,9 +2000,10 @@ void VulkanBackend::beginFrame(const FrameParams& /*params*/) {
1558
2000
  beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
1559
2001
  vkBeginCommandBuffer(commandBuffers_[frameIndex_], &beginInfo);
1560
2002
 
1561
- std::array<VkClearValue, 2> clearValues{};
2003
+ std::array<VkClearValue, 3> clearValues{};
1562
2004
  clearValues[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}};
1563
2005
  clearValues[1].depthStencil = {0.0f, 0}; // Reversed-Z: 0 = far
2006
+ clearValues[2].color = {{0.0f, 0.0f, 0.0f, 1.0f}};
1564
2007
 
1565
2008
  VkRenderPassBeginInfo rpBegin{};
1566
2009
  rpBegin.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
@@ -1568,15 +2011,13 @@ void VulkanBackend::beginFrame(const FrameParams& /*params*/) {
1568
2011
  rpBegin.framebuffer = framebuffers_[imageIndex_];
1569
2012
  rpBegin.renderArea.offset = {0, 0};
1570
2013
  rpBegin.renderArea.extent = swapchainExtent_;
1571
- rpBegin.clearValueCount = static_cast<uint32_t>(clearValues.size());
2014
+ rpBegin.clearValueCount = (sampleCount_ != VK_SAMPLE_COUNT_1_BIT) ? 3u : 2u;
1572
2015
  rpBegin.pClearValues = clearValues.data();
1573
2016
 
1574
2017
  vkCmdBeginRenderPass(commandBuffers_[frameIndex_], &rpBegin, VK_SUBPASS_CONTENTS_INLINE);
1575
2018
 
1576
2019
  // Negative viewport height flips Y to match the OpenGL/Metal clip-space
1577
2020
  // convention that the shared C++ projection matrix targets (Vulkan 1.1+).
1578
- // The spec auto-adjusts triangle winding so VK_FRONT_FACE_COUNTER_CLOCKWISE
1579
- // still works correctly.
1580
2021
  VkViewport viewport{};
1581
2022
  viewport.x = 0.0f;
1582
2023
  viewport.y = static_cast<float>(swapchainExtent_.height);
@@ -1624,7 +2065,6 @@ void VulkanBackend::drawScene(const FrameResult& frame) {
1624
2065
  vkCmdDraw(cmd, 3, 1, 0, 0);
1625
2066
  }
1626
2067
 
1627
- // ── Terrain pass ──────────────────────────────────────────────────────────
1628
2068
  if (frame.draws.empty() || frame.localPositions.empty() || frame.indices.empty() ||
1629
2069
  frame.altitudes.empty() || !terrainPipeline_) {
1630
2070
  return;
@@ -1648,7 +2088,6 @@ void VulkanBackend::drawScene(const FrameResult& frame) {
1648
2088
 
1649
2089
  if (!vtxBufs_[fi] || !idxBufs_[fi]) return;
1650
2090
 
1651
- // Per-frame UBO: just cameraEcef (fragment uses it for lighting view direction).
1652
2091
  TerrainUBO terrU{};
1653
2092
  terrU.cameraEcef[0] = frame.cameraEcef.x;
1654
2093
  terrU.cameraEcef[1] = frame.cameraEcef.y;
@@ -1658,17 +2097,14 @@ void VulkanBackend::drawScene(const FrameResult& frame) {
1658
2097
 
1659
2098
  vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, terrainPipeline_);
1660
2099
 
1661
- // Bind vertex buffers: binding 0 = positions, 1 = UVs, 2 = altitudes.
1662
2100
  VkBuffer vertexBuffers[] = {vtxBufs_[fi], uvBufs_[fi], altBufs_[fi]};
1663
2101
  VkDeviceSize offsets[] = {0, 0, 0};
1664
2102
  vkCmdBindVertexBuffers(cmd, 0, 3, vertexBuffers, offsets);
1665
2103
  vkCmdBindIndexBuffer(cmd, idxBufs_[fi], 0, VK_INDEX_TYPE_UINT32);
1666
2104
 
1667
- // Bind set 0 (UBO) once for the whole terrain pass.
1668
2105
  vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, terrainPipelineLayout_,
1669
2106
  0, 1, &terrainDescSets_[fi], 0, nullptr);
1670
2107
 
1671
- // Bind set 1 (imagery) and set 2 (water mask) — start with fallbacks.
1672
2108
  VkDescriptorSet lastTexSet = fallbackTexDescSet_;
1673
2109
  VkDescriptorSet lastWaterMaskSet = fallbackWaterMaskDescSet_;
1674
2110
  vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, terrainPipelineLayout_,
@@ -1676,41 +2112,47 @@ void VulkanBackend::drawScene(const FrameResult& frame) {
1676
2112
  vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, terrainPipelineLayout_,
1677
2113
  2, 1, &lastWaterMaskSet, 0, nullptr);
1678
2114
 
2115
+ // Combined push-constant block: vertex bytes 0-63, fragment bytes 64-127.
2116
+ // Issued once per draw instead of two separate vkCmdPushConstants calls
2117
+ // (P2-arg-buffers).
2118
+ struct CombinedPC {
2119
+ TerrainVertexPC v;
2120
+ TerrainFragmentPC f;
2121
+ };
2122
+ static_assert(sizeof(CombinedPC) == 128, "PC layout drift");
2123
+ CombinedPC pc{};
2124
+
1679
2125
  for (const auto& draw : frame.draws) {
1680
2126
  if (draw.indexCount == 0) continue;
1681
2127
 
1682
- // ── Vertex push constants: per-tile RTC MVP (bytes 0-63) ─────────────────
1683
- TerrainVertexPC vpc{};
1684
2128
  const float* mp = glm::value_ptr(draw.mvpMatrix);
1685
- for (int i = 0; i < 16; ++i) vpc.mvpMatrix[i] = mp[i];
1686
- vkCmdPushConstants(cmd, terrainPipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT,
1687
- 0, sizeof(TerrainVertexPC), &vpc);
1688
-
1689
- // ── Fragment push constants: overlay + water mask + RTC (bytes 64-127) ───
1690
- TerrainFragmentPC fpc{};
1691
- fpc.hasOverlay = (draw.overlayTexture && draw.hasUVs) ? 1u : 0u;
1692
- fpc.isEllipsoidFallback = draw.isEllipsoidFallback ? 1u : 0u;
1693
- fpc.isOnlyWater = draw.isOnlyWater ? 1u : 0u;
1694
- fpc.hasWaterMask = (draw.waterMaskTexture != nullptr) ? 1u : 0u;
1695
- fpc.wmWest = draw.wmTileBounds.x;
1696
- fpc.wmSouth = draw.wmTileBounds.y;
1697
- fpc.wmEast = draw.wmTileBounds.z;
1698
- fpc.wmNorth = draw.wmTileBounds.w;
1699
- fpc.rtcCenterX = draw.rtcCenterEcef.x;
1700
- fpc.rtcCenterY = draw.rtcCenterEcef.y;
1701
- fpc.rtcCenterZ = draw.rtcCenterEcef.z;
1702
- fpc.translationX = draw.overlayTranslation.x;
1703
- fpc.translationY = draw.overlayTranslation.y;
1704
- fpc.scaleX = draw.overlayScale.x;
1705
- fpc.scaleY = draw.overlayScale.y;
1706
- vkCmdPushConstants(cmd, terrainPipelineLayout_, VK_SHADER_STAGE_FRAGMENT_BIT,
1707
- sizeof(TerrainVertexPC), sizeof(TerrainFragmentPC), &fpc);
1708
-
1709
- // ── Set 1: imagery overlay ───────────────────────────────────────────────
2129
+ for (int i = 0; i < 16; ++i) pc.v.mvpMatrix[i] = mp[i];
2130
+
2131
+ pc.f.hasOverlay = (draw.overlayTexture && draw.hasUVs) ? 1u : 0u;
2132
+ pc.f.isEllipsoidFallback = draw.isEllipsoidFallback ? 1u : 0u;
2133
+ pc.f.isOnlyWater = draw.isOnlyWater ? 1u : 0u;
2134
+ pc.f.hasWaterMask = (draw.waterMaskTexture != nullptr) ? 1u : 0u;
2135
+ pc.f.wmWest = draw.wmTileBounds.x;
2136
+ pc.f.wmSouth = draw.wmTileBounds.y;
2137
+ pc.f.wmEast = draw.wmTileBounds.z;
2138
+ pc.f.wmNorth = draw.wmTileBounds.w;
2139
+ pc.f.rtcCenterX = draw.rtcCenterEcef.x;
2140
+ pc.f.rtcCenterY = draw.rtcCenterEcef.y;
2141
+ pc.f.rtcCenterZ = draw.rtcCenterEcef.z;
2142
+ pc.f.translationX = draw.overlayTranslation.x;
2143
+ pc.f.translationY = draw.overlayTranslation.y;
2144
+ pc.f.scaleX = draw.overlayScale.x;
2145
+ pc.f.scaleY = draw.overlayScale.y;
2146
+ vkCmdPushConstants(cmd, terrainPipelineLayout_,
2147
+ VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT,
2148
+ 0, sizeof(CombinedPC), &pc);
2149
+
1710
2150
  VkDescriptorSet texSet = fallbackTexDescSet_;
1711
2151
  if (draw.overlayTexture && draw.hasUVs) {
1712
2152
  auto* tex = static_cast<VulkanTexture*>(draw.overlayTexture);
1713
- if (tex && tex->descriptorSet) {
2153
+ // Skip textures still pending their first GPU upload — would read
2154
+ // undefined data otherwise. Falls back to white for one frame.
2155
+ if (tex && tex->descriptorSet && !tex->pendingUpload) {
1714
2156
  texSet = tex->descriptorSet;
1715
2157
  }
1716
2158
  }
@@ -1720,11 +2162,10 @@ void VulkanBackend::drawScene(const FrameResult& frame) {
1720
2162
  1, 1, &lastTexSet, 0, nullptr);
1721
2163
  }
1722
2164
 
1723
- // ── Set 2: water mask ────────────────────────────────────────────────────
1724
2165
  VkDescriptorSet wmSet = fallbackWaterMaskDescSet_;
1725
2166
  if (draw.waterMaskTexture) {
1726
2167
  auto* wmTex = static_cast<VulkanTexture*>(draw.waterMaskTexture);
1727
- if (wmTex && wmTex->descriptorSet) {
2168
+ if (wmTex && wmTex->descriptorSet && !wmTex->pendingUpload) {
1728
2169
  wmSet = wmTex->descriptorSet;
1729
2170
  }
1730
2171
  }
@@ -1748,7 +2189,7 @@ void VulkanBackend::endFrame() {
1748
2189
  VK_CHECK(vkEndCommandBuffer(cmd));
1749
2190
 
1750
2191
  VkSemaphore waitSemaphores[] = {imageAvailableSemaphores_[frameIndex_]};
1751
- VkSemaphore signalSemaphores[] = {renderFinishedSemaphores_[frameIndex_]};
2192
+ VkSemaphore signalSemaphores[] = {renderFinishedSemaphores_[imageIndex_]};
1752
2193
  VkPipelineStageFlags waitStages[] = {VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT};
1753
2194
 
1754
2195
  VkSubmitInfo submitInfo{};