Skip to content

The settings window

NuclearMeltdown edited this page Aug 24, 2026 · 7 revisions

The settings window

src/ui/settings_host.cpp, src/ui/settings_window.cpp, src/app.cpp

The settings can be drawn over the picture or given a window of their own, which can then be moved to a second monitor or set beside the preview. The switch is under Settings → Display.

The second window shares one Direct3D device with the preview, and sharing a device turned out to be the whole story behind a stutter that reached the desktop's own mouse cursor. This page is mostly a post-mortem, because everything here was found by measurement and several of the intermediate fixes were wrong.

How the settings are arranged

Ten tabs, grouped by what a setting affects rather than by what it technically is.

Source the card and the signal coming out of it
Picture what is done to that signal for viewing
HDR the whole subject, including what the outputs write
Audio playback, delay, levels, microphone
Recording everything that leaves CapView as a file or a camera
Encoder ffmpeg, which encoder, and what it is told
Display the window, the theme, the language, the overlays
Keys
Profiles
Updates

That distinction did real work. The switches deciding whether a recording, a screenshot or the virtual camera keeps its full range used to sit under Display — three tabs away from any of the three things they govern.

Recording and Encoder are separate because they answer different questions. Where does the file go, how big, how often is a decision about the recording; which chip encodes it and how hard it works is a decision about the machine. One is set once and the other every session. ffmpeg lives with the encoder for the same reason: it is what the encoder runs on, not a property of the recording.

HDR earns a tab rather than a section, because it is not a display setting. Its source curve belongs to the card, its tone mapping to the screen, and three of its switches decide what the recorder, the screenshots and the virtual camera write. A subject that reaches across four tabs is a subject.

Tab persistence

Which tab is open is state ImGui keeps per context, and the settings are drawn into a different context depending on whether they live in their own window. So SettingsWindow tracks it explicitly:

if (!tabRestored_) {                     // once per run, from the configuration
  tabRestored_ = true;
  activeTab_ = cfg().app.settingsTab;
  wantTab_ = activeTab_;
}
ImGuiContext* nowContext = ImGui::GetCurrentContext();
if (nowContext != tabContext_) {         // and on every context switch
  tabContext_ = nowContext;
  wantTab_ = activeTab_;
}

Without the second half, switching to the separate window dropped you back on the Source tab every time.

The stutter: three causes, each hiding the next

The symptom was that having the settings in their own window made everything stutter — including the system mouse cursor, which is what said it was not an ordinary frame-rate problem.

1. Presenting inside another window's frame

The dialog's frame was drawn and presented from inside the preview's own frame. Presenting a second swap chain part-way through another window's frame flushes everything already queued for it.

It runs after the preview's present now.

2. Drawing 235 times a second for a 25 fps source

The preview redrew on every wake-up of the message loop, and a second window on screen produces a steady stream of messages.

Measured: the whole pipeline — video shaders, colour work, readbacks for the recorder and the camera, a present — ran 235 times a second to show a source delivering 25.

Drawing is paced by the picture now: a new frame, a field falling due, or a floor of thirty a second so meters and toasts keep moving. See Latency for the loop.

This fix was wrong the first time. The first version paced against a fixed 33 ms clock, which beats against 25 fps and produced a slow visible pulse. Pacing has to be against the picture, not against a timer.

3. SetMaximumFrameLatency is device-wide

This is the one that actually cost the milliseconds.

SetMaximumFrameLatency(1) is what makes the preview's latency as short as it is — and it applies to the device, not to a swap chain. With one frame of queue and two swap chains, each present waited for the other's frame to retire.

Measured: the dialog's present cost 8–14 ms and the preview's 3 ms. At a queue of three, both are under a millisecond.

D3DContext::SetFrameLatency(UINT) raises the queue to three only while the dialog is on screen, because the rest of the time the short queue is the point.

The dialog's swap chain also uses BufferCount = 3 and ALLOW_TEARING where supported (swapchainFlags_ / presentFlags_).

Getting back into Windows' modal drag loop

Dragging a window puts Windows into a loop of its own that does not return until the mouse is released. CapView's own loop stops running and the preview stops with it — measured at 1.24 seconds frozen during a drag.

The only way back in is from a message the window receives while that loop is running.

WM_TIMER does not work

The obvious answer, and it fails:

case WM_ENTERSIZEMOVE:
  ::SetTimer(hwnd, 1, 16, nullptr);

WM_TIMER is the lowest priority message there is, and Windows only generates one when the queue is otherwise empty. During a drag the queue never is. Measured: at an interval of 8 ms it fired seven times in a second.

The timer is still set, but only as a floor for when the mouse is held still.

WM_MOVING and WM_SIZING do

case WM_MOVING:
case WM_SIZING:
  self->PumpModalFrame();
  break;   // and on to DefWindowProc, which does the actual moving

Windows sends these continuously while the window is being dragged or resized — once per mouse movement — and unlike WM_TIMER they are real messages that cannot be starved by the flood of mouse input causing the problem in the first place.

Measured after: 125 pumps a second against the timer's seven.

The ceiling matters as much as the hook

if (lastModalTick_ != 0 && now - lastModalTick_ < 30) return;

Rendering on every WM_MOVING puts a whole frame — shaders, readbacks, two presents — between each mouse movement and the window following it, which trades one kind of stutter for another. That was the second wrong fix: the preview ran beautifully and the window lagged behind the cursor.

Thirty a second, not eighty. The preview has nothing more to show than the capture card delivers, and every frame drawn here is time the window is not following the mouse.

Measured during a drag: a preview frame costs 0.6 to 0.8 ms and the window keeps running at better than thirty a second.

inFrameCallback_ guards against reentrancy, since the frame callback can itself pump messages.

What is left, and whose it is

Dragging the settings window across the preview still hitches slightly. That one is not CapView's to fix.

The work is not the problem — 0.6 to 0.8 ms a frame, measured. Two overlapping windows have to be composed together by the desktop, and one of them is repainting thirty times a second. Dragging the dialog onto another monitor, or off the preview, makes it go away.

Three smaller things that were also wrong

No taskbar button

The window was created owned but without WS_EX_APPWINDOW, which is how Windows is asked for a taskbar button. Without one, a window has nowhere to go when it is minimised, and lands as a stub in a corner of the screen the way windows did before there was a taskbar.

MakeWindowAssociation is deliberately not called — it is per-factory, not per-window, and calling it for the second window would change the first one's Alt+Enter behaviour.

The shared font atlas released the wrong texture

Both windows share one ImFontAtlas — the glyphs are the same and one copy on the GPU is enough.

But the DX11 backend stores the atlas's texture id in the atlas. Closing the second window released the texture the first one was still drawing with, and the whole interface went blank until restart.

The atlas is still shared; the texture is rebuilt on the way out with ImGui_ImplDX11_CreateDeviceObjects().

The window jumped on every switch

Switching between embedded and separate destroys and rebuilds the window, so it came up wherever Windows decided each time.

SettingsHost::placement() reads GetWindowPlacement — the restored rectangle, not the current one, so a window read while minimised or maximised is not remembered at the wrong size. App::RememberSettingsWindow() copies it into the configuration before Destroy(), and Create() checks the remembered position against the virtual screen before using it:

// A hundred pixels of title bar has to remain reachable.
if (where.x + 100 > desk.left && where.x < desk.right - 100 &&
    where.y >= desk.top && where.y < desk.bottom - 40) { x = where.x; y = where.y; }

A window remembered on a monitor that is no longer there does not come up somewhere nobody can reach it.

Is ImGui the wrong framework for this?

Reasonable question, and the answer is no — the problems above were all Direct3D and Win32 problems, not ImGui ones.

ImGui does assume it is called once per frame from whatever thread owns the device, which is exactly the assumption that makes the modal-loop problem visible. But any immediate-mode or retained-mode UI sharing a device with a latency-tuned swap chain hits the same three issues: the present ordering, the device-wide frame latency, and Windows' modal loop. A different framework would have hidden the first, made no difference to the second, and hit the third identically.

Clone this wiki locally