diff --git a/CMakeLists.txt b/CMakeLists.txt
index 9abc5c33eefb63f4852c5ea90efb604918638f50..41793a624dcdd495553d2f7c986ccc81be1af85b 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -5,6 +5,7 @@ find_package(Boost)
 
 add_subdirectory(pack)
 add_subdirectory(glsl)
+add_subdirectory(display)
 
 if (TARGET imgui)
     add_subdirectory(imgui)
diff --git a/display/CMakeLists.txt b/display/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..5a103d6ebbc2c81d069e32ad4ad14882e6618a0d
--- /dev/null
+++ b/display/CMakeLists.txt
@@ -0,0 +1,19 @@
+cmake_minimum_required(VERSION 3.0)
+
+file(GLOB_RECURSE SOURCE_FILES "*.cc")
+file(GLOB_RECURSE HEADER_FILES "*.hh")
+
+# X11 (optional, but useful to acquire displays)
+
+add_library(lava-extras-display ${LAVA_LINK_TYPE} ${SOURCE_FILES} ${HEADER_FILES})
+
+find_package(X11)
+if(X11_FOUND)
+    target_compile_definitions(lava-extras-display PUBLIC -DLAVA_DISPLAY_X11_AVAILABLE)
+    target_include_directories(lava-extras-display PUBLIC "${X11_X11_INCLUDE_PATH}")
+    target_link_libraries(lava-extras-display PUBLIC "${X11_X11_LIB}")
+endif()
+
+target_include_directories(lava-extras-display PUBLIC ./)
+target_compile_options(lava-extras-display PRIVATE ${LAVA_EXTRAS_DEF_OPTIONS})
+target_link_libraries(lava-extras-display PUBLIC lava)
diff --git a/display/lava-extras/display/DisplayOutput.cc b/display/lava-extras/display/DisplayOutput.cc
new file mode 100644
index 0000000000000000000000000000000000000000..205f4d52b15c57229d3cf3872beefc5bee3021e5
--- /dev/null
+++ b/display/lava-extras/display/DisplayOutput.cc
@@ -0,0 +1,43 @@
+#include "DisplayOutput.hh"
+#include "DisplayWindow.hh"
+#include <lava/common/str_utils.hh>
+
+namespace lava {
+namespace display {
+
+DisplayOutput::DisplayOutput() {}
+
+std::shared_ptr<DisplayWindow>
+DisplayOutput::openWindow(const SharedDevice &device, uint32_t index) {
+    return std::make_shared<DisplayWindow>(device, index);
+}
+
+std::vector<const char *>
+DisplayOutput::instanceExtensions(const std::vector<const char *> &available) {
+    std::vector<const char *> result = {
+        VK_KHR_SURFACE_EXTENSION_NAME, VK_KHR_DISPLAY_EXTENSION_NAME,
+        VK_EXT_DIRECT_MODE_DISPLAY_EXTENSION_NAME};
+
+    bool has_direct_mode = util::contains(available, VK_EXT_DIRECT_MODE_DISPLAY_EXTENSION_NAME);
+    bool has_acquire = util::contains(available, "VK_EXT_acquire_xlib_display");
+
+    if (has_direct_mode && has_acquire) {
+        result.push_back(VK_EXT_DIRECT_MODE_DISPLAY_EXTENSION_NAME);
+        result.push_back("VK_EXT_acquire_xlib_display");
+    }
+
+    return result;
+}
+
+void DisplayOutput::onInstanceCreated(Instance *instance) {}
+
+std::vector<const char *> DisplayOutput::deviceExtensions() {
+    return {VK_KHR_SWAPCHAIN_EXTENSION_NAME};
+}
+
+void DisplayOutput::onPhysicalDeviceSelected(vk::PhysicalDevice phy) {}
+
+void DisplayOutput::onLogicalDeviceCreated(const SharedDevice &device) {}
+
+} // namespace display
+} // namespace lava
diff --git a/display/lava-extras/display/DisplayOutput.hh b/display/lava-extras/display/DisplayOutput.hh
new file mode 100644
index 0000000000000000000000000000000000000000..1949c642f9679e29b8608eab151dcf3bfe21e5cf
--- /dev/null
+++ b/display/lava-extras/display/DisplayOutput.hh
@@ -0,0 +1,36 @@
+#pragma once
+#include <lava/features/IFeature.hh>
+
+namespace lava {
+
+namespace display {
+
+class DisplayWindow;
+class DisplayOutput : public lava::features::IFeature {
+  public:
+    static std::shared_ptr<DisplayOutput> create() {
+        return std::make_shared<DisplayOutput>();
+    }
+
+    DisplayOutput();
+
+    std::shared_ptr<DisplayWindow> openWindow(SharedDevice const &device,
+                                              uint32_t index);
+
+    /* IFeature overrides */
+
+    std::vector<const char *> instanceExtensions(std::vector<const char *> const& available) override;
+
+    void onInstanceCreated(Instance *instance) override;
+
+    std::vector<const char *> deviceExtensions() override;
+    void onPhysicalDeviceSelected(vk::PhysicalDevice phy) override;
+    void onLogicalDeviceCreated(SharedDevice const &device) override;
+
+  protected:
+    lava::Instance *mInstance;
+    lava::Device *mDevice;
+    vk::PhysicalDevice mPhysicalDevice;
+};
+} // namespace display
+} // namespace lava
diff --git a/display/lava-extras/display/DisplayWindow.cc b/display/lava-extras/display/DisplayWindow.cc
new file mode 100644
index 0000000000000000000000000000000000000000..f6778a8f484c4b9d01f188ebcfb8fca821c64979
--- /dev/null
+++ b/display/lava-extras/display/DisplayWindow.cc
@@ -0,0 +1,168 @@
+#include "DisplayWindow.hh"
+#include <lava/common/log.hh>
+#include <lava/createinfos/Images.hh>
+#include <lava/objects/Device.hh>
+#include <lava/objects/Image.hh>
+#include <lava/objects/Instance.hh>
+
+namespace lava {
+namespace display {
+
+#ifdef LAVA_DISPLAY_X11_AVAILABLE
+#include <X11/X.h>
+void acquireDisplay(vk::Instance instance, vk::PhysicalDevice phy, vk::DisplayKHR disp)
+{
+    typedef VkResult AcquireDisplayFunc(VkPhysicalDevice, Display*, VkDisplayKHR);
+    auto func = (AcquireDisplayFunc*)instance.getProcAddr("vkAcquireXlibDisplayEXT");
+    auto *xd = XOpenDisplay(nullptr);
+    if (xd) {
+        // Running inside an X server
+        auto res = func(phy, XOpenDisplay(nullptr), disp);
+        if (res != VK_SUCCESS) {
+            lava::error() << "Could not acquire display from XServer, is it in use?";
+        }
+    }
+}
+#else
+void acquireDisplay(vk::Instance,vk::PhysicalDevice,vk::DisplayKHR) {}
+#endif
+
+void DisplayWindow::buildSwapchainWith(
+    const DisplayWindow::SwapchainBuildHandler &handler) {
+    mSwapchainHandler = handler;
+    buildSwapchain();
+}
+
+void DisplayWindow::buildSwapchain() {
+    auto caps = mDevice->physical().getSurfaceCapabilitiesKHR(mSurface);
+    auto pmodes = mDevice->physical().getSurfacePresentModesKHR(mSurface);
+    auto formats = mDevice->physical().getSurfaceFormatsKHR(mSurface);
+
+    vk::SwapchainCreateInfoKHR info;
+    info.surface = mSurface;
+    info.minImageCount = caps.minImageCount;
+    info.imageFormat = formats[0].format;
+    info.imageColorSpace = formats[0].colorSpace;
+    info.imageExtent = mSurfaceInfo.imageExtent;
+    info.imageArrayLayers = 1;
+    info.imageUsage = vk::ImageUsageFlagBits::eColorAttachment |
+                      vk::ImageUsageFlagBits::eTransferDst;
+    info.preTransform = mSurfaceInfo.transform;
+    info.presentMode = vk::PresentModeKHR::eImmediate;
+    info.clipped = true;
+
+    auto width = info.imageExtent.width;
+    auto height = info.imageExtent.height;
+
+    mChain = mDevice->handle().createSwapchainKHR(info);
+    mChainViews.clear();
+    mChainImages.clear();
+
+    {
+        auto chainHandles = mDevice->handle().getSwapchainImagesKHR(mChain);
+        auto imgCreateInfo =
+            lava::attachment2D(width, height, info.imageFormat);
+        for (vk::Image handle : chainHandles) {
+            auto image = std::make_shared<lava::Image>(
+                mDevice, imgCreateInfo, handle, vk::ImageViewType::e2D);
+            mChainViews.push_back(image->createView());
+            mChainImages.push_back(move(image));
+        }
+    }
+    mSwapchainHandler(mChainViews);
+    mSwapchainInfo = info;
+}
+
+DisplayWindow::DisplayWindow(SharedDevice device,
+                                             uint32_t index)
+    : mDevice(device) {
+
+    auto const &instance = device->instance();
+
+    auto displays = device->physical().getDisplayPropertiesKHR();
+    mDisplay = displays[index].display;
+
+    auto planes = device->physical().getDisplayPlanePropertiesKHR();
+    auto plane_idx = std::find_if(begin(planes), end(planes),
+                                  [&](vk::DisplayPlanePropertiesKHR const &p) {
+                                      return !p.currentDisplay ||
+                                             p.currentDisplay == mDisplay;
+                                  }) -
+                     begin(planes);
+
+    auto modes = device->physical().getDisplayModePropertiesKHR(mDisplay);
+    mMode = modes[0].displayMode;
+
+    auto dpcaps =
+        device->physical().getDisplayPlaneCapabilitiesKHR(mMode, plane_idx);
+
+    acquireDisplay(instance->handle(), device->physical(), mDisplay);
+
+    mSurfaceInfo = vk::DisplaySurfaceCreateInfoKHR()
+                       .setDisplayMode(mMode)
+                       .setPlaneIndex(plane_idx)
+                       .setPlaneStackIndex(planes[plane_idx].currentStackIndex)
+                       .setImageExtent(dpcaps.maxSrcExtent);
+    mSurface = instance->handle().createDisplayPlaneSurfaceKHR(mSurfaceInfo);
+
+    auto supported = mDevice->physical().getSurfaceSupportKHR(0, mSurface);
+    if (!supported)
+        throw std::runtime_error("Can't present to display surface.");
+
+    mRenderingComplete = device->handle().createSemaphore({});
+    mImageReady = device->handle().createSemaphore({});
+
+    mQueue = &device->graphicsQueue();
+}
+
+DisplayWindow::~DisplayWindow() {}
+
+DisplayWindow::Frame DisplayWindow::startFrame() {
+    assert(mChain && "You need to provide a handler for swapchain creation.");
+
+    while (true) {
+        auto res = vkAcquireNextImageKHR(mDevice->handle(), mChain, 1e9,
+                                         mImageReady, {}, &mPresentIndex);
+        if (res == VK_TIMEOUT) {
+            lava::error()
+                << "GlfwWindow::startFrame(): acquireNextImage timed out (>1s)";
+            continue;
+        }
+
+        if (res == VK_ERROR_OUT_OF_DATE_KHR || res == VK_SUBOPTIMAL_KHR) {
+            mDevice->handle().waitIdle();
+            buildSwapchain();
+            continue;
+        }
+
+        return {this};
+    }
+}
+
+DisplayWindow::Frame::Frame(DisplayWindow::Frame &&rhs) : window(rhs.window) {
+    rhs.window = nullptr;
+}
+
+DisplayWindow::Frame::~Frame() {
+    if (!window)
+        return;
+
+    vk::PresentInfoKHR info;
+    info.pImageIndices = &window->mPresentIndex;
+    info.pSwapchains = &window->mChain;
+    info.swapchainCount = 1;
+    info.pWaitSemaphores = &window->mRenderingComplete;
+    info.waitSemaphoreCount = 1;
+
+    try {
+        window->mQueue->handle().presentKHR(info);
+    } catch (vk::OutOfDateKHRError const &) {
+        window->mDevice->handle().waitIdle();
+        window->buildSwapchain();
+    }
+}
+
+DisplayWindow::Frame::Frame(DisplayWindow *parent) : window(parent) {}
+
+} // namespace features
+} // namespace lava
diff --git a/display/lava-extras/display/DisplayWindow.hh b/display/lava-extras/display/DisplayWindow.hh
new file mode 100644
index 0000000000000000000000000000000000000000..cdb10196ca7bd5fe5fee3ddaaec1fb39e250811b
--- /dev/null
+++ b/display/lava-extras/display/DisplayWindow.hh
@@ -0,0 +1,120 @@
+#pragma once
+#include <functional>
+#include <vector>
+
+#include <lava/common/NoCopy.hh>
+#include <lava/common/vulkan.hh>
+#include <lava/fwd.hh>
+
+namespace lava {
+
+namespace display {
+
+class DisplayWindow {
+  public:
+    class Frame;
+    using SwapchainBuildHandler =
+        std::function<void(std::vector<SharedImageView>)>;
+
+    void
+    buildSwapchainWith(const DisplayWindow::SwapchainBuildHandler &handler);
+
+    /// index of the image that should be drawn to next
+    uint32_t nextIndex();
+
+    /// wait for this semaphore before drawing to the current image
+    vk::Semaphore imageReady() const { return mImageReady; }
+
+    /// signal this semaphore when the image is ready to be presented
+    vk::Semaphore renderingComplete() const {
+        mRenderingCompleteCalled = true;
+        return mRenderingComplete;
+    }
+
+    /// the Format of the images in the swapchain.
+    /// Make sure the attachment format in your Framebuffers matches this.
+    vk::Format format() { return mChainFormat.format; }
+
+    uint32_t width() const { return mWidth; }
+    uint32_t height() const { return mHeight; }
+
+    /// Call this when window is resized
+    void onResize(uint32_t w, uint32_t h);
+
+    /// Don't use this directly, use openWindow in DisplayOutput instead
+    DisplayWindow(SharedDevice device, uint32_t index);
+    ~DisplayWindow();
+
+    /// Call this before you start rendering to the window.
+    /// Use the included index to select the right FBO to render to.
+    /// Automatically presents the image when the return value goes out-of-scope
+    Frame startFrame();
+
+    class Frame {
+      public:
+        LAVA_RAII_CLASS(Frame);
+        Frame(Frame &&rhs);
+        ~Frame();
+
+        uint32_t imageIndex() const { return window->mPresentIndex; }
+        vk::Semaphore imageReady() const { return window->mImageReady; }
+        vk::Semaphore renderingComplete() const {
+            return window->mRenderingComplete;
+        }
+
+        SharedImage const &image() const {
+            return window->mChainImages[window->mPresentIndex];
+        }
+
+      private:
+        Frame(DisplayWindow *parent);
+        DisplayWindow *window;
+        friend class DisplayWindow;
+    };
+
+    vk::DisplayKHR display() const { return mDisplay; }
+    vk::DisplayModeKHR mode() const { return mMode; }
+
+    vk::SurfaceKHR const &surface() const { return mSurface; }
+    vk::SurfaceFormatKHR const &surfaceFormat() const { return mChainFormat; }
+    vk::SwapchainCreateInfoKHR const &swapchainInfo() const {
+        return mSwapchainInfo;
+    }
+    size_t swapchainImageCount() const { return mChainImages.size(); }
+    std::vector<SharedImage> const &chainImages() const { return mChainImages; }
+    std::vector<SharedImageView> const &chainViews() const {
+        return mChainViews;
+    }
+
+  protected:
+    void buildSwapchain();
+
+    SharedDevice mDevice;
+    vk::SurfaceFormatKHR mChainFormat;
+
+    uint32_t mWidth;
+    uint32_t mHeight;
+
+    vk::DisplayKHR mDisplay;
+    vk::DisplayModeKHR mMode;
+
+    vk::SurfaceKHR mSurface;
+    vk::SwapchainKHR mChain;
+
+    vk::Semaphore mImageReady;
+    vk::Semaphore mRenderingComplete;
+    mutable bool mRenderingCompleteCalled = false;
+
+    uint32_t mPresentIndex;
+    lava::Queue *mQueue;
+
+    std::vector<SharedImage> mChainImages;
+    std::vector<SharedImageView> mChainViews;
+    SwapchainBuildHandler mSwapchainHandler;
+
+    vk::DisplaySurfaceCreateInfoKHR mSurfaceInfo;
+    vk::SwapchainCreateInfoKHR mSwapchainInfo;
+    friend class Frame;
+};
+} // namespace display
+} // namespace lava