aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/core/application.cpp56
-rw-r--r--src/core/application.h4
-rw-r--r--src/core/log.h11
-rw-r--r--src/core/window.cpp17
-rw-r--r--src/core/window.h2
-rw-r--r--src/main.cpp26
-rw-r--r--src/platform/opengl/shader.cpp13
-rw-r--r--src/platform/opengl/texture.cpp14
-rw-r--r--src/platform/vulkan/vulkan_common.h7
-rw-r--r--src/platform/vulkan/vulkan_cubemap.cpp33
-rw-r--r--src/platform/vulkan/vulkan_device.cpp46
-rw-r--r--src/platform/vulkan/vulkan_device.h4
-rw-r--r--src/platform/vulkan/vulkan_resources.cpp5
-rw-r--r--src/platform/vulkan/vulkan_swapchain.cpp77
-rw-r--r--src/ui/ui_layer.cpp14
-rw-r--r--src/ui/ui_layer.h3
16 files changed, 271 insertions, 61 deletions
diff --git a/src/core/application.cpp b/src/core/application.cpp
index e53e420..de318fb 100644
--- a/src/core/application.cpp
+++ b/src/core/application.cpp
@@ -4,10 +4,13 @@
#include "settings_manager.h"
#include "platform/opengl/opengl_device.h"
-#include "platform/vulkan/vulkan_device.h"
+#ifndef DONUT_NO_VULKAN
+ #include "platform/vulkan/vulkan_device.h"
+#endif
#include <GLFW/glfw3.h>
#include <imgui.h>
+#include <imgui_impl_glfw.h> // ImGui_ImplGlfw_GetContentScaleForWindow
#include <glm/glm.hpp>
#include <algorithm>
#include <thread>
@@ -29,6 +32,7 @@ namespace Donut
}
Application::Application(const std::string& name, int width, int height)
+ : m_title(name), m_init_w(width), m_init_h(height)
{
s_instance = this;
@@ -42,11 +46,31 @@ namespace Donut
? RendererAPI::API::Vulkan : RendererAPI::API::OpenGL);
}
+#ifdef DONUT_NO_VULKAN
+ // generated without a Vulkan SDK, so the backend isn't in this binary at all
+ if (RendererAPI::get_api() == RendererAPI::API::Vulkan)
+ {
+ DONUT_WARN("built without the Vulkan backend, falling back to OpenGL");
+ RendererAPI::set_api(RendererAPI::API::OpenGL);
+ }
+#else
+ if (RendererAPI::get_api() == RendererAPI::API::Vulkan && !RHI::vulkan_runtime_available())
+ {
+ DONUT_WARN("no Vulkan runtime on this machine (vulkan-1.dll), falling back to OpenGL");
+ RendererAPI::set_api(RendererAPI::API::OpenGL);
+ }
if (RendererAPI::get_api() == RendererAPI::API::Vulkan)
RHI::vulkan_prepare_glfw(); // must precede glfwInit() inside the Window ctor
+#endif
m_window = create_scope<Window>(name, width, height);
m_window->set_event_callback([this](Event& event) { on_event(event); });
+ if (!m_window->get_native_window())
+ {
+ DONUT_ERROR("no window, no app (GLFW said why above)");
+ m_running = false;
+ return;
+ }
on_init();
}
@@ -62,15 +86,33 @@ namespace Donut
int w = 0, h = 0;
glfwGetFramebufferSize((GLFWwindow*)m_window->get_native_window(), &w, &h);
+#ifndef DONUT_NO_VULKAN
if (RendererAPI::get_api() == RendererAPI::API::Vulkan)
m_device = RHI::create_vulkan_device();
else
+#endif
m_device = RHI::create_opengl_device();
RHI::NativeWindow native{ m_window->get_native_window(), w, h };
if (!m_device->init(native))
{
+ m_device->shutdown();
+ m_device.reset();
+#ifndef DONUT_NO_VULKAN
+ // a Vulkan window can't host a GL context (GLFW_NO_API), so rebuild it
+ // and try once more on OpenGL. the saved setting stays as the user left it.
+ if (RendererAPI::get_api() == RendererAPI::API::Vulkan)
+ {
+ DONUT_WARN("Vulkan device init failed (see above), falling back to OpenGL");
+ RendererAPI::set_api(RendererAPI::API::OpenGL);
+ m_window.reset();
+ m_window = create_scope<Window>(m_title, m_init_w, m_init_h);
+ m_window->set_event_callback([this](Event& event) { on_event(event); });
+ if (m_window->get_native_window()) { on_init(); return; }
+ }
+#endif
DONUT_ERROR("RHI device initialization failed");
+ m_running = false; // nothing to draw with; run() just returns
return;
}
@@ -80,9 +122,13 @@ namespace Donut
m_ui = create_scope<UILayer>();
m_device->init_imgui();
+ // windows hands out physical pixels with no scaling, so a 4k desktop at 200%
+ // would get a half-size ui. the glfw backend reports 1.0 on macos (retina
+ // scaling lives in the framebuffer there), so the mac is untouched.
+ const float dpi = ImGui_ImplGlfw_GetContentScaleForWindow((GLFWwindow*)m_window->get_native_window());
// the UI layer owns its typography and its imgui.ini section; both go into
// the device-created ImGui context before its first NewFrame reads the ini.
- m_ui->init();
+ m_ui->init(dpi);
g_prev_scroll = glfwSetScrollCallback((GLFWwindow*)m_window->get_native_window(), donut_scroll_callback);
@@ -99,8 +145,8 @@ namespace Donut
{
double frame_start = glfwGetTime();
glfwPollEvents();
- if (!m_minimized && m_device)
- render_frame();
+ if (m_minimized) { glfwWaitEventsTimeout(0.1); continue; } // nothing to draw, so don't spin a core
+ if (m_device) render_frame();
// with vsync off, cap to the target FPS (vsync itself paces otherwise).
if (!SettingsManager::get_v_sync_enabled())
@@ -179,7 +225,7 @@ namespace Donut
{
ExportConfig cfg;
cfg.width = 1280; cfg.height = 720;
- int written = export_frame(cfg);
+ [[maybe_unused]] const int written = export_frame(cfg); // only the debug log reads it
DONUT_INFO("autoexport wrote {} file(s)", written);
close();
}
diff --git a/src/core/application.h b/src/core/application.h
index bb88919..3c24ee8 100644
--- a/src/core/application.h
+++ b/src/core/application.h
@@ -69,6 +69,10 @@ namespace Donut
bool m_running = true;
bool m_minimized = false;
+ // kept for rebuilding the window when Vulkan fails and we retry on OpenGL
+ std::string m_title;
+ int m_init_w = 1280, m_init_h = 720;
+
static Application* s_instance;
};
}
diff --git a/src/core/log.h b/src/core/log.h
index 4237d1c..ca96b26 100644
--- a/src/core/log.h
+++ b/src/core/log.h
@@ -141,16 +141,15 @@ namespace Donut
};
}
+// trace/info are debug-only chatter. warnings and errors stay in every build:
+// a release build that fails on someone else's machine should still say why.
#if defined(DONUT_DEBUG)
#define DONUT_TRACE(format, ...) ::Donut::Logger::trace(format __VA_OPT__(,) __VA_ARGS__)
#define DONUT_INFO(format, ...) ::Donut::Logger::info(format __VA_OPT__(,) __VA_ARGS__)
- #define DONUT_WARN(format, ...) ::Donut::Logger::warn(format __VA_OPT__(,) __VA_ARGS__)
- #define DONUT_ERROR(format, ...) ::Donut::Logger::error(format __VA_OPT__(,) __VA_ARGS__)
- #define DONUT_FATAL(format, ...) ::Donut::Logger::fatal(format __VA_OPT__(,) __VA_ARGS__)
#else
#define DONUT_TRACE(format, ...) {}
#define DONUT_INFO(format, ...) {}
- #define DONUT_WARN(format, ...) {}
- #define DONUT_ERROR(format, ...) {}
- #define DONUT_FATAL(format, ...) {}
#endif
+#define DONUT_WARN(format, ...) ::Donut::Logger::warn(format __VA_OPT__(,) __VA_ARGS__)
+#define DONUT_ERROR(format, ...) ::Donut::Logger::error(format __VA_OPT__(,) __VA_ARGS__)
+#define DONUT_FATAL(format, ...) ::Donut::Logger::fatal(format __VA_OPT__(,) __VA_ARGS__)
diff --git a/src/core/window.cpp b/src/core/window.cpp
index 9a8d1ec..8bb4d7e 100644
--- a/src/core/window.cpp
+++ b/src/core/window.cpp
@@ -24,6 +24,8 @@ namespace Donut
{
DONUT_INFO("Initializing window: ", m_title, " (", m_width, "x", m_height, ")");
+ glfwSetErrorCallback(glfw_error_callback); // before init, so a failing init says why
+
if (!s_glfw_initialized)
{
int success = glfwInit();
@@ -44,23 +46,26 @@ namespace Donut
}
else
{
-#ifdef __APPLE__
- // macOS caps OpenGL at 4.1 Core Profile and demands a forward-compat
- // core-profile context for anything modern (>= 3.3) to even compile.
- // skip these hints and GLFW hands you a dusty 2.1 context and every
- // single shader in the project refuses to build. so, we ask nicely:
+ // the shaders are GLSL 4.10 core, so ask for exactly that everywhere.
+ // macOS caps OpenGL at 4.1 and also wants forward-compat for anything
+ // past 2.1, or every shader in the project refuses to build.
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 1);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
+#ifdef __APPLE__
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE);
#endif
}
+ // size the window in physical pixels for the monitor's scale (windows/x11;
+ // a no-op on macos, where the framebuffer scales instead)
+ glfwWindowHint(GLFW_SCALE_TO_MONITOR, GLFW_TRUE);
m_window = glfwCreateWindow(m_width, m_height, m_title.c_str(), nullptr, nullptr);
if (!m_window)
{
DONUT_ERROR("Could not create GLFW window!");
glfwTerminate();
+ s_glfw_initialized = false;
return;
}
@@ -70,7 +75,6 @@ namespace Donut
glfwMakeContextCurrent(m_window);
glfwSetWindowUserPointer(m_window, this);
- glfwSetErrorCallback(glfw_error_callback);
glfwSetWindowCloseCallback(m_window, glfw_window_close_callback);
glfwSetWindowSizeCallback(m_window, glfw_window_size_callback);
glfwSetWindowFocusCallback(m_window, glfw_window_focus_callback);
@@ -87,6 +91,7 @@ namespace Donut
auto Window::shutdown() -> void
{
DONUT_INFO("Shutting down window: ", m_title);
+ if (!m_window) return; // creation failed, and init() already cleaned up GLFW
glfwDestroyWindow(m_window);
s_glfw_window_count--;
diff --git a/src/core/window.h b/src/core/window.h
index b8b59c5..b97874d 100644
--- a/src/core/window.h
+++ b/src/core/window.h
@@ -53,7 +53,7 @@ namespace Donut
static auto glfw_cursor_pos_callback(GLFWwindow* window, double x_pos, double y_pos) -> void;
private:
- GLFWwindow* m_window;
+ GLFWwindow* m_window = nullptr;
EventHandler m_event_handler;
std::string m_title;
diff --git a/src/main.cpp b/src/main.cpp
index a59cf8b..c813278 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,8 +1,30 @@
#include "core/application.h"
-int main()
+#include <filesystem>
+
+// every asset path is relative to the repo root, but a double-clicked exe (or a
+// ./bin/<cfg>/donut from elsewhere) starts wherever the launcher put it. walk up
+// from `start` until assets/shaders shows up and run from there.
+static auto chdir_to_project_root(std::filesystem::path start) -> bool
+{
+ namespace fs = std::filesystem;
+ std::error_code ec;
+ for (fs::path p = start; !p.empty(); p = p.parent_path())
+ {
+ if (fs::exists(p / "assets" / "shaders", ec)) { fs::current_path(p, ec); return !ec; }
+ if (p == p.root_path()) break;
+ }
+ return false;
+}
+
+int main(int /*argc*/, char** argv)
{
+ // the cwd first (the normal case), then wherever the exe itself lives. if both
+ // miss, the loaders complain with the cwd in the message.
+ std::error_code ec;
+ if (!chdir_to_project_root(std::filesystem::current_path(ec)) && argv && argv[0])
+ chdir_to_project_root(std::filesystem::absolute(argv[0], ec).parent_path());
Donut::Application* app = new Donut::Application("Donut Engine - Black Hole Simulation", 1280, 720);
app->run();
delete app;
-} \ No newline at end of file
+}
diff --git a/src/platform/opengl/shader.cpp b/src/platform/opengl/shader.cpp
index ca63c14..7319f8b 100644
--- a/src/platform/opengl/shader.cpp
+++ b/src/platform/opengl/shader.cpp
@@ -6,6 +6,8 @@
#include <fstream>
#include <iostream>
#include <vector>
+#include <filesystem>
+#include "core/log.h"
namespace Donut
{
@@ -93,6 +95,13 @@ namespace Donut
in.read(&result[0], size);
}
}
+ else
+ {
+ std::error_code ec; // u8string: msvc's string() throws on a cwd outside the ANSI code page
+ const auto cwd = std::filesystem::current_path(ec).u8string();
+ DONUT_ERROR("shader file not found: {} (cwd {}; generated shaders live in assets/shaders/generated)",
+ filepath, std::string(cwd.begin(), cwd.end()));
+ }
return result;
}
@@ -140,7 +149,7 @@ namespace Donut
int max_length = 0;
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &max_length);
std::vector<char> info_log(max_length);
- glGetShaderInfoLog(shader, max_length, &max_length, &info_log[0]);
+ if (max_length > 0) glGetShaderInfoLog(shader, max_length, &max_length, info_log.data()); // [0] on an empty vector trips msvc's debug STL
glDeleteShader(shader);
for (auto id : glShaderIDs)
glDeleteShader(id);
@@ -167,7 +176,7 @@ namespace Donut
int max_length = 0;
glGetProgramiv(m_renderer_id, GL_INFO_LOG_LENGTH, &max_length);
std::vector<char> info_log(max_length);
- glGetProgramInfoLog(m_renderer_id, max_length, &max_length, &info_log[0]);
+ if (max_length > 0) glGetProgramInfoLog(m_renderer_id, max_length, &max_length, info_log.data());
glDeleteProgram(m_renderer_id);
for (auto id : glShaderIDs)
glDeleteShader(id);
diff --git a/src/platform/opengl/texture.cpp b/src/platform/opengl/texture.cpp
index 5036ca6..41d14d5 100644
--- a/src/platform/opengl/texture.cpp
+++ b/src/platform/opengl/texture.cpp
@@ -188,15 +188,15 @@ namespace Donut
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
- auto equirect_shader = Shader::create("assets/shaders/EquirectToCubemap.glsl");
- if (!equirect_shader)
+ // the slang-generated capture shader, the same source the vulkan path uses
+ auto equirect_shader = Shader::create("assets/shaders/generated/equirect_to_cubemap.glsl");
+ uint32_t shader_program = equirect_shader ? equirect_shader->get_renderer_id() : 0;
+ if (!shader_program)
{
- DONUT_ERROR("Failed to create equirectangular to cubemap shader");
+ DONUT_ERROR("equirect_to_cubemap shader missing or failed to compile; the sky stays dark");
return;
}
- uint32_t shader_program = equirect_shader->get_renderer_id();
-
float vertices[] =
{
-1.0f, 1.0f, -1.0f, -1.0f, -1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, -1.0f, 1.0f, -1.0f,
@@ -231,7 +231,7 @@ namespace Donut
glUniform1i(glGetUniformLocation(shader_program, "u_EquirectangularMap"), 0);
// EquirectToCubemap is authored in Slang (row-major); transpose glm's
// column-major matrices on upload (GL_TRUE) to match.
- glUniformMatrix4fv(glGetUniformLocation(shader_program, "u_Projection"), 1, GL_TRUE, &capture_projection[0][0]);
+ glUniformMatrix4fv(glGetUniformLocation(shader_program, "u_projection"), 1, GL_TRUE, &capture_projection[0][0]);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, hdr_texture);
@@ -239,7 +239,7 @@ namespace Donut
glBindFramebuffer(GL_FRAMEBUFFER, capture_fbo);
for (unsigned int i = 0; i < 6; ++i)
{
- glUniformMatrix4fv(glGetUniformLocation(shader_program, "u_View"), 1, GL_TRUE, &capture_views[i][0][0]);
+ glUniformMatrix4fv(glGetUniformLocation(shader_program, "u_view"), 1, GL_TRUE, &capture_views[i][0][0]);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, m_renderer_id, 0);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glBindVertexArray(cube_vao);
diff --git a/src/platform/vulkan/vulkan_common.h b/src/platform/vulkan/vulkan_common.h
index 22d389c..ead4d1f 100644
--- a/src/platform/vulkan/vulkan_common.h
+++ b/src/platform/vulkan/vulkan_common.h
@@ -187,6 +187,9 @@ namespace Donut::RHI
auto bind_pipeline(Pipeline* p) -> void override
{
m_pipe = static_cast<VkPipelineR*>(p);
+ // a pipeline whose shaders never loaded has no vk object; binding a null
+ // handle is driver roulette, so skip it and let draw() skip too
+ if (!m_pipe || m_pipe->m_pipeline == VK_NULL_HANDLE) { m_pipe = nullptr; return; }
vkCmdBindPipeline(m_cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, m_pipe->m_pipeline);
}
auto set_viewport(int x, int y, int w, int h, bool flip_y) -> void override
@@ -216,9 +219,9 @@ namespace Donut::RHI
auto bind_index_buffer(Buffer* ib) -> void override
{ vkCmdBindIndexBuffer(m_cmd, static_cast<VkBufferR*>(ib)->m_buf, 0, VK_INDEX_TYPE_UINT32); }
auto draw(uint32_t vertex_count) -> void override
- { flush_descriptors(); vkCmdDraw(m_cmd, vertex_count, 1, 0, 0); }
+ { if (!m_pipe) return; flush_descriptors(); vkCmdDraw(m_cmd, vertex_count, 1, 0, 0); }
auto draw_indexed(uint32_t index_count) -> void override
- { flush_descriptors(); vkCmdDrawIndexed(m_cmd, index_count, 1, 0, 0, 0); }
+ { if (!m_pipe) return; flush_descriptors(); vkCmdDrawIndexed(m_cmd, index_count, 1, 0, 0, 0); }
// allocate + write + bind a descriptor set for the current pipeline's
// declared resources, using whatever was bound since bind_pipeline.
diff --git a/src/platform/vulkan/vulkan_cubemap.cpp b/src/platform/vulkan/vulkan_cubemap.cpp
index 3ffc496..de0116e 100644
--- a/src/platform/vulkan/vulkan_cubemap.cpp
+++ b/src/platform/vulkan/vulkan_cubemap.cpp
@@ -2,6 +2,7 @@
#include "stb_image.h"
#include <glm/gtc/matrix_transform.hpp>
+#include <glm/gtc/packing.hpp>
namespace Donut::RHI
{
@@ -36,10 +37,22 @@ namespace Donut::RHI
csm.addressModeU = csm.addressModeV = csm.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
vkCreateSampler(m_device, &csm, nullptr, &tex->m_sampler);
+ // the capture shaders come first: if they're missing (no generated/ dir)
+ // take the same dark road as a missing hdr, rather than handing the driver
+ // two garbage module handles.
+ VkShaderModule vmod = VK_NULL_HANDLE, fmod = VK_NULL_HANDLE;
+ const bool shaders_ok = create_shader_module("assets/shaders/generated/equirect_to_cubemap.vertexMain.spv", vmod)
+ && create_shader_module("assets/shaders/generated/equirect_to_cubemap.fragmentMain.spv", fmod);
+ // bottom row first, same as the GL loader: SampleSphericalMap sends "up" to
+ // v = 1, so the top of the panorama has to be the last row. without this the
+ // whole sky is upside down (the old projection flip was half-hiding that).
int w = 0, h = 0, ch = 0;
- float* pixels = stbi_loadf(path.c_str(), &w, &h, &ch, 4);
+ stbi_set_flip_vertically_on_load(true);
+ float* pixels = shaders_ok ? stbi_loadf(path.c_str(), &w, &h, &ch, 4) : nullptr;
if (!pixels)
{
+ if (vmod) vkDestroyShaderModule(m_device, vmod, nullptr);
+ if (fmod) vkDestroyShaderModule(m_device, fmod, nullptr);
DONUT_WARN("Vulkan RHI: HDRI '{}' could not be loaded; using a dark background", path);
VkCommandBufferAllocateInfo cbai{ VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO };
cbai.commandPool = m_command_pool; cbai.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; cbai.commandBufferCount = 1;
@@ -65,13 +78,14 @@ namespace Donut::RHI
}
const VkFormat eq_fmt = VK_FORMAT_R16G16B16A16_SFLOAT;
- size_t texel_count = (size_t)w * h * 4;
+ size_t texel_count = (size_t)w * (size_t)h * 4;
VkDeviceSize eq_size = (VkDeviceSize)texel_count * sizeof(uint16_t);
VkBuffer eq_staging; VkDeviceMemory eq_staging_mem;
create_buffer_raw(eq_size, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, host_vis, eq_staging, eq_staging_mem);
void* mp = nullptr; vkMapMemory(m_device, eq_staging_mem, 0, eq_size, 0, &mp);
uint16_t* dst = (uint16_t*)mp;
- for (size_t i = 0; i < texel_count; ++i) { __fp16 hf = (__fp16)pixels[i]; std::memcpy(&dst[i], &hf, sizeof(uint16_t)); }
+ // glm's half packing rather than __fp16: that one is a clang/ARM extension and MSVC has never heard of it
+ for (size_t i = 0; i < texel_count; ++i) dst[i] = glm::packHalf1x16(pixels[i]);
vkUnmapMemory(m_device, eq_staging_mem);
stbi_image_free(pixels);
@@ -141,9 +155,6 @@ namespace Donut::RHI
VkDescriptorPoolCreateInfo dpci{ VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO }; dpci.maxSets = 6; dpci.poolSizeCount = 2; dpci.pPoolSizes = psizes;
vkCreateDescriptorPool(m_device, &dpci, nullptr, &pool);
- VkShaderModule vmod, fmod;
- create_shader_module("assets/shaders/generated/equirect_to_cubemap.vertexMain.spv", vmod);
- create_shader_module("assets/shaders/generated/equirect_to_cubemap.fragmentMain.spv", fmod);
VkPipelineLayout playout;
VkPipelineLayoutCreateInfo plci{ VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO }; plci.setLayoutCount = 1; plci.pSetLayouts = &set_layout;
vkCreatePipelineLayout(m_device, &plci, nullptr, &playout);
@@ -180,8 +191,11 @@ namespace Donut::RHI
create_buffer_raw(sizeof(cube_verts), VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, host_vis, cube_vb, cube_vb_mem);
vkMapMemory(m_device, cube_vb_mem, 0, sizeof(cube_verts), 0, &mp); std::memcpy(mp, cube_verts, sizeof(cube_verts)); vkUnmapMemory(m_device, cube_vb_mem);
+ // no vulkan y-flip here. rendering into a texture and sampling it back is
+ // the same in both APIs (clip-space bottom lands on row 0 either way), so
+ // these are the exact GL capture matrices. the flip that used to sit here
+ // mirrored every face top to bottom and scrambled the sky.
glm::mat4 proj = glm::perspective(glm::radians(90.0f), 1.0f, 0.1f, 10.0f);
- proj[1][1] *= -1.0f;
glm::mat4 views[6] = {
glm::lookAt(glm::vec3(0), glm::vec3( 1, 0, 0), glm::vec3(0, -1, 0)),
glm::lookAt(glm::vec3(0), glm::vec3(-1, 0, 0), glm::vec3(0, -1, 0)),
@@ -194,7 +208,10 @@ namespace Donut::RHI
for (uint32_t i = 0; i < 6; ++i)
{
create_buffer_raw(128, VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, host_vis, ubo[i], ubo_mem[i]);
- glm::mat4 mats[2] = { glm::transpose(proj), glm::transpose(views[i]) };
+ // straight glm, no transpose: the SPIR-V marks these RowMajor and does
+ // v * M, which reads glm's column-major memory as-is (same as every
+ // other UBO here). only GL's flattened loose uniforms need the transpose.
+ glm::mat4 mats[2] = { proj, views[i] };
vkMapMemory(m_device, ubo_mem[i], 0, 128, 0, &mp); std::memcpy(mp, mats, 128); vkUnmapMemory(m_device, ubo_mem[i]);
VkDescriptorSetAllocateInfo dsai{ VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO }; dsai.descriptorPool = pool; dsai.descriptorSetCount = 1; dsai.pSetLayouts = &set_layout;
vkAllocateDescriptorSets(m_device, &dsai, &sets[i]);
diff --git a/src/platform/vulkan/vulkan_device.cpp b/src/platform/vulkan/vulkan_device.cpp
index f780f6f..54a4a3c 100644
--- a/src/platform/vulkan/vulkan_device.cpp
+++ b/src/platform/vulkan/vulkan_device.cpp
@@ -1,5 +1,15 @@
#include "vulkan_common.h"
+#include <algorithm>
+#include <filesystem>
+
+#if defined(_WIN32)
+ #ifndef NOMINMAX
+ #define NOMINMAX
+ #endif
+ #include <windows.h> // LoadLibraryExW, for the runtime probe
+#endif
+
#include <imgui.h>
#include <imgui_impl_glfw.h>
#include <imgui_impl_vulkan.h>
@@ -44,7 +54,16 @@ namespace Donut::RHI
auto VulkanDevice::create_shader_module(const std::string& path, VkShaderModule& out) const -> bool
{
auto spv = load_spirv(path);
- if (spv.empty()) { DONUT_ERROR("Vulkan RHI: failed to load SPIR-V {}", path); return false; }
+ if (spv.empty())
+ {
+ // u8string, not string(): msvc's narrow conversion throws on a cwd the
+ // ANSI code page can't spell, which would turn this log into a crash
+ std::error_code ec;
+ const auto cwd = std::filesystem::current_path(ec).u8string();
+ DONUT_ERROR("Vulkan RHI: failed to load SPIR-V {} (cwd {}; shaders live in assets/shaders/generated, see tools/compile-shaders.sh)",
+ path, std::string(cwd.begin(), cwd.end()));
+ return false;
+ }
VkShaderModuleCreateInfo ci{ VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO };
ci.codeSize = spv.size() * 4; ci.pCode = spv.data();
return vkCreateShaderModule(m_device, &ci, nullptr, &out) == VK_SUCCESS;
@@ -190,8 +209,15 @@ namespace Donut::RHI
vkQueueSubmit(m_graphics_queue, 1, &si, VK_NULL_HANDLE); vkQueueWaitIdle(m_graphics_queue);
void* mp = nullptr; vkMapMemory(m_device, mem, 0, sz, 0, &mp);
- std::memcpy(out.data(), mp, sz); // RGBA8_UNORM, top-down (top-left origin)
+ std::memcpy(out.data(), mp, sz);
vkUnmapMemory(m_device, mem);
+ // off-screen targets hold the same rows as on GL: clip-space bottom lands on
+ // row 0 in both APIs, and the present pass flips it upright. so flip here
+ // too, exactly like the GL readback, or the export comes out upside down.
+ const size_t row = (size_t)w * 4;
+ for (uint32_t y = 0; y < h / 2; ++y)
+ std::swap_ranges(out.begin() + (std::ptrdiff_t)(y * row), out.begin() + (std::ptrdiff_t)((y + 1) * row),
+ out.begin() + (std::ptrdiff_t)((h - 1 - y) * row));
vkFreeCommandBuffers(m_device, m_command_pool, 1, &cmd);
vkDestroyBuffer(m_device, buf, nullptr); vkFreeMemory(m_device, mem, nullptr);
}
@@ -227,8 +253,12 @@ namespace Donut::RHI
vkQueueSubmit(m_graphics_queue, 1, &si, VK_NULL_HANDLE); vkQueueWaitIdle(m_graphics_queue);
void* mp = nullptr; vkMapMemory(m_device, mem, 0, sz, 0, &mp);
- std::memcpy(out.data(), mp, sz); // R32G32B32A32_SFLOAT, top-down
+ std::memcpy(out.data(), mp, sz); // R32G32B32A32_SFLOAT
vkUnmapMemory(m_device, mem);
+ const size_t row = (size_t)w * 4; // same flip as the RGBA8 readback above
+ for (uint32_t y = 0; y < h / 2; ++y)
+ std::swap_ranges(out.begin() + (std::ptrdiff_t)(y * row), out.begin() + (std::ptrdiff_t)((y + 1) * row),
+ out.begin() + (std::ptrdiff_t)((h - 1 - y) * row));
vkFreeCommandBuffers(m_device, m_command_pool, 1, &cmd);
vkDestroyBuffer(m_device, buf, nullptr); vkFreeMemory(m_device, mem, nullptr);
}
@@ -299,6 +329,16 @@ namespace Donut::RHI
auto create_vulkan_device() -> Scope<Device> { return create_scope<VulkanDevice>(); }
+ auto vulkan_runtime_available() -> bool
+ {
+#if defined(_WIN32)
+ // stays loaded on purpose: the delay-load thunks bind to this module later
+ return LoadLibraryExW(L"vulkan-1.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32) != nullptr;
+#else
+ return true; // macOS links the loader directly, and there's no Windows-style delay-load
+#endif
+ }
+
auto vulkan_prepare_glfw() -> void
{
#ifdef __APPLE__
diff --git a/src/platform/vulkan/vulkan_device.h b/src/platform/vulkan/vulkan_device.h
index 85cc5ca..31f5842 100644
--- a/src/platform/vulkan/vulkan_device.h
+++ b/src/platform/vulkan/vulkan_device.h
@@ -14,4 +14,8 @@ namespace Donut::RHI
// the loader the app links against (its own dlopen fails on macOS/Homebrew)
// and configures the MoltenVK ICD / layer paths.
auto vulkan_prepare_glfw() -> void;
+
+ // whether a Vulkan loader exists on this machine at all. on windows vulkan-1.dll
+ // is delay-loaded, so this has to say yes before any vk* call; true elsewhere.
+ auto vulkan_runtime_available() -> bool;
}
diff --git a/src/platform/vulkan/vulkan_resources.cpp b/src/platform/vulkan/vulkan_resources.cpp
index ee2091b..05cb1be 100644
--- a/src/platform/vulkan/vulkan_resources.cpp
+++ b/src/platform/vulkan/vulkan_resources.cpp
@@ -77,7 +77,10 @@ namespace Donut::RHI
VkImageCreateInfo ici{ VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO };
ici.imageType = VK_IMAGE_TYPE_2D; ici.format = cfmt; ici.extent = { (uint32_t)w, (uint32_t)h, 1 };
ici.mipLevels = 1; ici.arrayLayers = 1; ici.samples = VK_SAMPLE_COUNT_1_BIT;
- ici.tiling = VK_IMAGE_TILING_OPTIMAL; ici.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
+ // TRANSFER_SRC because export reads targets back with a copy. MoltenVK never
+ // minded it missing; a desktop driver is within its rights to hand back garbage.
+ ici.tiling = VK_IMAGE_TILING_OPTIMAL;
+ ici.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
vkCreateImage(m_device, &ici, nullptr, &rt->m_image);
VkMemoryRequirements req{}; vkGetImageMemoryRequirements(m_device, rt->m_image, &req);
VkMemoryAllocateInfo ai{ VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO };
diff --git a/src/platform/vulkan/vulkan_swapchain.cpp b/src/platform/vulkan/vulkan_swapchain.cpp
index cbd4512..65b510c 100644
--- a/src/platform/vulkan/vulkan_swapchain.cpp
+++ b/src/platform/vulkan/vulkan_swapchain.cpp
@@ -11,19 +11,44 @@ namespace Donut::RHI
const char** glfwExts = glfwGetRequiredInstanceExtensions(&glfwExtCount);
if (!glfwExts) { DONUT_ERROR("Vulkan RHI: GLFW reports no surface support"); return false; }
std::vector<const char*> exts(glfwExts, glfwExts + glfwExtCount);
- exts.push_back(VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME);
- exts.push_back(VK_KHR_GET_PHYSICAL_DEVICE_PROPERTIES_2_EXTENSION_NAME);
+ // the portability bits are how MoltenVK gets listed at all (it's a
+ // "portability" driver, not a conformant one). a desktop loader may not
+ // offer the extension, and asking for one it doesn't have fails the whole
+ // instance, so only ask when it's on the menu.
+ uint32_t iec = 0; vkEnumerateInstanceExtensionProperties(nullptr, &iec, nullptr);
+ std::vector<VkExtensionProperties> iexts(iec);
+ vkEnumerateInstanceExtensionProperties(nullptr, &iec, iexts.data());
+ auto has_ext = [&](const char* name)
+ {
+ for (const auto& e : iexts) if (std::strcmp(e.extensionName, name) == 0) return true;
+ return false;
+ };
+ VkInstanceCreateFlags flags = 0;
+#ifdef VK_KHR_portability_enumeration // older SDK headers don't have it at all
+ if (has_ext(VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME))
+ {
+ exts.push_back(VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME);
+ flags |= VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR;
+ }
+#endif
+ if (has_ext(VK_KHR_GET_PHYSICAL_DEVICE_PROPERTIES_2_EXTENSION_NAME))
+ exts.push_back(VK_KHR_GET_PHYSICAL_DEVICE_PROPERTIES_2_EXTENSION_NAME);
+
+ // validation only in debug builds: it's slow, and with the SDK installed on
+ // windows it would otherwise load for every release run too.
std::vector<const char*> layers;
+#ifdef DONUT_DEBUG
uint32_t layer_count = 0; vkEnumerateInstanceLayerProperties(&layer_count, nullptr);
std::vector<VkLayerProperties> avail(layer_count);
vkEnumerateInstanceLayerProperties(&layer_count, avail.data());
for (const auto& l : avail)
if (std::strcmp(l.layerName, "VK_LAYER_KHRONOS_validation") == 0)
layers.push_back("VK_LAYER_KHRONOS_validation");
+#endif
VkInstanceCreateInfo ici{ VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO };
- ici.flags = VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR;
+ ici.flags = flags;
ici.pApplicationInfo = &app;
ici.enabledExtensionCount = (uint32_t)exts.size(); ici.ppEnabledExtensionNames = exts.data();
ici.enabledLayerCount = (uint32_t)layers.size(); ici.ppEnabledLayerNames = layers.data();
@@ -31,6 +56,7 @@ namespace Donut::RHI
if (r != VK_SUCCESS && !layers.empty())
{
DONUT_WARN("Vulkan RHI: validation layer unavailable, continuing without it");
+ layers.clear();
ici.enabledLayerCount = 0; ici.ppEnabledLayerNames = nullptr;
r = vkCreateInstance(&ici, nullptr, &m_instance);
}
@@ -46,19 +72,30 @@ namespace Donut::RHI
if (count == 0) { DONUT_ERROR("Vulkan RHI: no physical devices"); return false; }
std::vector<VkPhysicalDevice> devices(count);
vkEnumeratePhysicalDevices(m_instance, &count, devices.data());
- m_physical = devices[0];
- uint32_t q = 0; vkGetPhysicalDeviceQueueFamilyProperties(m_physical, &q, nullptr);
- std::vector<VkQueueFamilyProperties> qfams(q);
- vkGetPhysicalDeviceQueueFamilyProperties(m_physical, &q, qfams.data());
- bool fg = false, fp = false;
- for (uint32_t i = 0; i < q; ++i)
+ // pick the best gpu that can both draw and present: a discrete card over an
+ // integrated one (laptops tend to list the igpu first), never one that can't
+ // present to our surface.
+ int best = -1;
+ for (VkPhysicalDevice pd : devices)
{
- if (!fg && (qfams[i].queueFlags & VK_QUEUE_GRAPHICS_BIT)) { m_graphics_family = i; fg = true; }
- VkBool32 present = VK_FALSE; vkGetPhysicalDeviceSurfaceSupportKHR(m_physical, i, m_surface, &present);
- if (!fp && present) { m_present_family = i; fp = true; }
+ uint32_t q = 0; vkGetPhysicalDeviceQueueFamilyProperties(pd, &q, nullptr);
+ std::vector<VkQueueFamilyProperties> qfams(q);
+ vkGetPhysicalDeviceQueueFamilyProperties(pd, &q, qfams.data());
+ uint32_t gfam = UINT32_MAX, pfam = UINT32_MAX;
+ for (uint32_t i = 0; i < q; ++i)
+ {
+ if (gfam == UINT32_MAX && (qfams[i].queueFlags & VK_QUEUE_GRAPHICS_BIT)) gfam = i;
+ VkBool32 present = VK_FALSE; vkGetPhysicalDeviceSurfaceSupportKHR(pd, i, m_surface, &present);
+ if (pfam == UINT32_MAX && present) pfam = i;
+ }
+ if (gfam == UINT32_MAX || pfam == UINT32_MAX) continue;
+ VkPhysicalDeviceProperties pp{}; vkGetPhysicalDeviceProperties(pd, &pp);
+ const int score = pp.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU ? 3
+ : pp.deviceType == VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU ? 2 : 1;
+ if (score > best) { best = score; m_physical = pd; m_graphics_family = gfam; m_present_family = pfam; }
}
- if (!fg || !fp) { DONUT_ERROR("Vulkan RHI: no graphics/present queue"); return false; }
+ if (best < 0) { DONUT_ERROR("Vulkan RHI: no gpu with both a graphics and a present queue"); return false; }
std::vector<const char*> dev_exts = { VK_KHR_SWAPCHAIN_EXTENSION_NAME };
uint32_t dec = 0; vkEnumerateDeviceExtensionProperties(m_physical, nullptr, &dec, nullptr);
@@ -295,14 +332,26 @@ namespace Donut::RHI
auto VulkanDevice::recreate_swapchain() -> bool
{
+ // minimized (0x0 on windows): wait it out, but not through a close request,
+ // or closing a minimized window from the taskbar hangs the process
int w = 0, h = 0; glfwGetFramebufferSize(m_window, &w, &h);
- while (w == 0 || h == 0) { glfwGetFramebufferSize(m_window, &w, &h); glfwWaitEvents(); }
+ while ((w == 0 || h == 0) && !glfwWindowShouldClose(m_window)) { glfwWaitEvents(); glfwGetFramebufferSize(m_window, &w, &h); }
+ if (w == 0 || h == 0) return false; // closing; keep the old swapchain until shutdown
m_width = w; m_height = h;
vkDeviceWaitIdle(m_device);
cleanup_swapchain();
if (!create_swapchain()) return false;
if (!create_image_views()) return false;
if (!create_depth_and_framebuffers())return false;
+ // a new swapchain can come back with a different image count, and the
+ // present semaphores are per image
+ if (m_render_finished.size() != m_images.size())
+ {
+ for (auto s : m_render_finished) vkDestroySemaphore(m_device, s, nullptr);
+ m_render_finished.assign(m_images.size(), VK_NULL_HANDLE);
+ VkSemaphoreCreateInfo sci{ VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO };
+ for (auto& s : m_render_finished) VKD_CHECK(vkCreateSemaphore(m_device, &sci, nullptr, &s));
+ }
m_images_in_flight.assign(m_images.size(), VK_NULL_HANDLE);
return true;
}
diff --git a/src/ui/ui_layer.cpp b/src/ui/ui_layer.cpp
index 90ddb7c..a65000e 100644
--- a/src/ui/ui_layer.cpp
+++ b/src/ui/ui_layer.cpp
@@ -140,7 +140,7 @@ namespace Donut
// the theme: soft-rounded cards on a near-black ground, 1px borders for edges,
// and the accent reserved for "this is live / selected" — slider grabs, checks,
// the active tab's underline, the one primary button per panel.
- static auto apply_donut_style() -> void
+ static auto apply_donut_style(float dpi_scale) -> void
{
ImGuiStyle& s = ImGui::GetStyle();
s.WindowRounding = 8.0f; s.ChildRounding = 6.0f; s.FrameRounding = 6.0f;
@@ -216,6 +216,9 @@ namespace Donut
c[ImGuiCol_TableRowBgAlt] = ImVec4(1.0f, 1.0f, 1.0f, 0.025f);
c[ImGuiCol_TextSelectedBg] = amber_dim;
c[ImGuiCol_DragDropTarget] = kAmberHi;
+
+ // everything above is in 1x pixels; a 200% desktop wants it all doubled.
+ if (dpi_scale != 1.0f) { s.ScaleAllSizes(dpi_scale); s.FontScaleDpi = dpi_scale; }
}
// inspector row: label in a fixed left column, the control fills the rest.
@@ -262,8 +265,9 @@ namespace Donut
}
}
- auto UILayer::init() -> void
+ auto UILayer::init(float dpi_scale) -> void
{
+ m_dpi_scale = dpi_scale;
ImGuiIO& io = ImGui::GetIO();
m_font_body = io.Fonts->AddFontFromFileTTF("assets/fonts/inter/static/Inter_18pt-Regular.ttf", kBodySize);
m_font_heading = io.Fonts->AddFontFromFileTTF("assets/fonts/inter/static/Inter_18pt-SemiBold.ttf", kHeadSize);
@@ -329,7 +333,7 @@ namespace Donut
auto UILayer::draw(const UIContext& ctx) -> View
{
static bool styled = false;
- if (!styled) { apply_donut_style(); styled = true; }
+ if (!styled) { apply_donut_style(m_dpi_scale); styled = true; }
ImGuizmo::BeginFrame(); // the Properties panel draws the scene gizmo
@@ -991,7 +995,11 @@ namespace Donut
};
char buf[64];
row("Device", ctx.device_name.empty() ? "GPU" : ctx.device_name.c_str());
+#ifdef __APPLE__
row("API", RendererAPI::get_api() == RendererAPI::API::Vulkan ? "Vulkan (MoltenVK)" : "OpenGL 4.1");
+#else
+ row("API", RendererAPI::get_api() == RendererAPI::API::Vulkan ? "Vulkan" : "OpenGL");
+#endif
row("Workspace", tab.label);
std::snprintf(buf, sizeof(buf), "%.0f x %.0f", io.DisplaySize.x * io.DisplayFramebufferScale.x,
io.DisplaySize.y * io.DisplayFramebufferScale.y);
diff --git a/src/ui/ui_layer.h b/src/ui/ui_layer.h
index 4479844..bcfff23 100644
--- a/src/ui/ui_layer.h
+++ b/src/ui/ui_layer.h
@@ -30,7 +30,7 @@ namespace Donut
// loads the Inter faces and registers the workspace ini section on the
// live ImGui context. call once after the device's init_imgui() and before
// the first draw(): ImGui reads imgui.ini on its first NewFrame.
- auto init() -> void;
+ auto init(float dpi_scale = 1.0f) -> void;
auto draw(const UIContext& ctx) -> View;
private:
@@ -76,6 +76,7 @@ namespace Donut
int m_active_tab = 0; // index into m_tabs; the source of truth
int m_tab_request = -1; // one-frame programmatic strip selection
bool m_first_frame = true;
+ float m_dpi_scale = 1.0f; // monitor content scale (windows/linux); 1 on macos
// properties-panel gizmo op (0 = translate, 1 = scale); cast in the .cpp.
int m_gizmo_op = 0;