diff --git a/src/films/display.cpp b/src/films/display.cpp
index d08ff5b2..caa0f68b 100644
--- a/src/films/display.cpp
+++ b/src/films/display.cpp
@@ -2,10 +2,12 @@
 // Created by Mike Smith on 2023/6/14.
 //
 
+#include <imgui.h>
+
 #include <runtime/image.h>
 #include <runtime/swapchain.h>
 
-#include <gui/window.h>
+#include <gui/imgui_window.h>
 
 #include <base/film.h>
 #include <base/scene.h>
@@ -96,15 +98,20 @@ class DisplayInstance final : public Film::Instance {
 
 private:
     luisa::unique_ptr<Film::Instance> _base;
-    luisa::unique_ptr<Window> _window;
+    luisa::unique_ptr<ImGuiWindow> _window;
     Image<float> _framebuffer;
-    Swapchain _swapchain;
-    Shader2D<> _blit;
+    Shader2D<int, bool, float> _blit;
+    Shader2D<Image<float>> _clear;
     Clock _clock;
-    mutable double _last_frame_time;
+    Stream *_stream{};
+    ImTextureID _background{};
+    mutable double _last_frame_time{};
+    mutable int _tone_mapping{};
+    mutable float _exposure{};
+    mutable int _background_fit{};
 
 private:
-    [[nodiscard]] auto _tone_mapping_uncharted2(Expr<float3> color) noexcept {
+    [[nodiscard]] static auto _tone_mapping_uncharted2(Expr<float3> color) noexcept {
         static constexpr auto a = 0.15f;
         static constexpr auto b = 0.50f;
         static constexpr auto c = 0.10f;
@@ -117,7 +124,7 @@ class DisplayInstance final : public Film::Instance {
         };
         return op(1.6f * color) / op(white);
     }
-    [[nodiscard]] auto _tone_mapping_aces(Expr<float3> color) noexcept {
+    [[nodiscard]] static auto _tone_mapping_aces(Expr<float3> color) noexcept {
         constexpr auto a = 2.51f;
         constexpr auto b = 0.03f;
         constexpr auto c = 2.43f;
@@ -125,17 +132,19 @@ class DisplayInstance final : public Film::Instance {
         constexpr auto e = 0.14f;
         return (color * (a * color + b)) / (color * (c * color + d) + e);
     }
-    [[nodiscard]] auto _linear_to_srgb(Expr<float3> color) noexcept {
+    [[nodiscard]] static auto _linear_to_srgb(Expr<float3> color) noexcept {
         return ite(color <= .0031308f,
                    color * 12.92f,
                    1.055f * pow(color, 1.f / 2.4f) - .055f);
     }
 
 public:
-    DisplayInstance(const Pipeline &pipeline, const Film *film,
+    DisplayInstance(const Pipeline &pipeline, const Display *film,
                     luisa::unique_ptr<Film::Instance> base) noexcept
         : Film::Instance{pipeline, film},
-          _base{std::move(base)} {}
+          _base{std::move(base)},
+          _tone_mapping{luisa::to_underlying(film->tone_mapping())},
+          _exposure{film->exposure().x} {}
 
     [[nodiscard]] Film::Accumulation read(Expr<uint2> pixel) const noexcept override {
         return _base->read(pixel);
@@ -146,32 +155,37 @@ class DisplayInstance final : public Film::Instance {
         auto &&device = pipeline().device();
         auto size = node()->resolution();
         if (!_window) {
-            _window = luisa::make_unique<Window>("Display", size);
             auto d = node<Display>();
-            auto option = SwapchainOption{
-                .display = _window->native_display(),
-                .window = _window->native_handle(),
-                .size = size,
-                .wants_hdr = d->hdr(),
-                .wants_vsync = d->vsync(),
-                .back_buffer_count = d->back_buffers()};
-            _swapchain = device.create_swapchain(*command_buffer.stream(), option);
-            _framebuffer = device.create_image<float>(
-                _swapchain.backend_storage(), size);
-            _blit = device.compile<2>([&] {
+            _stream = command_buffer.stream();
+            auto window_size = size;
+            while (std::max(window_size.x, window_size.y) > 2048u) {
+                window_size /= 2u;
+            }
+            _window = luisa::make_unique<ImGuiWindow>(
+                device, *command_buffer.stream(), "Display",
+                ImGuiWindow::Config{
+                    .size = window_size,
+                    .vsync = d->vsync(),
+                    .hdr = d->hdr(),
+                    .back_buffers = d->back_buffers()});
+            _framebuffer = device.create_image<float>(_window->swapchain().backend_storage(), size);
+            _background = reinterpret_cast<ImTextureID>(_window->register_texture(_framebuffer, TextureSampler::linear_point_zero()));
+            _blit = device.compile<2>([base = _base.get(), &framebuffer = _framebuffer](Int tonemapping, Bool ldr, Float scale) noexcept {
                 auto p = dispatch_id().xy();
-                auto exposure = d->exposure();
-                exposure = make_float3(std::exp2(exposure.x), std::exp2(exposure.y), std::exp2(exposure.z));
-                auto color = _base->read(p).average * exposure;
-                switch (d->tone_mapping()) {
-                    case Display::ToneMapping::NONE: break;
-                    case Display::ToneMapping::UNCHARTED2: color = _tone_mapping_uncharted2(color); break;
-                    case Display::ToneMapping::ACES: color = _tone_mapping_aces(color); break;
-                }
-                if (_framebuffer.storage() == PixelStorage::BYTE4) {// LDR
+                auto color = base->read(p).average * scale;
+                $switch (tonemapping) {
+                    $case (static_cast<int>(Display::ToneMapping::NONE)) {};
+                    $case (static_cast<int>(Display::ToneMapping::UNCHARTED2)) { color = _tone_mapping_uncharted2(color); };
+                    $case (static_cast<int>(Display::ToneMapping::ACES)) { color = _tone_mapping_aces(color); };
+                    $default { unreachable(); };
+                };
+                $if (ldr) {// LDR
                     color = _linear_to_srgb(color);
-                }
-                _framebuffer->write(p, make_float4(color, 1.f));
+                };
+                framebuffer->write(p, make_float4(color, 1.f));
+            });
+            _clear = device.compile<2>([](ImageFloat image) noexcept {
+                image.write(dispatch_id().xy(), make_float4(0.f));
             });
         }
         _last_frame_time = _clock.toc();
@@ -186,27 +200,75 @@ class DisplayInstance final : public Film::Instance {
     }
 
     void release() noexcept override {
-        while (_window && !_window->should_close()) {
-            _window->poll_events();
-        }
+        _window = nullptr;
         _framebuffer = {};
-        _swapchain = {};
-        _window.reset();
         _base->release();
     }
 
+private:
+    [[nodiscard]] float2 _compute_background_size(const ImGuiViewport *viewport, int fit) const noexcept {
+        auto frame_size = make_float2(_framebuffer.size());
+        auto viewport_size = make_float2(viewport->Size.x, viewport->Size.y);
+        switch (fit) {
+            case 0: {// aspect fill
+                auto aspect = frame_size.x / frame_size.y;
+                auto viewport_aspect = viewport_size.x / viewport_size.y;
+                auto ratio = aspect < viewport_aspect ? viewport_size.x / frame_size.x : viewport_size.y / frame_size.y;
+                return frame_size * ratio;
+            }
+            case 1: {// aspect fit
+                auto aspect = frame_size.x / frame_size.y;
+                auto viewport_aspect = viewport_size.x / viewport_size.y;
+                auto ratio = aspect > viewport_aspect ? viewport_size.x / frame_size.x : viewport_size.y / frame_size.y;
+                return frame_size * ratio;
+            }
+            case 2: {// stretch
+                return viewport_size;
+            }
+            default: break;
+        }
+        return viewport_size;
+    }
+
+    void _display() const noexcept {
+        auto scale = luisa::exp2(_exposure);
+        auto is_ldr = _window->framebuffer().storage() != PixelStorage::FLOAT4;
+        auto size = _framebuffer.size();
+        *_stream << _blit(_tone_mapping, is_ldr, scale).dispatch(size);
+        auto viewport = ImGui::GetMainViewport();
+        auto bg_size = _compute_background_size(viewport, _background_fit);
+        auto p_min = make_float2(viewport->Pos.x, viewport->Pos.y) +
+                     .5f * (make_float2(viewport->Size.x, viewport->Size.y) - bg_size);
+        ImGui::GetBackgroundDrawList()->AddImage(_background,
+                                                 ImVec2{p_min.x, p_min.y},
+                                                 ImVec2{p_min.x + bg_size.x, p_min.y + bg_size.y});
+        ImGui::Begin("Console", nullptr, ImGuiWindowFlags_AlwaysAutoResize);
+        {
+            ImGui::Text("Display FPS: %.2f", ImGui::GetIO().Framerate);
+            ImGui::SliderFloat("Exposure", &_exposure, -10.f, 10.f);
+            constexpr const char *const tone_mapping_names[] = {"None", "Uncharted2", "ACES"};
+            ImGui::Combo("Tone Mapping", &_tone_mapping, tone_mapping_names, std::size(tone_mapping_names));
+            constexpr const char *const fit_names[] = {"Fill", "Fit", "Stretch"};
+            ImGui::Combo("Background Fit", &_background_fit, fit_names, std::size(fit_names));
+        }
+        ImGui::End();
+    }
+
     bool show(CommandBuffer &command_buffer) const noexcept override {
+        LUISA_ASSERT(command_buffer.stream() == _stream, "Command buffer stream mismatch.");
         auto interval = 1. / node<Display>()->target_fps();
         if (auto current_time = _clock.toc();
             current_time - _last_frame_time >= interval) {
             _last_frame_time = current_time;
-            _window->poll_events();
             if (_window->should_close()) {
                 command_buffer << synchronize();
                 exit(0);// FIXME: exit gracefully
             }
-            command_buffer << _blit().dispatch(node()->resolution())
-                           << _swapchain.present(_framebuffer);
+            command_buffer << commit();
+            _window->prepare_frame();
+            *_stream << _clear(_window->framebuffer()).dispatch(_window->framebuffer().size());
+            _display();
+            _window->render_frame();
             return true;
         }
         return false;