"Cross-platform" usually means a codebase pockmarked with #ifdef _WIN32. You open a file expecting layout logic and instead find three forks of everything: window creation, the GL context, the swapchain, DPI handling. The platform differences leak into every layer because nobody drew a line that says the OS-specific part stops here.
When I brought Vel to Windows, I wanted to find out how small that line could be. The answer turned out to be two functions.
The seam is two functions
Vel renders through Lume, my GPU engine built on Dawn (Google's WebGPU implementation). Dawn already gives me one drawing API that lands on Metal, D3D12, or Vulkan depending on the platform. So the only thing that's genuinely OS-specific is the very first handshake: take a window, hand back something the GPU can present into.
That's the whole platform contract — engine/src/platform/Platform.h:
namespace vel::platform {
// Returns an opaque native handle for the window's render surface.
void* attachNativeSurface(GLFWwindow* window);
// Resize the native backing store, if the platform needs one.
void resizeNativeSurface(void* nativeHandle, int widthPx, int heightPx);
}
Everything above this line — the widget tree, layout, paint, the entire engine — is platform-neutral and compiles identically everywhere. engine/src/gpu/Surface.cpp calls attachNativeSurface, gets back a void*, and feeds it into the matching wgpu::SurfaceSource* descriptor. It never learns what OS it's on.
On macOS, that handle is a CAMetalLayer you attach to the NSView. It's the longest of the implementations because Cocoa makes you set up a layer, pick a pixel format, and track the backing scale factor — about 40 lines.
Windows is shorter. The whole file:
#define GLFW_EXPOSE_NATIVE_WIN32
#include <GLFW/glfw3.h>
#include <GLFW/glfw3native.h>
namespace vel::platform {
// On Windows the wgpu surface binds directly to the window's HWND, so there is
// no intermediate layer to create — we just hand back the GLFW window's HWND.
void* attachNativeSurface(GLFWwindow* window) {
if (!window) return nullptr;
return static_cast<void*>(glfwGetWin32Window(window));
}
// No-op: there is no CAMetalLayer-equivalent backing store to resize. The
// swapchain is sized by wgpu::Surface::Configure() in Surface.cpp.
void resizeNativeSurface(void*, int, int) {}
}
That's it. There's no layer to construct, so attachNativeSurface is one line, and resize is a no-op because the D3D12 swapchain binds straight to the HWND — when the window resizes, wgpu::Surface::Configure() handles it. The asymmetry with macOS isn't sloppiness; it's the platforms being honestly different, contained to the one place where they differ.
CMake selects the right file and nothing else changes:
if(APPLE)
set(VEL_PLATFORM_SOURCES engine/src/platform/SurfaceMac.mm)
elseif(WIN32)
set(VEL_PLATFORM_SOURCES engine/src/platform/SurfaceWin.cpp)
endif()
The grep that convinced me the design held: there is not a single #ifdef _WIN32 anywhere in the framework or registry. The OS forks live in two ~20-40 line files, and the compiler picks one.
The bug that doesn't show up at build time
So the port "worked" almost immediately — it compiled, it linked, the window opened. And then it crashed the first time anything tried to draw text, with a missing-DLL error for a library that was unmistakably present in my vcpkg tree.
This is the Dawn-on-D3D12 trap, and it's a good one. Dawn's D3D12 backend compiles shaders at runtime using DirectX's standalone compiler — dxcompiler.dll and dxil.dll. It doesn't link them. It LoadLibrary()s them lazily, the first time it needs to compile a shader.
That timing is the whole problem. vcpkg's "applocal" deploy step — the thing that copies your DLL dependencies next to your .exe — works by inspecting the binary's link-time import table. But these DLLs were never imported at link time; they get pulled in at runtime by name. So the tooling that's supposed to make Windows binaries portable looks at the executable, sees no dependency on dxcompiler.dll, and faithfully copies nothing.
The fix is to stop relying on inference and just copy them, for any Vel app target:
function(vel_copy_dxc_runtime TARGET)
if(NOT WIN32)
return()
endif()
set(_dxc_bin "${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/bin")
foreach(_dll dxcompiler.dll dxil.dll)
if(EXISTS "${_dxc_bin}/${_dll}")
add_custom_command(TARGET ${TARGET} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${_dxc_bin}/${_dll}" "$<TARGET_FILE_DIR:${TARGET}>"
VERBATIM)
endif()
endforeach()
endfunction()
The lesson generalizes past Windows: any dependency resolved by dlopen/LoadLibrary is invisible to your build graph. Static analysis of the binary can't find it, so your packaging step won't either. If a library loads plugins, codecs, or — like Dawn — a shader compiler by name at runtime, you own deploying those files yourself. It will pass every test on the dev machine where the DLL happens to be on PATH, and fail on the first clean box.
One more Windows-specific wrinkle worth naming: MSVC exports no symbols from a shared library unless you annotate them, and I wasn't about to sprinkle __declspec(dllexport) through public headers that also compile on macOS. CMake's WINDOWS_EXPORT_ALL_SYMBOLS ON generates the export table for the whole libvel, so the showcase and hot-reload plugins link against it the same way they do everywhere else. One property, not a header rewrite.
What it cost, and what it didn't
What the seam buys: adding a platform is a bounded, legible task. I can point at the two functions a new OS has to implement and know that's the entire surface area. Linux is the same shape — an X11/Wayland SurfaceX11.cpp against the same contract — which is why it's a known quantity rather than a rewrite.
What it costs: the abstraction is only as portable as Dawn is, and I've inherited Dawn's operational reality — including a shader compiler that loads itself at runtime and a build system that can't see it coming. I traded a pile of #ifdefs for a dependency I don't fully control. For a 2D UI engine that's a good trade; if I needed exotic per-backend GPU features, the seam would start leaking and I'd be writing the #ifdefs after all.
But the honest result is the one I wanted: Windows support is a 20-line file and a DLL-copy function, not a fork of the codebase. The interesting work stayed in the engine, where it belongs.
Next up is the Linux surface — same contract, X11 first — and then a per-app DLL-deploy helper so the runtime-loaded libraries travel with shipped apps automatically instead of living in the SDK's bin.











