---
title: "The Windows port that was one 20-line file (and the DLL that wasn't there)"
date: "2026-06-25"
description: "How Vel runs on Windows via Dawn-D3D12 behind a two-function surface seam — and the runtime-loaded shader DLL that vcpkg's applocal deploy quietly missed."
slug: "windows-one-surface-seam"
tldr: "Porting Vel to Windows touched exactly one new source file (SurfaceWin.cpp, ~20 lines) because Dawn already abstracts the GPU and the platform seam is two functions. The only real fight was a DLL that gets LoadLibrary'd at runtime, so the build system never saw it."
tags: ["cpp", "windows", "webgpu", "graphics"]
cover: "/images/blog/windows-one-surface-seam.svg"
coverAlt: "Diagram of Vel's platform seam: one Surface.cpp calls attachNativeSurface, implemented per-OS by SurfaceMac, SurfaceWin, and SurfaceWeb"
author: "Sai Chandan Kadarla"
devto_id: 3983574
---

"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`:

```cpp
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:

```cpp
#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:

```cmake
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:

```cmake
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 `#ifdef`s 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 `#ifdef`s 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`.
