diff --git a/CMakeLists.txt b/CMakeLists.txt
index 90c54cfa69095fc41300065ab26ab22d7215eef0..d68b269f36170faf92d5ba6d89b0eac9d060bbca 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -565,6 +565,28 @@ message("=======================================================================
         target_link_libraries(lava-spawn lava::demo)
 
         set_property(TARGET lava-spawn PROPERTY VS_DEBUGGER_WORKING_DIRECTORY "${PROJECT_BINARY_DIR}")
+
+        message("> lava-light")
+
+        set(LIGHT_SHADERS
+                res/light/gbuffer.frag
+                res/light/gbuffer.vert
+                res/light/lighting.frag
+                res/light/lighting.vert
+                res/light/data.inc
+                )
+
+        add_executable(lava-light 
+                ${LIBLAVA_DEMO_DIR}/light.cpp
+                ${LIGHT_SHADERS}
+                )
+
+        source_group("Shader Files" FILES ${LIGHT_SHADERS})
+
+        set_target_properties(lava-light PROPERTIES FOLDER "lava-demo")
+        target_link_libraries(lava-light lava::demo)
+
+        set_property(TARGET lava-light PROPERTY VS_DEBUGGER_WORKING_DIRECTORY "${PROJECT_BINARY_DIR}")
 endif()
 
 option(LIBLAVA_TEMPLATE "Enable Template" TRUE)
diff --git a/liblava-demo/light.cpp b/liblava-demo/light.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..a1d4f27c2d6838d2b7569cb5853618adefe82217
--- /dev/null
+++ b/liblava-demo/light.cpp
@@ -0,0 +1,460 @@
+// file      : liblava-demo/light.cpp
+// copyright : Copyright (c) 2018-present, Lava Block OÜ and contributors
+// license   : MIT; see accompanying LICENSE file
+
+#include <imgui.h>
+#include <demo.hpp>
+
+using namespace lava;
+
+// structs for interfacing with shaders
+namespace glsl
+{
+    using namespace glm;
+    using uint = uint32_t;
+#include "res/light/data.inc"
+}
+
+glsl::UboData g_ubo;
+
+struct gbuffer_attachment {
+    enum type : uint32_t {
+        albedo = 0,
+        normal,
+        metallic_roughness,
+        depth,
+        count
+    };
+
+    VkFormats requested_formats;
+    VkImageUsageFlags usage;
+    image::ptr image_handle;
+    attachment::ptr renderpass_attachment;
+    VkAttachmentReference subpass_reference;
+
+    bool create(uint32_t index);
+};
+
+using attachment_array = std::array<gbuffer_attachment, gbuffer_attachment::count>;
+attachment_array g_attachments = {
+    gbuffer_attachment{ { VK_FORMAT_R8G8B8A8_UNORM }, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT },
+    gbuffer_attachment{ { VK_FORMAT_R16G16B16A16_SFLOAT }, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT },
+    gbuffer_attachment{ { VK_FORMAT_R16G16_SFLOAT }, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT },
+    gbuffer_attachment{ { VK_FORMAT_D32_SFLOAT, VK_FORMAT_D16_UNORM }, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT },
+};
+
+using light_array = std::array<glsl::LightData, 3>;
+const light_array g_lights = {
+    glsl::LightData{ { 2.0f, 2.0f, 2.5f }, 10.0f, { 30.0f, 10.0f, 10.0f } },
+    glsl::LightData{ { -2.0f, -2.0f, -0.5f }, 10.0f, { 10.0f, 30.0f, 10.0f } },
+    glsl::LightData{ { 0.0f, 0.0f, -1.5f }, 10.0f, { 10.0f, 10.0f, 30.0f } }
+};
+
+app* g_app = nullptr;
+
+render_pass::ptr create_gbuffer_renderpass(attachment_array& attachments);
+
+int main(int argc, char* argv[]) {
+    app app("lava light", { argc, argv });
+    if (!app.setup())
+        return error::not_ready;
+
+    target_callback resize_callback;
+    app.target->add_callback(&resize_callback);
+
+    g_app = &app;
+
+    // create global immutable resources
+    // destroyed in app.add_run_end
+
+    mesh::ptr object = create_mesh(app.device, mesh_type::quad);
+    if (!object)
+        return error::create_failed;
+
+    using object_array = std::array<mat4, 2>;
+    object_array object_instances;
+
+    texture::ptr tex_normal = load_texture(app.device, "light/normal.png");
+    texture::ptr tex_roughness = load_texture(app.device, "light/roughness.png");
+    if (!tex_normal || !tex_roughness)
+        return error::create_failed;
+
+    app.staging.add(tex_normal);
+    app.staging.add(tex_roughness);
+
+    buffer ubo_buffer;
+    if (!ubo_buffer.create_mapped(app.device, nullptr, sizeof(g_ubo), VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT))
+        return error::create_failed;
+
+    buffer light_buffer;
+    if (!light_buffer.create_mapped(app.device, g_lights.data(), sizeof(g_lights), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT))
+        return error::create_failed;
+
+    const VkSamplerCreateInfo sampler_info = {
+        .sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO,
+        .magFilter = VK_FILTER_NEAREST,
+        .minFilter = VK_FILTER_NEAREST,
+        .mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST
+    };
+    VkSampler sampler;
+    if (!app.device->vkCreateSampler(&sampler_info, &sampler))
+        return error::create_failed;
+
+    // pipeline-specific resources
+    // created in app.on_create, destroyed in app.on_destroy
+
+    descriptor::pool descriptor_pool;
+
+    render_pass::ptr gbuffer_renderpass = make_render_pass(app.device);
+    descriptor::ptr gbuffer_set_layout = make_descriptor();
+    pipeline_layout::ptr gbuffer_pipeline_layout = make_pipeline_layout();
+    graphics_pipeline::ptr gbuffer_pipeline = make_graphics_pipeline(app.device);
+    VkDescriptorSet gbuffer_set = VK_NULL_HANDLE;
+
+    descriptor::ptr lighting_set_layout = make_descriptor();
+    pipeline_layout::ptr lighting_pipeline_layout = make_pipeline_layout();
+    graphics_pipeline::ptr lighting_pipeline = make_graphics_pipeline(app.device);
+    VkDescriptorSet lighting_set = VK_NULL_HANDLE;
+
+    app.on_create = [&]() {
+        const VkDescriptorPoolSizes pool_sizes = {
+            { VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 1 * 2 }, // one uniform buffer for each pass (gbuffer + lighting)
+            { VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 1 }, // light buffer
+            { VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 2 /* normal + roughness texture */ + g_attachments.size() },
+        };
+        constexpr ui32 max_sets = 2; // one for each pass
+        if (!descriptor_pool.create(app.device, pool_sizes, max_sets))
+            return false;
+
+        // gbuffer pass
+
+        gbuffer_set_layout->add_binding(0, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT);
+        gbuffer_set_layout->add_binding(1, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, VK_SHADER_STAGE_FRAGMENT_BIT);
+        gbuffer_set_layout->add_binding(2, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, VK_SHADER_STAGE_FRAGMENT_BIT);
+        if (!gbuffer_set_layout->create(app.device))
+            return false;
+        gbuffer_set = gbuffer_set_layout->allocate(descriptor_pool.get());
+        if (!gbuffer_set)
+            return false;
+
+        std::vector<VkWriteDescriptorSet> gbuffer_write_sets;
+        for (const descriptor::binding::ptr& binding : gbuffer_set_layout->get_bindings()) {
+            const VkDescriptorSetLayoutBinding& info = binding->get();
+            gbuffer_write_sets.push_back({ .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
+                                           .dstSet = gbuffer_set,
+                                           .dstBinding = info.binding,
+                                           .descriptorCount = info.descriptorCount,
+                                           .descriptorType = info.descriptorType });
+        }
+
+        gbuffer_write_sets[0].pBufferInfo = ubo_buffer.get_descriptor_info();
+        gbuffer_write_sets[1].pImageInfo = tex_normal->get_descriptor_info();
+        gbuffer_write_sets[2].pImageInfo = tex_roughness->get_descriptor_info();
+
+        app.device->vkUpdateDescriptorSets(gbuffer_write_sets.size(), gbuffer_write_sets.data());
+
+        gbuffer_pipeline_layout->add(gbuffer_set_layout);
+        gbuffer_pipeline_layout->add_range({ VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(glsl::PushConstantData) });
+        if (!gbuffer_pipeline_layout->create(app.device))
+            return false;
+
+        const VkPipelineColorBlendAttachmentState gbuffer_blend_state = {
+            .colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT
+        };
+
+        if (!gbuffer_pipeline->add_shader(file_data("light/gbuffer.vertex.spirv"), VK_SHADER_STAGE_VERTEX_BIT))
+            return false;
+        if (!gbuffer_pipeline->add_shader(file_data("light/gbuffer.fragment.spirv"), VK_SHADER_STAGE_FRAGMENT_BIT))
+            return false;
+        for (size_t i = 0; i < g_attachments.size() - 1; i++) {
+            gbuffer_pipeline->add_color_blend_attachment(gbuffer_blend_state);
+        }
+        gbuffer_pipeline->set_depth_test_and_write(true, true);
+        gbuffer_pipeline->set_depth_compare_op(VK_COMPARE_OP_LESS);
+        gbuffer_pipeline->set_rasterization_cull_mode(VK_CULL_MODE_NONE);
+        gbuffer_pipeline->set_vertex_input_binding({ 0, sizeof(vertex), VK_VERTEX_INPUT_RATE_VERTEX });
+        gbuffer_pipeline->set_vertex_input_attributes({
+            { 0, 0, VK_FORMAT_R32G32B32_SFLOAT, to_ui32(offsetof(vertex, position)) },
+            { 1, 0, VK_FORMAT_R32G32_SFLOAT, to_ui32(offsetof(vertex, uv)) },
+            { 2, 0, VK_FORMAT_R32G32B32_SFLOAT, to_ui32(offsetof(vertex, normal)) },
+        });
+        gbuffer_pipeline->set_layout(gbuffer_pipeline_layout);
+        gbuffer_pipeline->set_auto_size(true);
+
+        gbuffer_pipeline->on_process = [&](VkCommandBuffer cmd_buf) {
+            scoped_label label(cmd_buf, "gbuffer");
+
+            gbuffer_pipeline_layout->bind(cmd_buf, gbuffer_set);
+            object->bind(cmd_buf);
+
+            for (size_t i = 0; i < object_instances.size(); i++) {
+                const glsl::PushConstantData pc = {
+                    .model = object_instances[i],
+                    .color = v3(1.0f),
+                    .metallic = float(i % 2),
+                    .enableNormalMapping = 1 - (i % 2)
+                };
+                app.device->call().vkCmdPushConstants(cmd_buf, gbuffer_pipeline_layout->get(), VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT,
+                                                      0, sizeof(pc), &pc);
+                object->draw(cmd_buf);
+            }
+        };
+
+        gbuffer_renderpass = create_gbuffer_renderpass(g_attachments);
+        gbuffer_renderpass->add_front(gbuffer_pipeline);
+
+        // lighting pass
+
+        for (size_t i = 0; i < g_attachments.size(); i++) {
+            lighting_set_layout->add_binding(i, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, VK_SHADER_STAGE_FRAGMENT_BIT);
+        }
+        lighting_set_layout->add_binding(g_attachments.size() + 0, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, VK_SHADER_STAGE_FRAGMENT_BIT);
+        lighting_set_layout->add_binding(g_attachments.size() + 1, VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, VK_SHADER_STAGE_FRAGMENT_BIT);
+        if (!lighting_set_layout->create(app.device))
+            return false;
+        lighting_set = lighting_set_layout->allocate(descriptor_pool.get());
+        if (!lighting_set)
+            return false;
+
+        lighting_pipeline_layout->add(lighting_set_layout);
+        if (!lighting_pipeline_layout->create(app.device))
+            return false;
+
+        const VkPipelineColorBlendAttachmentState lighting_blend_state = {
+            .colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT
+        };
+
+        if (!lighting_pipeline->add_shader(file_data("light/lighting.vertex.spirv"), VK_SHADER_STAGE_VERTEX_BIT))
+            return false;
+        if (!lighting_pipeline->add_shader(file_data("light/lighting.fragment.spirv"), VK_SHADER_STAGE_FRAGMENT_BIT))
+            return false;
+        lighting_pipeline->add_color_blend_attachment(lighting_blend_state);
+        lighting_pipeline->set_rasterization_cull_mode(VK_CULL_MODE_NONE);
+        lighting_pipeline->set_layout(lighting_pipeline_layout);
+        lighting_pipeline->set_auto_size(true);
+
+        lighting_pipeline->on_process = [&](VkCommandBuffer cmd_buf) {
+            scoped_label label(cmd_buf, "lighting");
+
+            // run a fullscreen pass to calculate lighting, the shader loops over all lights
+            // - this is NOT very performant, but simplifies the demo
+            // - in a proper deferred renderer you most likely want to:
+            //     - render light geometries (e.g. spheres) while depth testing against the gbuffer depth
+            //     - use some kind of spatial acceleration structure for lights
+
+            lighting_pipeline_layout->bind(cmd_buf, lighting_set);
+            app.device->call().vkCmdDraw(cmd_buf, 3, 1, 0, 0);
+        };
+
+        // use lava's default backbuffer renderpass
+        render_pass::ptr lighting_renderpass = app.shading.get_pass();
+        lighting_renderpass->add_front(lighting_pipeline);
+
+        // the resize callback creates the gbuffer images and renderpass, call it once manually
+        if (!resize_callback.on_created({}, { { 0, 0 }, app.target->get_size() }))
+            return false;
+
+        // renderpasses have been created at this point, actually create the pipelines
+        if (!gbuffer_pipeline->create(gbuffer_renderpass->get()))
+            return false;
+        if (!lighting_pipeline->create(lighting_renderpass->get()))
+            return false;
+
+        return true;
+    };
+
+    app.on_process = [&](VkCommandBuffer cmd_buf, index frame) {
+        scoped_label label(cmd_buf, "on_process");
+
+        // start custom renderpass, run on_process() for each pipeline added to the renderpass
+        gbuffer_renderpass->process(cmd_buf);
+    };
+
+    app.on_update = [&](delta dt) {
+        float seconds = to_delta(app.get_running_time());
+        constexpr float distance = 1.25f;
+        const float left = -distance * (object_instances.size() - 1) * 0.5f;
+        for (size_t i = 0; i < object_instances.size(); i++) {
+            float x = left + distance * i;
+            v3 axis = v3(0.0f);
+            axis[i % 3] = 1.0f;
+            mat4 model = mat4(1.0f);
+            model = glm::translate(model, { x, 0.0f, 0.0f });
+            model = glm::rotate(model, glm::radians(std::fmod(seconds * 45.0f, 360.0f)), axis);
+            model = glm::scale(model, { 0.5f, 0.5f, 0.5f });
+            object_instances[i] = model;
+        }
+
+        return true;
+    };
+
+    // handle backbuffer resize
+
+    resize_callback.on_created = [&](VkAttachmentsRef, rect area) {
+        // update uniform buffer
+        g_ubo.camPos = { 0.0f, 0.0f, -1.25f };
+        g_ubo.lightCount = g_lights.size();
+        g_ubo.view = glm::lookAtLH(g_ubo.camPos, { 0.0f, 0.0f, 0.0f }, { 0.0f, 1.0f, 0.0f });
+        g_ubo.projection = perspective_matrix(area.get_size(), 90.0f, 3.0f);
+        g_ubo.invProjection = glm::inverse(g_ubo.projection);
+        g_ubo.resolution = area.get_size();
+        *(decltype(g_ubo)*) ubo_buffer.get_mapped_data() = g_ubo;
+
+        // (re-)create gbuffer attachments and collect views for framebuffer creation
+        VkImageViews views;
+        for (gbuffer_attachment& att : g_attachments) {
+            if (!att.image_handle->create(app.device, area.get_size()))
+                return false;
+            views.push_back(att.image_handle->get_view());
+        }
+
+        // update lighting descriptor set with new gbuffer image handles
+        std::vector<VkWriteDescriptorSet> lighting_write_sets;
+        for (const descriptor::binding::ptr& binding : lighting_set_layout->get_bindings()) {
+            const VkDescriptorSetLayoutBinding& info = binding->get();
+            lighting_write_sets.push_back({ .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
+                                            .dstSet = lighting_set,
+                                            .dstBinding = info.binding,
+                                            .descriptorCount = info.descriptorCount,
+                                            .descriptorType = info.descriptorType });
+        }
+
+        std::array<VkDescriptorImageInfo, g_attachments.size()> lighting_images;
+        for (size_t i = 0; i < g_attachments.size(); i++) {
+            lighting_images[i] = {
+                .sampler = sampler,
+                .imageView = g_attachments[i].image_handle->get_view(),
+                .imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
+            };
+            lighting_write_sets[i].pImageInfo = &lighting_images[i];
+        }
+        lighting_write_sets[g_attachments.size() + 0].pBufferInfo = ubo_buffer.get_descriptor_info();
+        lighting_write_sets[g_attachments.size() + 1].pBufferInfo = light_buffer.get_descriptor_info();
+
+        app.device->vkUpdateDescriptorSets(lighting_write_sets.size(), lighting_write_sets.data());
+
+        // create framebuffer (and renderpass if necessary)
+        if (gbuffer_renderpass->get() == VK_NULL_HANDLE)
+            return gbuffer_renderpass->create({ views }, area);
+        else
+            return gbuffer_renderpass->on_created({ views }, area);
+    };
+
+    resize_callback.on_destroyed = [&]() {
+        app.device->wait_for_idle();
+        // destroy framebuffer
+        gbuffer_renderpass->on_destroyed();
+        // destroy gbuffer attachments
+        for (gbuffer_attachment& att : g_attachments) {
+            att.image_handle->destroy();
+        }
+    };
+
+    app.imgui.on_draw = [&]() {
+        ImGui::SetNextWindowPos(ImVec2(30, 30), ImGuiCond_FirstUseEver);
+        ImGui::SetNextWindowSize(ImVec2(262, 262), ImGuiCond_FirstUseEver);
+
+        ImGui::Begin(app.get_name());
+
+        app.draw_about();
+
+        ImGui::End();
+    };
+
+    app.on_destroy = [&]() {
+        app.target->remove_callback(&resize_callback);
+        resize_callback.on_destroyed();
+
+        lighting_pipeline->destroy();
+        lighting_pipeline_layout->destroy();
+        lighting_set_layout->destroy();
+
+        gbuffer_pipeline->destroy();
+        gbuffer_pipeline_layout->destroy();
+        gbuffer_set_layout->destroy();
+        gbuffer_renderpass->destroy();
+
+        descriptor_pool.destroy();
+    };
+
+    app.add_run_end([&]() {
+        app.device->vkDestroySampler(sampler);
+        sampler = VK_NULL_HANDLE;
+
+        light_buffer.destroy();
+        ubo_buffer.destroy();
+
+        tex_roughness->destroy();
+        tex_normal->destroy();
+
+        object->destroy();
+    });
+
+    return app.run();
+}
+
+bool gbuffer_attachment::create(uint32_t index) {
+    usage |= VK_IMAGE_USAGE_SAMPLED_BIT;
+    std::optional<VkFormat> format = get_supported_format(g_app->device->get_vk_physical_device(), requested_formats, usage);
+    if (!format.has_value())
+        return false;
+
+    image_handle = make_image(*format);
+    image_handle->set_usage(usage);
+
+    renderpass_attachment = make_attachment(*format);
+    renderpass_attachment->set_op(VK_ATTACHMENT_LOAD_OP_CLEAR, VK_ATTACHMENT_STORE_OP_STORE);
+    renderpass_attachment->set_stencil_op(VK_ATTACHMENT_LOAD_OP_DONT_CARE, VK_ATTACHMENT_STORE_OP_DONT_CARE);
+    renderpass_attachment->set_layouts(VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
+
+    subpass_reference.attachment = index;
+    subpass_reference.layout = (usage & VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT)
+                                   ? VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL
+                                   : VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;
+
+    return true;
+}
+
+render_pass::ptr create_gbuffer_renderpass(attachment_array& attachments) {
+    VkClearValues clear_values(attachments.size(), { .color = { 0.0f, 0.0f, 0.0f, 1.0f } });
+    clear_values[gbuffer_attachment::depth] = { .depthStencil = { 1.0f, 0 } };
+
+    render_pass::ptr pass = make_render_pass(g_app->device);
+    pass->set_clear_values(clear_values);
+
+    VkAttachmentReferences color_attachments;
+    for (uint32_t i = 0; i < gbuffer_attachment::count; i++) {
+        if (!attachments[i].create(i))
+            return nullptr;
+        pass->add(attachments[i].renderpass_attachment);
+        if (i != gbuffer_attachment::depth)
+            color_attachments.push_back(attachments[i].subpass_reference);
+    }
+
+    subpass::ptr sub = make_subpass();
+    sub->set_color_attachments(color_attachments);
+    sub->set_depth_stencil_attachment(attachments[gbuffer_attachment::depth].subpass_reference);
+    pass->add(sub);
+
+    subpass_dependency::ptr dependency = make_subpass_dependency(VK_SUBPASS_EXTERNAL, 0);
+    // wait for previous fragment shader to finish reading before clearing attachments
+    dependency->set_stage_mask(
+        VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
+        VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT);
+    // we need a memory barrier because this isn't a standard write-after-read hazard
+    // subpass deps have an implicit attachment layout transition, so the dst access mask must be correct
+    dependency->set_access_mask(0,
+                                VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT);
+    pass->add(dependency);
+
+    dependency = make_subpass_dependency(pass->get_subpass_count() - 1, VK_SUBPASS_EXTERNAL);
+    // don't run any fragment shader (sample attachments) before we're done writing to attachments
+    dependency->set_stage_mask(VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT,
+                               VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT);
+    // make attachment writes visible to subsequent reads
+    dependency->set_access_mask(VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT,
+                                VK_ACCESS_SHADER_READ_BIT);
+    pass->add(dependency);
+
+    return pass;
+}
diff --git a/res/light/data.inc b/res/light/data.inc
new file mode 100644
index 0000000000000000000000000000000000000000..a61b8bbb65b9e057ceba48812180f3f40ee46976
--- /dev/null
+++ b/res/light/data.inc
@@ -0,0 +1,25 @@
+struct UboData
+{
+    vec3 camPos;
+    uint lightCount;
+    mat4 view;
+    mat4 projection;
+    mat4 invProjection;
+    uvec2 resolution;
+};
+
+struct PushConstantData
+{
+    mat4 model;
+    vec3 color;
+    float metallic;
+    uint enableNormalMapping;
+};
+
+struct LightData
+{
+    vec3 position;
+    float radius;
+    vec3 intensity;
+    float _padding;
+};
diff --git a/res/light/gbuffer.frag b/res/light/gbuffer.frag
new file mode 100644
index 0000000000000000000000000000000000000000..17904f116e9dbc913f7660a6b7d5ada43712ca45
--- /dev/null
+++ b/res/light/gbuffer.frag
@@ -0,0 +1,57 @@
+#version 450 core
+#extension GL_GOOGLE_include_directive : require
+
+#include "data.inc"
+
+layout (location = 0) in vec3 inPos;
+layout (location = 1) in vec2 inUV;
+layout (location = 2) in vec3 inNormal;
+
+layout(push_constant) uniform PushConstants
+{
+    PushConstantData pc;
+};
+
+layout (binding = 1) uniform sampler2D samplerNormal;
+layout (binding = 2) uniform sampler2D samplerRoughness;
+
+layout (location = 0) out vec4 outAlbedo;
+layout (location = 1) out vec4 outNormal;
+layout (location = 2) out vec2 outMetallicRoughness;
+
+// Schlüter 2013. Normal Mapping Without Precomputed Tangents.
+// http://www.thetenthplanet.de/archives/1180
+mat3 cotangentFrame(vec3 N, vec3 pos, vec2 uv)
+{
+    vec3 dPx = dFdx(pos);
+    vec3 dPy = dFdy(pos);
+    vec2 dTx = dFdx(uv);
+    vec2 dTy = dFdy(uv);
+
+    vec3 dPxC = cross(N, dPx);
+    vec3 dPyC = cross(dPy, N);
+
+    vec3 T = dPyC * dTx.x + dPxC * dTy.x;
+    vec3 B = dPyC * dTx.y + dPxC * dTy.y;
+
+    float invmax = inversesqrt(max(dot(T, T), dot(B, B)));
+
+    return mat3(T * invmax, B * invmax, N);
+}
+
+void main()
+{
+    vec3 normal = normalize(inNormal);
+    if(pc.enableNormalMapping != 0)
+    {
+        mat3 TBN = cotangentFrame(normal, inPos, inUV);
+        vec3 tangentNormal = texture(samplerNormal, inUV).xyz * 2.0 - 1.0;
+        normal = normalize(TBN * tangentNormal);
+    }
+
+    float roughness = texture(samplerRoughness, inUV).x;
+
+    outAlbedo = vec4(pc.color, 1.0);
+    outNormal = vec4(normal, 1.0);
+    outMetallicRoughness = vec2(pc.metallic, roughness);
+}
diff --git a/res/light/gbuffer.fragment.spirv b/res/light/gbuffer.fragment.spirv
new file mode 100644
index 0000000000000000000000000000000000000000..16a9aef8b901eb4f7f995163e90cef61a4b76bdd
Binary files /dev/null and b/res/light/gbuffer.fragment.spirv differ
diff --git a/res/light/gbuffer.vert b/res/light/gbuffer.vert
new file mode 100644
index 0000000000000000000000000000000000000000..c1a53258366d80e35fff16bf6805d922b6535974
--- /dev/null
+++ b/res/light/gbuffer.vert
@@ -0,0 +1,35 @@
+#version 450 core
+#extension GL_GOOGLE_include_directive : require
+
+#include "data.inc"
+
+layout (location = 0) in vec3 inPos;
+layout (location = 1) in vec2 inUV;
+layout (location = 2) in vec3 inNormal;
+
+layout(push_constant) uniform PushConstants
+{
+    PushConstantData pc;
+};
+
+layout (binding = 0) uniform Ubo
+{
+    UboData ubo;
+};
+
+layout (location = 0) out vec3 outPos;
+layout (location = 1) out vec2 outUV;
+layout (location = 2) out vec3 outNormal;
+
+void main()
+{
+    outPos = vec3(pc.model * vec4(inPos, 1.0));
+    outUV = inUV;
+    outNormal = normalize(mat3(pc.model) * inNormal);
+    // correctly render double-sided materials
+    vec3 V = normalize(ubo.camPos - outPos);
+    if(dot(outNormal, V) < 0.0)
+        outNormal = -outNormal;
+
+    gl_Position = ubo.projection * ubo.view * vec4(outPos, 1.0);
+}
diff --git a/res/light/gbuffer.vertex.spirv b/res/light/gbuffer.vertex.spirv
new file mode 100644
index 0000000000000000000000000000000000000000..f218de66b47c0da2feb228b38a491c9cc023a5c2
Binary files /dev/null and b/res/light/gbuffer.vertex.spirv differ
diff --git a/res/light/gen_spirv.bat b/res/light/gen_spirv.bat
new file mode 100644
index 0000000000000000000000000000000000000000..dedef7221ba685ff03eb039a5429ebefff63a3ba
--- /dev/null
+++ b/res/light/gen_spirv.bat
@@ -0,0 +1,7 @@
+@ECHO on
+
+glslangValidator -V -o gbuffer.fragment.spirv gbuffer.frag
+glslangValidator -V -o gbuffer.vertex.spirv gbuffer.vert
+
+glslangValidator -V -o lighting.fragment.spirv lighting.frag
+glslangValidator -V -o lighting.vertex.spirv lighting.vert
diff --git a/res/light/gen_spirv.sh b/res/light/gen_spirv.sh
new file mode 100644
index 0000000000000000000000000000000000000000..3c4b2c65deb92de6f79e9b3b210f7f63d5f77305
--- /dev/null
+++ b/res/light/gen_spirv.sh
@@ -0,0 +1,7 @@
+#!/bin/bash
+
+glslangValidator -V -o gbuffer.fragment.spirv gbuffer.frag
+glslangValidator -V -o gbuffer.vertex.spirv gbuffer.vert
+
+glslangValidator -V -o lighting.fragment.spirv lighting.frag
+glslangValidator -V -o lighting.vertex.spirv lighting.vert
diff --git a/res/light/lighting.frag b/res/light/lighting.frag
new file mode 100644
index 0000000000000000000000000000000000000000..a6e800d074d8206d3cacd98e9994e561cd8e2a3c
--- /dev/null
+++ b/res/light/lighting.frag
@@ -0,0 +1,171 @@
+#version 450 core
+#extension GL_GOOGLE_include_directive : require
+
+#include "data.inc"
+
+layout (binding = 0) uniform sampler2D samplerGbufferAlbedo;
+layout (binding = 1) uniform sampler2D samplerGbufferNormal;
+layout (binding = 2) uniform sampler2D samplerGbufferMetallicRoughness;
+layout (binding = 3) uniform sampler2D samplerGbufferDepth;
+
+layout (binding = 4) uniform Ubo
+{
+    UboData ubo;
+};
+
+layout (binding = 5) restrict readonly buffer Sbo_Lights
+{
+    LightData lights[];
+};
+
+layout(location = 0) out vec4 outFragColor;
+
+// Physically based shading, metallic + roughness workflow (GLTF 2.0 core material spec)
+// https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#metallic-roughness-material
+// Some GLSL code adapted from
+// https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#appendix-b-brdf-implementation
+// https://google.github.io/filament/Filament.md.html
+
+#define INV_PI 0.31831
+
+struct Material
+{
+    vec3 diffuse;
+    vec3 f0;
+    float a;
+};
+
+// Schlick approximation to Fresnel equation using F90 = 1
+// Schlick 1994. An Inexpensive BRDF Model for Physically based Rendering.
+vec3 F_Schlick(float VoH, vec3 F0)
+{
+    float f = pow(1.0 - VoH, 5.0);
+    return f + F0 * (1.0 - f);
+}
+
+// GGX Normal Distribution Function
+// Bruce Walter et al. 2007. Microfacet Models for Refraction through Rough Surfaces.
+float D_GGX(float NoH, float a)
+{
+    a = NoH * a;
+    float k = a / (1.0 - NoH * NoH + a * a);
+    return k * k * INV_PI;
+}
+
+// GGX Geometric Shadowing/Occlusion Function, based on Smith approach
+// height-correlated joint masking-shadowing function
+// Heitz 2014. Understanding the Masking-Shadowing Function in Microfacet-Based BRDFs.
+float V_SmithGGXCorrelated(float NoV, float NoL, float a)
+{
+    float a2 = a * a;
+    float GGXV = NoL * sqrt(NoV * NoV * (1.0 - a2) + a2);
+    float GGXL = NoV * sqrt(NoL * NoL * (1.0 - a2) + a2);
+    return 0.5 / (GGXV + GGXL);
+}
+
+// Lambertian diffuse BRDF
+vec3 diffuseBrdf(Material mat, float VoH)
+{
+    vec3 F = F_Schlick(VoH, mat.f0);
+    return (1.0 - F) * mat.diffuse * INV_PI;
+}
+
+vec3 specularBrdf(Material mat, float NoH, float VoH, float NoV, float NoL)
+{
+    float D = D_GGX(NoH, mat.a);
+    vec3 F = F_Schlick(VoH, mat.f0);
+    float V = V_SmithGGXCorrelated(NoV, NoL, mat.a);
+
+    return (D * V) * F;
+}
+
+// https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#appendix-b-brdf-implementation
+vec3 brdf(Material mat, vec3 v, vec3 l, vec3 n)
+{
+    vec3 h = normalize(l + v);
+
+    float NoV = abs(dot(n, v)) + 1e-5;
+    float NoL = clamp(dot(n, l), 0.0, 1.0);
+    float NoH = clamp(dot(n, h), 0.0, 1.0);
+    float VoH = clamp(dot(v, h), 0.0, 1.0);
+
+    return specularBrdf(mat, NoH, VoH, NoV, NoL) + diffuseBrdf(mat, VoH);
+}
+
+// inverse square falloff
+float distanceAttenuation(float distance)
+{
+    return 1.0 / max(distance * distance, 0.01 * 0.01);
+}
+
+// windowing function with smooth transition to 0
+// radius is arbitrary
+// Karis 2014. Real Shading in Unreal Engine 4.
+float distanceAttenuation(float distance, float radius)
+{
+    float nom = clamp(1.0 - pow(distance / radius, 4.0), 0.0, 1.0);
+    return nom * nom * distanceAttenuation(distance);
+}
+
+vec3 screenToEye(vec3 fragCoord, uvec2 resolution, mat4 invProjection)
+{
+    vec3 ndc = vec3(
+        2.0 * fragCoord.xy / vec2(resolution) - 1.0, // -> [-1;1]
+        fragCoord.z // [0;1]
+    );
+    vec4 eye = invProjection * vec4(ndc, 1.0);
+    return eye.xyz / eye.w;
+}
+
+// luminance-only fit of ACES LDR output transform
+// Narkowicz 2016. ACES Filmic Tone Mapping Curve.
+// https://knarkowicz.wordpress.com/2016/01/06/aces-filmic-tone-mapping-curve/
+vec3 tonemap(vec3 x)
+{
+    return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0);
+}
+
+void main()
+{
+    vec2 uv = gl_FragCoord.xy / vec2(ubo.resolution);
+    vec3 albedo = texture(samplerGbufferAlbedo, uv).rgb;
+    vec2 metallicRoughness = texture(samplerGbufferMetallicRoughness, uv).xy;
+    float metallic = metallicRoughness.x;
+    float roughness = metallicRoughness.y;
+    
+    // https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#metallic-roughness-pbr_mat
+
+    const vec3 dielectricSpecular = vec3(0.04, 0.04, 0.04);
+    const vec3 black = vec3(0.0, 0.0, 0.0);
+
+    Material mat;
+    mat.diffuse = mix(albedo * (vec3(1.0) - dielectricSpecular), black, metallic);
+    mat.f0 = mix(dielectricSpecular, albedo, metallic);
+    mat.a = roughness * roughness;
+
+    // restore fragment coordinate from depth
+    float z = texture(samplerGbufferDepth, uv).x;
+    vec3 fragCoord = vec3(gl_FragCoord.xy, z);
+    vec3 pos = screenToEye(fragCoord, ubo.resolution, ubo.invProjection);
+
+    vec3 V = normalize(ubo.camPos - pos);
+    vec3 N = texture(samplerGbufferNormal, uv).xyz;
+
+    vec3 radiance = vec3(0.0);
+    for(uint i = 0; i < ubo.lightCount; i++)
+    {
+        LightData light = lights[i];
+        float dist = distance(light.position, pos);
+        float attenuation = distanceAttenuation(dist, light.radius);
+        if(attenuation > 0.0)
+        {
+            vec3 L = normalize(light.position - pos);
+            vec3 radiance_in = light.intensity * attenuation;
+            float NoL = clamp(dot(N, L), 0.0, 1.0);
+            if(NoL > 0.0)
+                radiance += brdf(mat, V, L, N) * radiance_in * NoL;
+        }
+    }
+    
+    outFragColor = vec4(tonemap(radiance), 1.0);
+}
diff --git a/res/light/lighting.fragment.spirv b/res/light/lighting.fragment.spirv
new file mode 100644
index 0000000000000000000000000000000000000000..9ca1d97ad3df14ad750b119f502b560662a27a53
Binary files /dev/null and b/res/light/lighting.fragment.spirv differ
diff --git a/res/light/lighting.vert b/res/light/lighting.vert
new file mode 100644
index 0000000000000000000000000000000000000000..7bec00d3a8b52e4969d858e386cc762085e244dc
--- /dev/null
+++ b/res/light/lighting.vert
@@ -0,0 +1,7 @@
+#version 450 core
+
+void main()
+{
+    vec2 uv = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
+    gl_Position = vec4(uv * 2.0f + -1.0f, 0.0f, 1.0f);
+}
diff --git a/res/light/lighting.vertex.spirv b/res/light/lighting.vertex.spirv
new file mode 100644
index 0000000000000000000000000000000000000000..d664c7747f5c955ac7ff3fcabafc00644bcb5f87
Binary files /dev/null and b/res/light/lighting.vertex.spirv differ
diff --git a/res/light/normal.png b/res/light/normal.png
new file mode 100644
index 0000000000000000000000000000000000000000..c73c2672b782f9c858189cbf2dad969734435af2
Binary files /dev/null and b/res/light/normal.png differ
diff --git a/res/light/roughness.png b/res/light/roughness.png
new file mode 100644
index 0000000000000000000000000000000000000000..075f9431c83d4c8caa664530f4d4afc3f5bff086
Binary files /dev/null and b/res/light/roughness.png differ