<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: 泽</title>
    <description>The latest articles on DEV Community by 泽 (@zze489).</description>
    <link>https://dev.to/zze489</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4238892%2F524f4f86-85cb-45e1-b502-d81451db5047.jpg</url>
      <title>DEV Community: 泽</title>
      <link>https://dev.to/zze489</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zze489"/>
    <language>en</language>
    <item>
      <title>The Real Cost of Building a C++ GUI Framework from Scratch: 87,094 Lines, 4 Hard Problems</title>
      <dc:creator>泽</dc:creator>
      <pubDate>Wed, 11 Nov 2026 10:54:31 +0000</pubDate>
      <link>https://dev.to/zze489/the-real-cost-of-building-a-c-gui-framework-from-scratch-87094-lines-4-hard-problems-2g8j</link>
      <guid>https://dev.to/zze489/the-real-cost-of-building-a-c-gui-framework-from-scratch-87094-lines-4-hard-problems-2g8j</guid>
      <description>&lt;p&gt;A month ago I asked how hard it is to build a cross-platform GUI framework without Qt, SDL, or GLFW — hand-written Win32/X11 window layers, and hand-written D3D12 / Vulkan / OpenGL backends.&lt;/p&gt;

&lt;p&gt;I finished it. &lt;strong&gt;Zekit is now open source.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/Z-ze489/Zekit" rel="noopener noreferrer"&gt;https://github.com/Z-ze489/Zekit&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Gitee: &lt;a href="https://gitee.com/z-ze489/Zekit" rel="noopener noreferrer"&gt;https://gitee.com/z-ze489/Zekit&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pure C++20. Windows / Linux / Android. The window layer talks directly to Win32 and X11. The render layer has three backends: D3D12, Vulkan, and OpenGL. No Qt, no SDL, no GLFW.&lt;/p&gt;

&lt;p&gt;This post is not “look, it’s easy.” It’s the bill: what was actually hard, what was just learning an API, and what cost real debugging time.&lt;/p&gt;

&lt;h3&gt;
  
  
  The numbers first
&lt;/h3&gt;

&lt;p&gt;Own code, excluding vendored FreeType and third-party headers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Win32 window layer (IMM32, DPI, drag-and-drop): 2,146 lines&lt;/li&gt;
&lt;li&gt;X11 window layer (EWMH, XIM, cursors, multi-monitor): 3,370 lines&lt;/li&gt;
&lt;li&gt;OpenGL renderer (WGL + EGL + GLProgram + renderer): 1,221 lines&lt;/li&gt;
&lt;li&gt;Vulkan renderer (context + renderer + runtime loader): 2,113 lines&lt;/li&gt;
&lt;li&gt;D3D12 renderer (context + renderer): 1,489 lines&lt;/li&gt;
&lt;li&gt;Software rasterizer fallback: 792 lines&lt;/li&gt;
&lt;li&gt;Text (FreeType rasterization + text-edit base): 1,582 lines&lt;/li&gt;
&lt;li&gt;Everything else (widget tree, layout, theme, events, 80+ widgets): ~74,000 lines&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Total: 87,094 lines&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;88 public headers. One include path.&lt;/p&gt;

&lt;p&gt;Line count shows volume, not difficulty. Four things actually hurt.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The same concept, three APIs, three lifetime models
&lt;/h3&gt;

&lt;p&gt;Tutorials teach you “how to create a Vulkan swapchain” or “how to create a D3D12 descriptor heap.” They don’t tell you that the same operation is not a renamed function — it’s a different set of invariants.&lt;/p&gt;

&lt;p&gt;Real table from my code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Frame: GL has none; Vulkan is acquire → draw → present; D3D12 is driven by the context’s frame callback.&lt;/li&gt;
&lt;li&gt;Projection matrix: GL uses &lt;code&gt;glUniformMatrix4fv&lt;/code&gt;; Vulkan uses push constants; D3D12 uses root constants.&lt;/li&gt;
&lt;li&gt;Vertex data: GL uses &lt;code&gt;glBufferSubData&lt;/code&gt;; Vulkan uses per-frame-slot VBO/IBO; D3D12 uses a per-frame-slot upload-heap ring buffer.&lt;/li&gt;
&lt;li&gt;Textures: GL binds textures; Vulkan uses one descriptor set per texture; D3D12 uses one SRV per texture in a shader-visible heap.&lt;/li&gt;
&lt;li&gt;Samplers: GL attaches them to texture parameters; Vulkan uses separate &lt;code&gt;VkSampler&lt;/code&gt; objects; D3D12 uses separate sampler descriptors.&lt;/li&gt;
&lt;li&gt;Destruction: GL allows &lt;code&gt;glDelete&lt;/code&gt; at any time; Vulkan cannot destroy resources still in flight; D3D12 is the same as Vulkan.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last one is the real trap. GL lets you delete a texture at any time and the driver synchronizes for you — it quietly solves a concurrent resource-lifetime problem. Vulkan/D3D12 do not. You must maintain fences, frame slots, and deferred-release queues yourself, or the GPU reads freed memory and crashes somewhere you can’t see.&lt;/p&gt;

&lt;p&gt;So “learn three APIs” is misleading. You maintain three non-equivalent resource-lifetime models and make them look like one &lt;code&gt;DrawFrame&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Visual consistency is pixel-level, not API-level
&lt;/h3&gt;

&lt;p&gt;This was the most counter-intuitive part. Once all three backends render, you think you’re done. You’re not: the same button has different corner softness on D3D12 and OpenGL.&lt;/p&gt;

&lt;p&gt;I use SDF: vertices carry corner parameters, the pixel shader computes a distance field, no geometry tessellation. Sounds clean. Here’s a comment I added back into the code afterward:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Alpha fades out over exactly 1 pixel at the edge, to match hardware MSAA transition width, so SDF and MSAA are never one sharp and one blurry. Do not widen this band (once written as a 3-pixel smoothstep, every shape went blurry): bandwidth, gradient math, and geometry padding are one set of invariants.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Translation: the SDF anti-aliasing bandwidth must be exactly 1 pixel, matching hardware MSAA. Once it was 3 pixels, and every shape looked soft. And bandwidth, gradient math, and geometry padding are coupled — change one, the other two must follow.&lt;/p&gt;

&lt;p&gt;No spec tells you this. Not Khronos, not MSDN. Only breaking it once, staring at the screen, and fixing it back.&lt;/p&gt;

&lt;p&gt;That’s one rounded rectangle. Text anti-aliasing, shadow falloff, stroke pixel alignment, half-pixel offsets at high DPI — all the same pattern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first 20% is learning APIs. The last 80% is paying the consistency tax.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The platform layer is asymmetric, and the direction is counter-intuitive
&lt;/h3&gt;

&lt;p&gt;Look at the two numbers above: Win32 2,146 lines, X11 3,370 lines. X11 is 55% larger. I did not expect that — the intuition is “Win32 is verbose, X11 is clean.”&lt;/p&gt;

&lt;p&gt;Reality:&lt;/p&gt;

&lt;p&gt;On Win32, &lt;code&gt;CreateWindowEx&lt;/code&gt; + &lt;code&gt;WndProc&lt;/code&gt; is a ready-made rule set. IMM32 is one call to &lt;code&gt;ImmSetCompositionWindow&lt;/code&gt; to move the composition window to the caret. DPI has &lt;code&gt;SetProcessDpiAwarenessContext&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On X11, you have to wire the window into a modern desktop yourself. &lt;code&gt;_NET_WM_NAME&lt;/code&gt;, &lt;code&gt;_NET_WM_ICON&lt;/code&gt;, &lt;code&gt;_NET_WM_PID&lt;/code&gt;, &lt;code&gt;_NET_WM_WINDOW_TYPE&lt;/code&gt;, &lt;code&gt;_NET_WM_STATE&lt;/code&gt; (fullscreen/maximized/topmost/hidden), &lt;code&gt;_NET_WM_ALLOWED_ACTIONS&lt;/code&gt;, &lt;code&gt;_NET_WM_STATE_SKIP_TASKBAR&lt;/code&gt; — miss one of ~20 atoms and the window manager misbehaves, and you won’t see it on your own machine because your WM happens to be forgiving. IME goes through &lt;code&gt;XOpenIM&lt;/code&gt; → &lt;code&gt;XCreateIC&lt;/code&gt; → &lt;code&gt;Xutf8LookupString&lt;/code&gt;; the candidate window position must be computed in screen coordinates and set via &lt;code&gt;XSetICValues&lt;/code&gt; with &lt;code&gt;XNSpotLocation&lt;/code&gt;, and reset after every &lt;code&gt;ConfigureNotify&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Worse: platform cost is not “number of platforms × constant.” It’s the &lt;strong&gt;difference set&lt;/strong&gt; between platforms. Adding a second platform costs time proportional to how much it differs from the first, not to how complex it is.&lt;/p&gt;

&lt;p&gt;And an honest note: I have not done Wayland. Only X11. That is the cost showing.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Wrong abstractions compound
&lt;/h3&gt;

&lt;p&gt;The most dangerous part is the only irreversible one.&lt;/p&gt;

&lt;p&gt;If the design had been “each backend implements how to draw a rounded rectangle,” then three backends mean three anti-aliasing implementations, never consistent, and every new primitive must be changed in three places. By widget #60, one change opens three files and you die in maintenance.&lt;/p&gt;

&lt;p&gt;So the one thing I got right: define the middle layer before writing backends.&lt;/p&gt;

&lt;p&gt;Widgets produce one pure-data display list. One vertex format: 13 floats — position, color, UV, plus five rounded-corner SDF parameters. Backends do two things: upload vertices, draw.&lt;/p&gt;

&lt;p&gt;Once that layer is correct, the payoff compounds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Adding a backend is about 800–1,500 lines; the widget layer does not change.&lt;/li&gt;
&lt;li&gt;I also wrote window layers for macOS/iOS/Android/HarmonyOS. Source is there, but see the honest ledger below — layering made them cheaper, not finished.&lt;/li&gt;
&lt;li&gt;The software rasterizer coexists with GPU backends and verifies geometry, clipping, and text positioning — it removes the GPU driver from the suspect list.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If that display list is wrong, all 80+ widgets get reworked. Wrong abstractions don’t charge you on day one; they charge you in year one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Difficulty, in units
&lt;/h3&gt;

&lt;p&gt;Taking “first backend puts pixels on screen” as 1 unit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open a window + draw a rectangle: 1×. One week, no real difficulty.&lt;/li&gt;
&lt;li&gt;One backend with anti-aliased rounded corners + UTF-8 text + emoji: 2–3×. Font rasterization, SDF, MSAA, DPI.&lt;/li&gt;
&lt;li&gt;Second backend at pixel-level consistency: 3–5×. Lifetime semantics, precision differences, the invariants above.&lt;/li&gt;
&lt;li&gt;Text input (IME/caret/selection/undo/scrolling): 4–6×. Completely unrelated to graphics APIs. Pure time.&lt;/li&gt;
&lt;li&gt;Three platforms × three backends fully consistent: 8×+. Platform difference set explodes.&lt;/li&gt;
&lt;li&gt;All of the above, plus “user includes one header and it works”: +2×. Vendoring dependencies, runtime loading, build system.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note the fourth line: &lt;strong&gt;what makes people give up is not D3D12. It’s the IME.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Behind &lt;code&gt;ImmSetCompositionWindow&lt;/code&gt; / &lt;code&gt;XSetICValues&lt;/code&gt; is a state machine: where the composition string is drawn, who the candidate window follows, when it counts as commit, where backspace goes. It has nothing to do with the GPU, and it eats half your debugging time — because the bugs are “sometimes,” “occasionally,” “under this one IME.”&lt;/p&gt;

&lt;p&gt;And to be clear: &lt;strong&gt;if you only want a button that clicks, this cost is meaningless.&lt;/strong&gt; Use ImGui or Qt. This breakdown is for people who want to understand how the bottom layers actually turn.&lt;/p&gt;

&lt;h3&gt;
  
  
  If you actually want to try it, an order that saves detours
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Define the display list before writing backends. Reversed order guarantees rework.&lt;/li&gt;
&lt;li&gt;Write backend #0 as a software rasterizer. A few hundred lines, no driver dependency, verifies geometry, clipping, text positioning. When the GPU misbehaves, you know whose fault it is.&lt;/li&gt;
&lt;li&gt;Do not write D3D12 first. Order: GL (shortest path to pixels) → Vulkan (most verbose but most predictable) → D3D12 (most traps in memory, descriptors, fences).&lt;/li&gt;
&lt;li&gt;Each backend implements only four functions: Init / DrawFrame / Resize / Shutdown. Add nothing else.&lt;/li&gt;
&lt;li&gt;The text layer must not depend on the graphics API. FreeType produces bitmaps only. Keep this rule and changing backends never touches text.&lt;/li&gt;
&lt;li&gt;Get one platform fully working before starting the second. Parallel dual-platform is the most common way to die.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Honest ledger
&lt;/h3&gt;

&lt;p&gt;Current real status:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Windows 10/11 x64: primary. D3D12 / OpenGL 4.6 / Vulkan 1.0+.&lt;/li&gt;
&lt;li&gt;Linux x64: supported. EGL OpenGL / Vulkan. X11 only, no Wayland.&lt;/li&gt;
&lt;li&gt;macOS / iOS: source present, not compiled on real hardware. Metal backend written but unverified.&lt;/li&gt;
&lt;li&gt;Android: source present, emulator only. Mouse input on 2-in-1 devices not solved.&lt;/li&gt;
&lt;li&gt;HarmonyOS: source present, emulator only.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only Windows and Linux are deliverable. The other three are products of “layering made them cheaper,” not “finished.”&lt;/p&gt;

&lt;p&gt;And a harder statement: this does not prove that hand-writing a GUI framework is a reasonable choice. It proves that the cost can be quantified — 87,094 lines, four real hard problems, one correctly defined middle layer.&lt;/p&gt;

&lt;p&gt;The repository is MIT. Windows builds Zekit.lib, Linux builds libZekit.a. One file. No FreeType install, no Vulkan SDK install.&lt;/p&gt;

&lt;p&gt;87,094 lines is a lot of surface area. I can't claim all of it is bug-free — X11 window handling and IME are the parts I'd audit first.&lt;/p&gt;

</description>
      <category>cpp</category>
      <category>github</category>
      <category>opensource</category>
      <category>softwaredevelopment</category>
    </item>
  </channel>
</rss>
