diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 57584bd..0251345 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -39,3 +39,6 @@ jobs: - name: Test run: nix develop -c zig build test -Dnix + + - name: Build AppImage + run: nix develop -c zig build -Dappimage -Doptimize=ReleaseFast diff --git a/README.md b/README.md index 36778e5..364366d 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Use `spacecap -h` to see available commands. ### Windows -Windows is not yet supported. This application was architected in such a way +Windows is not yet supported. Spacecap is architected in such a way that it can be cross platform. For Windows support, the audio/video capture interfaces need to be implemented. It's on the roadmap, but is not currently a priority. diff --git a/build.zig b/build.zig index dbef348..654d06c 100644 --- a/build.zig +++ b/build.zig @@ -1,4 +1,3 @@ -const util = @import("src/util.zig"); const std = @import("std"); const ffmpeg_build_util = @import("build/ffmpeg_build.zig"); const version = @import("build/version.zig"); @@ -40,6 +39,12 @@ fn add_shared_dependencies( try compile_shader(allocator, b, exe, "random.vert", "random_vert_shader"); try compile_shader(allocator, b, exe, "bgr-ycbcr-shader-2plane.comp", "bgr-ycbcr-shader-2plane"); + inline for (.{ "logo_blue.png", "logo_red.png", "logo_green.png" }) |logo_file| { + exe.root_module.addAnonymousImport(logo_file, .{ + .root_source_file = b.path("packaging/" ++ logo_file), + }); + } + // vulkan const vulkan_headers = b.dependency("vulkan_headers", .{}); const vulkan = b.dependency( @@ -52,16 +57,11 @@ fn add_shared_dependencies( exe.root_module.addImport("vulkan", vulkan); exe.addIncludePath(vulkan_headers.path("")); - // SDL3 - const sdl = b.dependency("sdl", .{ + // NOTE: SDL3 is statically linked by imguiz. + const imguiz = b.dependency("imguiz", .{ .target = target, .optimize = optimize, - .linkage = .static, - }); - exe.linkLibrary(sdl.artifact("SDL3")); - - // imguiz - const imguiz = b.dependency("imguiz", .{}).module("imguiz"); + }).module("imguiz"); exe.root_module.addImport("imguiz", imguiz); // zigrc diff --git a/build.zig.zon b/build.zig.zon index d67a086..247ef21 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -79,8 +79,8 @@ .hash = "vulkan-0.0.0-r7YtxztIAwBc30xIMx4tzfUcEmc7goWiFJkR13yeLfi8", }, .imguiz = .{ - .url = "git+https://github.com/mgerb/imguiz#7d6e1a4dfb2f31da24c9f76e0c4ba8285e89d289", - .hash = "imguiz-0.0.0-sI63zx-_DwQbtJX5KbwrOK_VnEkxIft96REu9HVBZ3W8", + .url = "git+https://github.com/mgerb/imguiz#3b56163b6f87e681856e019141f034e9966d8aa0", + .hash = "imguiz-0.0.0-sI63z3FwbgSB_xv3TSKRjoMhwSfup8tmCPMq1ZJyDqUs", }, .ffmpeg = .{ .url = "https://github.com/FFmpeg/FFmpeg/archive/refs/tags/n8.0.1.tar.gz", @@ -94,10 +94,6 @@ .url = "git+https://github.com/Aandreba/zigrc#b1e98f1cc506e975bdb27341c27d920021e7b4d8", .hash = "zigrc-1.0.0-lENlWzvQAACulrbkL9PVhWjFsWSkYhi7AmfSbCM-2Xlh", }, - .sdl = .{ - .url = "git+https://github.com/allyourcodebase/SDL3#467a4baad2acdda1bcf717065793fbbdf2569f8d", - .hash = "sdl-0.0.0-i4QD0WN-qQB1RKdRC9-6iZyTco2jIIr3y2DFrAz-D9CH", - }, .pipewire = .{ .url = "https://github.com/mgerb/pipewire/archive/refs/heads/dev.tar.gz", .hash = "pipewire-1.6.2-tKslQ3LyEgC050NwrrKyjJjEXbO-M_CvmyVMu3FLLoJd", diff --git a/build_app_image.sh b/build_app_image.sh index 3a750f0..e490cdc 100755 --- a/build_app_image.sh +++ b/build_app_image.sh @@ -13,8 +13,14 @@ LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-}" linuxdeploy \ --appdir AppDir \ --executable zig-out/linux/spacecap \ --desktop-file packaging/linux/spacecap.desktop \ - --icon-file packaging/linux/spacecap.svg \ - --exclude-library libvulkan.so.1 + --icon-file packaging/logo_blue.png \ + --exclude-library libvulkan.so.1 \ + --library "$GTK3_LIB" \ + --library "$APPINDICATOR_LIB" + +# NOTE: These extra libs are required for the tray icon functionality. +# Unfortunately we have to pull in gtk, which increases app size by 50% :( + env -u SOURCE_DATE_EPOCH APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 appimagetool AppDir zig-out/linux/spacecap-linux-x86_64.AppImage rm -rf AppDir diff --git a/flake.nix b/flake.nix index d03f174..afed33d 100644 --- a/flake.nix +++ b/flake.nix @@ -105,6 +105,11 @@ libportal zlib glib + + # Required for linux tray icon. + gtk3 + libayatana-appindicator + appimage-run # For configuring ffmpeg headers @@ -118,11 +123,18 @@ VK_LAYER_PATH = "${pkgs.vulkan-validation-layers}/share/vulkan/explicit_layer.d"; VULKAN_SDK_PATH_WINDOWS = "${pkgs.pkgsCross.mingwW64.vulkan-loader}/bin"; + GTK3_LIB = "${pkgs.gtk3}/lib/libgtk-3.so.0"; + APPINDICATOR_LIB = "${pkgs.libayatana-appindicator}/lib/libayatana-appindicator3.so.1"; # Required for Github actions or non-NixOS machines. LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.vulkan-loader pkgs.glib + + # Required for linux tray icon. + pkgs.gtk3 + pkgs.libayatana-appindicator + pkgs.libportal # SDL runtime backends (don't rely on ffmpeg closure for these). pkgs.wayland diff --git a/packaging/linux/spacecap.desktop b/packaging/linux/spacecap.desktop index adc4e00..46a099f 100644 --- a/packaging/linux/spacecap.desktop +++ b/packaging/linux/spacecap.desktop @@ -3,6 +3,6 @@ Type=Application Name=Spacecap Comment=Hardware accelerated replay capture tool Exec=spacecap -Icon=spacecap +Icon=logo_blue Categories=AudioVideo;Video; Terminal=false diff --git a/packaging/linux/spacecap.svg b/packaging/linux/spacecap.svg deleted file mode 100644 index ae85fd8..0000000 --- a/packaging/linux/spacecap.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/packaging/logo.aseprite b/packaging/logo.aseprite new file mode 100644 index 0000000..f0a3fdb Binary files /dev/null and b/packaging/logo.aseprite differ diff --git a/packaging/logo_blue.png b/packaging/logo_blue.png new file mode 100644 index 0000000..342c749 Binary files /dev/null and b/packaging/logo_blue.png differ diff --git a/packaging/logo_green.png b/packaging/logo_green.png new file mode 100644 index 0000000..807ab65 Binary files /dev/null and b/packaging/logo_green.png differ diff --git a/packaging/logo_red.png b/packaging/logo_red.png new file mode 100644 index 0000000..5f8541d Binary files /dev/null and b/packaging/logo_red.png differ diff --git a/packaging/palette.aseprite b/packaging/palette.aseprite new file mode 100644 index 0000000..5746391 Binary files /dev/null and b/packaging/palette.aseprite differ diff --git a/src/state/actor.zig b/src/state/actor.zig index ef79dbc..45411a5 100644 --- a/src/state/actor.zig +++ b/src/state/actor.zig @@ -587,9 +587,7 @@ pub const Actor = struct { self.ui_mutex.lock(); defer self.ui_mutex.unlock(); - if (!self.state.is_recording_video) { - self.state.is_recording_video = true; - } + self.state.is_recording_video = true; } fn stop_record(self: *Self) !void { @@ -614,11 +612,12 @@ pub const Actor = struct { } } - /// Dispatch an action to the actor. + /// Dispatch an action to the actor. This is thread safe. /// /// WARN: The actor uses a buffered channel, /// and it will block the caller if it fills up. - /// Be careful when using this from the UI thread. + /// Be careful when using this from the UI thread, although + /// if the buffer fills up then something is seriously wrong. pub fn dispatch(self: *Self, action: Actions) !void { log.debug("[dispatch] dispatching action: {}", .{action}); try self.action_chan.send(action); diff --git a/src/ui/app_icon.zig b/src/ui/app_icon.zig new file mode 100644 index 0000000..a0751c9 --- /dev/null +++ b/src/ui/app_icon.zig @@ -0,0 +1,37 @@ +const imguiz = @import("imguiz").imguiz; + +const APP_ICON_BLUE = @embedFile("logo_blue.png"); +const APP_ICON_RED = @embedFile("logo_red.png"); +const APP_ICON_GREEN = @embedFile("logo_green.png"); + +/// A utility for interacting app icons with SDL3. +pub const AppIcon = struct { + const Self = @This(); + + app_icon_surface_blue: *imguiz.SDL_Surface, + app_icon_surface_red: *imguiz.SDL_Surface, + app_icon_surface_green: *imguiz.SDL_Surface, + + pub fn init() Self { + return .{ + .app_icon_surface_blue = imguiz.SDL_LoadPNG_IO( + imguiz.SDL_IOFromConstMem(APP_ICON_BLUE.ptr, APP_ICON_BLUE.len).?, + true, + ).?, + .app_icon_surface_red = imguiz.SDL_LoadPNG_IO( + imguiz.SDL_IOFromConstMem(APP_ICON_RED.ptr, APP_ICON_RED.len).?, + true, + ).?, + .app_icon_surface_green = imguiz.SDL_LoadPNG_IO( + imguiz.SDL_IOFromConstMem(APP_ICON_GREEN.ptr, APP_ICON_GREEN.len).?, + true, + ).?, + }; + } + + pub fn deinit(self: *Self) void { + imguiz.SDL_DestroySurface(self.app_icon_surface_blue); + imguiz.SDL_DestroySurface(self.app_icon_surface_red); + imguiz.SDL_DestroySurface(self.app_icon_surface_green); + } +}; diff --git a/src/ui/sdl.zig b/src/ui/sdl.zig index bcec59c..e6b1ba7 100644 --- a/src/ui/sdl.zig +++ b/src/ui/sdl.zig @@ -46,6 +46,9 @@ pub fn get_sdl_vulkan_extensions(allocator: std.mem.Allocator) !SDLVulkanExtensi /// If Linux, try Wayland, fallback to x11. pub fn init() !void { + _ = imguiz.SDL_SetHint(imguiz.SDL_HINT_APP_NAME, "Spacecap"); + _ = imguiz.SDL_SetHint(imguiz.SDL_HINT_APP_ID, "spacecap"); + if (util.is_linux()) { if (try try_sdl_init_with_hint("wayland")) { log.info("[sdl_init] using wayland", .{}); diff --git a/src/ui/tray.zig b/src/ui/tray.zig new file mode 100644 index 0000000..e71b2e7 --- /dev/null +++ b/src/ui/tray.zig @@ -0,0 +1,139 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const imguiz = @import("imguiz").imguiz; +const Actor = @import("../state/actor.zig").Actor; +const AppIcon = @import("./app_icon.zig").AppIcon; + +const APP_ICON_TOOLTIP = "Spacecap"; +const APP_ICON_RECORDING_TOOLTIP = "Spacecap - Replay Buffer Recording"; + +/// Use the SDL3 API to interact with the system tray. +/// NOTE: Interactions must be on the UI thread. +pub const Tray = struct { + const log = std.log.scoped(.tray); + const Self = @This(); + + pub const State = struct { + is_recording: bool, + is_capturing: bool, + }; + + actor: *Actor, + app_icon: *AppIcon, + tray: *imguiz.SDL_Tray, + start_replay_buffer_entry: *imguiz.SDL_TrayEntry, + stop_replay_buffer_entry: *imguiz.SDL_TrayEntry, + save_replay_entry: *imguiz.SDL_TrayEntry, + state: State = .{ + .is_recording = false, + .is_capturing = false, + }, + + pub fn init(actor: *Actor, app_icon: *AppIcon) !Self { + const tray = imguiz.SDL_CreateTray(app_icon.app_icon_surface_blue, "Spacecap") orelse return error.TrayInitCreateTray; + errdefer { + imguiz.SDL_DestroyTray(tray); + } + const menu = imguiz.SDL_CreateTrayMenu(tray) orelse { + log.warn("[init_sdl_tray] failed to create tray menu: {s}", .{imguiz.SDL_GetError()}); + return error.TrayInitMenu; + }; + + const start_record_entry = try insert_tray_entry(menu, "Start Replay Buffer"); + imguiz.SDL_SetTrayEntryCallback(start_record_entry, start_record_callback, actor); + + const stop_record_entry = try insert_tray_entry(menu, "Stop Replay Buffer"); + imguiz.SDL_SetTrayEntryCallback(stop_record_entry, stop_record_callback, actor); + + const save_replay_entry = try insert_tray_entry(menu, "Save Replay"); + imguiz.SDL_SetTrayEntryCallback(save_replay_entry, save_replay_callback, actor); + + const quit_entry = imguiz.SDL_InsertTrayEntryAt(menu, -1, "Quit", imguiz.SDL_TRAYENTRY_BUTTON) orelse { + log.warn("[init_sdl_tray] failed to create tray quit entry", .{}); + return error.TrayInitInsertQuitEntry; + }; + imguiz.SDL_SetTrayEntryCallback(quit_entry, quit_callback, null); + + return .{ + .actor = actor, + .app_icon = app_icon, + .tray = tray, + .start_replay_buffer_entry = start_record_entry, + .stop_replay_buffer_entry = stop_record_entry, + .save_replay_entry = save_replay_entry, + }; + } + + pub fn deinit(self: *Self) void { + imguiz.SDL_DestroyTray(self.tray); + } + + // Update the state of the tray menu entries. + // WARNING: Not thread safe. Must be called from the UI thread. + pub fn set_state(self: *Self, state: State) void { + if (std.meta.eql(self.state, state)) { + return; + } + + if (self.state.is_recording != state.is_recording) { + imguiz.SDL_SetTrayIcon(self.tray, self.get_icon_surface_for_state(state.is_recording)); + imguiz.SDL_SetTrayTooltip(self.tray, get_tooltip_for_state(state.is_recording)); + } + + imguiz.SDL_SetTrayEntryEnabled(self.start_replay_buffer_entry, !state.is_recording and state.is_capturing); + imguiz.SDL_SetTrayEntryEnabled(self.stop_replay_buffer_entry, state.is_recording); + imguiz.SDL_SetTrayEntryEnabled(self.save_replay_entry, state.is_recording); + + self.state = state; + } + + fn insert_tray_entry(menu: *imguiz.SDL_TrayMenu, comptime name: [:0]const u8) !*imguiz.SDL_TrayEntry { + const tray_entry = imguiz.SDL_InsertTrayEntryAt(menu, -1, name, imguiz.SDL_TRAYENTRY_BUTTON) orelse { + log.warn("[init_sdl_tray] failed to create tray entry: " ++ name, .{}); + return error.TrayInitInsertStartRecordEntry; + }; + imguiz.SDL_SetTrayEntryEnabled(tray_entry, false); + return tray_entry; + } + + fn get_icon_surface_for_state(self: *Self, is_recording: bool) ?*imguiz.SDL_Surface { + return if (is_recording) + self.app_icon.app_icon_surface_red + else + self.app_icon.app_icon_surface_blue; + } + + fn get_tooltip_for_state(is_recording: bool) [*c]const u8 { + return if (is_recording) + APP_ICON_RECORDING_TOOLTIP + else + APP_ICON_TOOLTIP; + } + + fn start_record_callback(userdata: ?*anyopaque, _: ?*imguiz.SDL_TrayEntry) callconv(.c) void { + assert(userdata != null); + const actor: *Actor = @ptrCast(@alignCast(userdata)); + actor.dispatch(.start_record) catch unreachable; + } + + fn stop_record_callback(userdata: ?*anyopaque, _: ?*imguiz.SDL_TrayEntry) callconv(.c) void { + assert(userdata != null); + const actor: *Actor = @ptrCast(@alignCast(userdata)); + actor.dispatch(.stop_record) catch unreachable; + } + + fn save_replay_callback(userdata: ?*anyopaque, _: ?*imguiz.SDL_TrayEntry) callconv(.c) void { + assert(userdata != null); + const actor: *Actor = @ptrCast(@alignCast(userdata)); + actor.dispatch(.save_replay) catch unreachable; + } + + fn quit_callback(_: ?*anyopaque, _: ?*imguiz.SDL_TrayEntry) callconv(.c) void { + var event: imguiz.SDL_Event = std.mem.zeroes(imguiz.SDL_Event); + event.type = imguiz.SDL_EVENT_QUIT; + if (!imguiz.SDL_PushEvent(&event)) { + log.warn("[quit_callback] failed to push quit event: {s}", .{imguiz.SDL_GetError()}); + } + } +}; diff --git a/src/ui/ui.zig b/src/ui/ui.zig index 1b9eb05..445d760 100644 --- a/src/ui/ui.zig +++ b/src/ui/ui.zig @@ -4,6 +4,7 @@ const c = @import("imguiz").imguiz; const vk = @import("vulkan"); const rc = @import("zigrc"); const sdl = @import("./sdl.zig"); +const Tray = @import("./tray.zig").Tray; const VulkanCapturePreviewTexture = @import("../vulkan/vulkan_capture_preview_texture.zig").VulkanCapturePreviewTexture; const Actor = @import("../state/actor.zig").Actor; @@ -13,6 +14,7 @@ const draw_left_column = @import("./draw_left_column.zig").draw_left_column; const draw_video_preview = @import("./draw_video_preview.zig").draw_video_preview; const VulkanImageBuffer = @import("../vulkan/vulkan_image_buffer.zig").VulkanImageBuffer; const WaylandPresentGate = @import("./wayland_present_gate.zig").WaylandPresentGate; +const AppIcon = @import("./app_icon.zig").AppIcon; // TODO: save and restore window size const WIDTH = 1600; @@ -30,12 +32,15 @@ pub const UI = struct { allocator: std.mem.Allocator, window: ?*c.struct_SDL_Window = null, + window_icon_surface: ?*c.SDL_Surface = null, + tray: ?Tray = null, surface: ?c.VkSurfaceKHR = null, descriptor_pool: ?vk.DescriptorPool = null, swapchain_rebuild: bool = false, wayland_present_gate: ?WaylandPresentGate = null, + app_icon: AppIcon, - /// Init SDL and return new UI instance + /// Init SDL and return new UI instance. pub fn init( allocator: std.mem.Allocator, actor: *Actor, @@ -48,6 +53,7 @@ pub const UI = struct { .allocator = allocator, .actor = actor, .vulkan = vulkan, + .app_icon = .init(), }; try sdl.init(); @@ -70,6 +76,7 @@ pub const UI = struct { } } + self.app_icon.deinit(); c.cImGui_ImplVulkan_Shutdown(); c.cImGui_ImplSDL3_Shutdown(); if (self.wayland_present_gate) |*wayland_present_gate| { @@ -90,19 +97,26 @@ pub const UI = struct { self.vulkan.window = null; } - // Seems like destroying the vulkan window destroys the surface? - // if (self.surface) |surface| { - // c.SDL_Vulkan_DestroySurface(self.vkInstance(), surface, null); - // } + if (self.surface) |surface| { + c.SDL_Vulkan_DestroySurface(self.vk_instance(), surface, null); + self.surface = null; + } if (self.window) |window| { c.SDL_DestroyWindow(window); } + if (self.tray) |*tray| { + tray.deinit(); + } + if (self.window_icon_surface) |icon_surface| { + c.SDL_DestroySurface(icon_surface); + } c.SDL_Quit(); self.allocator.destroy(self); } + // TODO: Split off the main loop into its own method. fn init_vulkan(self: *Self) !void { self.window = c.SDL_CreateWindow("Spacecap", WIDTH, HEIGHT, c.SDL_WINDOW_VULKAN | c.SDL_WINDOW_RESIZABLE | c.SDL_WINDOW_HIGH_PIXEL_DENSITY); if (self.window == null) return error.SDLCreateWindowFailure; @@ -112,6 +126,20 @@ pub const UI = struct { } } + if (!c.SDL_SetWindowIcon(self.window.?, self.app_icon.app_icon_surface_blue)) { + log.warn("[init_vulkan] failed to set window icon: {s}", .{c.SDL_GetError()}); + } + + // Just log the error. Should still run without the tray. + self.tray = Tray.init(self.actor, &self.app_icon) catch |err| blk: { + log.err("[init_vulkan] unable to initialize tray: {}", .{err}); + break :blk null; + }; + errdefer { + if (self.tray) |*tray| tray.deinit(); + self.tray = null; + } + var surface: c.VkSurfaceKHR = undefined; if (!c.SDL_Vulkan_CreateSurface(self.window, self.vk_instance(), null, &surface)) { @@ -196,13 +224,13 @@ pub const UI = struct { init_info.Queue = self.vk_queue(); init_info.PipelineCache = g_PipelineCache; // TODO: maybe need? init_info.DescriptorPool = self.vk_descriptor_pool(); - init_info.RenderPass = self.vulkan.window.?.RenderPass; - init_info.Subpass = 0; init_info.MinImageCount = MIN_IMAGE_COUNT; init_info.ImageCount = self.vulkan.window.?.ImageCount; - init_info.MSAASamples = c.VK_SAMPLE_COUNT_1_BIT; init_info.Allocator = null; init_info.CheckVkResultFn = check_vk_result; + init_info.PipelineInfoMain.RenderPass = self.vulkan.window.?.RenderPass; + init_info.PipelineInfoMain.Subpass = 0; + init_info.PipelineInfoMain.MSAASamples = c.VK_SAMPLE_COUNT_1_BIT; if (!c.cImGui_ImplVulkan_Init(&init_info)) { return error.ImGuiVulkanInitFailure; } @@ -228,6 +256,7 @@ pub const UI = struct { .GlyphMaxAdvanceX = std.math.floatMax(f32), .RasterizerMultiply = 1.0, .RasterizerDensity = 1.0, + .ExtraSizeScale = 1.0, }; const font = c.ImFontAtlas_AddFontFromMemoryTTF( @@ -277,6 +306,15 @@ pub const UI = struct { wayland_present_gate.dispatch_pending(); } + if (self.tray) |*tray| { + self.actor.ui_mutex.lock(); + defer self.actor.ui_mutex.unlock(); + tray.set_state(.{ + .is_recording = self.actor.state.is_recording_video, + .is_capturing = self.actor.state.is_capturing_video, + }); + } + // Resize swap chain? var fb_width: i32 = undefined; var fb_height: i32 = undefined; @@ -311,6 +349,7 @@ pub const UI = struct { fb_width, fb_height, MIN_IMAGE_COUNT, + c.VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT, ); self.vulkan.window.?.FrameIndex = 0; self.swapchain_rebuild = false; @@ -548,7 +587,16 @@ pub const UI = struct { fn setup_vulkan_window(self: *Self) !void { self.vulkan.window = .{ .Surface = self.surface.?, - .ClearEnable = true, + .AttachmentDesc = .{ + .format = c.VK_FORMAT_UNDEFINED, + .samples = c.VK_SAMPLE_COUNT_1_BIT, + .loadOp = c.VK_ATTACHMENT_LOAD_OP_CLEAR, + .storeOp = c.VK_ATTACHMENT_STORE_OP_STORE, + .stencilLoadOp = c.VK_ATTACHMENT_LOAD_OP_DONT_CARE, + .stencilStoreOp = c.VK_ATTACHMENT_STORE_OP_DONT_CARE, + .initialLayout = c.VK_IMAGE_LAYOUT_UNDEFINED, + .finalLayout = c.VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, + }, }; // Check for WSI support @@ -606,6 +654,7 @@ pub const UI = struct { fb_width, fb_height, MIN_IMAGE_COUNT, + c.VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT, ); } }