-
Notifications
You must be signed in to change notification settings - Fork 0
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 separate window is the default as of 2.0. It has a Direct3D device of its own, so it is fully decoupled from the preview — nothing it does can affect the preview's pacing, and the preview's size and position do not constrain it. The embedded panel stays, and not only for taste: a window capture in OBS cannot see a second window.
Ten tabs of controls before a capture card has been chosen is not an offer, it is an obstacle. Until a device is selected there is nothing but the device picker, and the other nine tabs are not rendered at all. A no device entry in the dropdown makes that state reachable without editing the configuration by hand.
Once a card is running, controls that cannot apply are absent rather than disabled. A greyed-out control still has to be read and dismissed.
| Shown when | |
|---|---|
| Native pixel grid, composite filter | the source is analogue |
| Scanlines and CRT mask | the source is 576 lines or fewer |
| Deinterlacing | the source has fields |
| HDR source curve, source peak | the source is not analogue |
Two of those deliberately do not key on analogue versus digital, because the obvious rule is wrong in both directions:
- Scanlines are keyed on line count. A RetroTINK or a MiSTer hangs off HDMI at 480p, and its owner is precisely the person who wants scanlines. Above 576 lines the shader's own gate switches them off regardless, so nothing is lost by hiding them there.
- Deinterlacing is keyed on whether there are fields. 1080i and 480i exist over HDMI. Hiding the field controls for every digital source would break the people who need them most.
The HDR tab is likewise split rather than hidden. The source half — which transfer curve arrives, how bright the source peaks — is meaningless for an analogue decoder and goes away. The display half stays, because it is not about the source at all: with the desktop in HDR mode an SDR picture still has to be written into the HDR container at a chosen white level, or it comes out too dim or too bright. That is as true of a SNES as of anything else.
SetAnalogueSource used to ask only whether the card exposes an analogue
decoder. On a hybrid card that is the wrong question: both inputs sit on one
board, the decoder is reported either way, and a console at 1080p60 over the
digital input was being called analogue.
The picture answers it. No standard in kStandards produces more than 576
lines, so a taller source did not come out of the decoder no matter what the
card can do.
A welcome screen, not the settings. It is the same screen as "no signal" — icon, wordmark, one line underneath — but the line says which key opens the settings instead of reporting that something is missing. Nothing is missing the first time.
From the second run onward the old behaviour returns and a missing device opens
the settings directly, because by then it is a problem rather than a greeting.
The state is read from whether a configuration file existed at startup:
Config::Load returns the same false for "no file" and for "broken file", and
the difference is that error is empty.
The second window used to share one Direct3D device with the preview, and that sharing turned out to be the whole story behind a stutter which reached the desktop's own mouse cursor. It has a device of its own now.
This page is mostly a post-mortem, because everything here was found by measurement and several of the intermediate fixes were wrong. The three balancing acts below are kept even though the last of them is superseded: they are how the shared device was identified as the cause in the first place.
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.
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 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.
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.
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.
This is the one that actually cost the milliseconds.
Superseded. The window now creates a Direct3D device of its own, which removes this coupling rather than balancing it — see A device of its own below. The measurement is kept because it is what pointed at the shared device in the first place.
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_).
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.
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.
case WM_MOVING:
case WM_SIZING:
self->PumpModalFrame();
break; // and on to DefWindowProc, which does the actual movingWindows 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.
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. So a ceiling went in:
if (lastModalTick_ != 0 && now - lastModalTick_ < 30) return; // wrongThat number was visible. Dragging the window dropped the preview from 60 to 33 frames — not because anything was overloaded, but because it had been decided here.
The obvious repair was to pace against the source's measured frame rate. It made things worse, and for two reasons that multiplied:
- PAL delivers 25 frames and the preview shows 50 deinterlaced fields. Pacing on the frame rate throws away half of what there is to show.
-
GetTickCountadvances in 15.6 ms steps. Every threshold rounds up to the next multiple: 40 ms becomes 46.8, which is 21.4 frames a second. That is what was measured. The old 30 ms rounded to 31.2 — the 32 seen before.
Any number is wrong here. Whether there is something new to show is known only
to the loop upstairs, which has decided it for the ordinary case all along, so
PumpModalFrame now decides nothing:
void SettingsHost::PumpModalFrame() {
if (!onFrame_ || inFrameCallback_) return;
inFrameCallback_ = true;
onFrame_();
inFrameCallback_ = false;
}The callback in App applies the same three conditions as the main loop — a new
picture, a field falling due, a slow floor (see Latency). The capture
card's event is auto-reset, so testing it with a zero wait is the same
consumption the main loop performs, and that loop is not running at the time.
The WM_TIMER floor went from 16 ms to 10, the smallest Windows accepts
(USER_TIMER_MINIMUM). A wake-up without a new picture now costs only the
question of whether there is one, so it may be asked more often than 60 Hz needs.
Measured during a drag: a preview frame costs 0.6 to 0.8 ms, and the preview runs at the source's rate rather than at a rate chosen here.
inFrameCallback_ guards against reentrancy, since the frame callback can itself
pump messages.
The three fixes above balanced a shared Direct3D device. They worked, and the window still did not feel free — so the sharing itself went.
SettingsHost::Create now calls D3D11CreateDevice and keeps its own device
and immediate context. Two things follow:
- The preview keeps a frame queue of one, permanently. It no longer has to be raised to three while the dialog is open, which was the single biggest lever on its latency being given away for the dialog's sake.
- Nothing serialises. Both windows used the same immediate context, so every ImGui draw call for the dialog went through the exact context the preview needed. Separate contexts, separate queues.
Measured with the settings window open and overlapping the preview: frame age 0.5 to 1.6 ms, against a preview that previously had to run at a queue of three to stay under a millisecond at all.
A second font atlas. A texture belongs to the device that made it, so the
two contexts cannot share one. LoadUiFont() is called again inside the host's
context — without it the window comes up in ImGui's built-in font and looks like
a different program.
And it removes a trap. The shared atlas had a sting: the DX11 backend keeps
the atlas's texture id inside the atlas, so shutting down the second context
called SetTexID(0) and released the texture the first one was still drawing
with. Closing the settings window blanked the entire interface until restart.
That was patched by rebuilding the texture on the way out; with two atlases
there is nothing to patch.
Reported against 1.7: with the settings in their own window, the preview occasionally flickered black in places. Only with the analogue, interlaced source. Gone as of 1.9, confirmed on the hardware that showed it.
It was never reproduced by instrumentation here. Two attempts, both defeated by the instrument rather than by the bug: screen capture runs at about twenty frames a second against a sixty frame preview, so a single dropped frame is caught roughly one time in three, and on moving content a per-pixel "darker than both neighbours" test flags every moving edge in the picture.
What found it was the report itself — that it only happened on the interlaced
source. That pointed at the field schedule rather than at the renderer, and the
WAIT_TIMEOUT bug below was sitting there: with the dialog open, every second
field was dropped. Fixing it made the flicker stop.
Ruled out along the way, each by reading rather than by guessing:
-
Scissor rectangles left behind by ImGui. The renderer's rasteriser state
has
ScissorEnable = FALSE, so a stale rectangle cannot clip the video. -
Undefined back-buffer content under
FLIP_DISCARD.D3DContext::BeginFrameclears the whole surface every frame. -
Repeated
SetMaximumFrameLatencycalls.D3DContext::SetFrameLatencyalready returns early when the value has not changed.
The defect behind it: with the settings window open, the message queue never goes quiet, so the second field of every interlaced frame was being dropped — see Latency. On a still picture that is invisible; on moving interlaced content half the fields going missing is exactly the kind of thing that reads as flicker.
Worth recording that the cause was reached from which sources it happened on rather than from a reproduction. Two reproduction attempts had already failed, and both of them were measuring the wrong thing.
The settings window itself does not always move perfectly smoothly. The preview no longer cares what the window does — that part is fixed — but the window can still hitch as it follows the cursor.
The work is not the problem: 0.6 to 0.8 ms a frame, measured. What is left is the desktop's. Two overlapping windows have to be composed together by DWM, and one of them is repainting at the capture card's rate. Dragging the dialog onto another monitor, or off the preview, makes it go away — which is the test that says whose it is.
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.
Both windows used to share one ImFontAtlas — same glyphs, one copy on the GPU.
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.
That was patched by rebuilding the texture on the way out. It is moot now: with a device of its own the window needs an atlas of its own, and neither can reach into the other.
Switching between embedded and separate destroys and rebuilds the window, so it came up wherever Windows decided each time.
Each form remembering its own position is not the answer — that was the first fix, and it produced two windows that each reappeared where it had last been, so switching modes still made the panel jump across the screen. What is wanted is a handover: the separate window opens exactly where the embedded panel was standing, and the other way round.
That handover has to be measured outer edge to outer edge:
RECT outer = {};
if (::GetWindowRect(host, &outer)) { // including the title bar
POINT inMain = {outer.left, outer.top};
::ScreenToClient(hwnd_, &inMain);
config_.app.settingsPanelX = inMain.x;
config_.app.settingsPanelY = inMain.y;
config_.app.settingsPanelW = outer.right - outer.left;
config_.app.settingsPanelH = outer.bottom - outer.top;
}Mapping client to client — with AdjustWindowRect to convert between them —
looks right and is not: the embedded panel counts its own ImGui title bar as
part of its area, while a real window's client area starts below the Windows
title bar. The difference between the two bars shifted the panel a few pixels on
every switch, down one way and up the other. Outer edges assume nothing about
what is inside.
The failsafe stays: a separate window dragged outside the main window has no sensible embedded position, so the panel is centred in the preview instead.
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.
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.