In the previous chapter we created a Win32 window with a small set of free functions. That version is useful because it shows the raw Win32 flow clearly: register a class, create a window, pump messages, and handle WndProc.
Now we will refactor that working code into a shape that can grow. By the end of this chapter you will have a small Window class that:
- keeps the Win32 registration and creation details together,
- creates the same resizable top-level window,
- keeps tracking the 1280 x 720 client area,
- stores the native
HWND, - forwards Win32 messages into an instance method,
- and leaves a clean place for DX12 resize and render code later.
We are still not rendering anything with DX12 yet. This chapter is about structure: taking code that works and arranging it so the renderer has a clean place to plug in.
Why Make a Window Class?
The C Win32 API is built around free functions and callbacks. That is fine for tiny examples, but a renderer quickly needs state:
- the
HWND, - the current client width and height,
- whether the window is minimized,
- resize handling,
- input state,
- and eventually a pointer to the renderer that owns the swap chain.
A Window class gives that state a home.
There is one awkward part: Windows does not know how to call a C++ member function directly as a window procedure. Win32 expects this shape:
LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
A non-static member function has a hidden this parameter, so its shape does not match. The usual solution is:
- Give Win32 a static callback with the correct signature.
- During window creation, pass
thisintoCreateWindowExW. - Store that pointer on the
HWNDwithSetWindowLongPtrW. - Forward later messages to an instance method like
HandleMessage.
That sounds like a lot, but once you see the pattern, it becomes reusable boilerplate.
Create the Project Directory
Before writing the window code, make a small project folder. We will keep headers and source files separate from the start because the renderer will grow quickly.
dx12-sandbox/
CMakeLists.txt
src/
main.cpp
Window.cpp
Window.h
Create it from a terminal like this:
mkdir dx12-sandbox
cd dx12-sandbox
mkdir src
New-Item CMakeLists.txt
New-Item src\main.cpp
New-Item src\Window.cpp
New-Item src\Window.h
The names are plain on purpose. main.cpp owns application startup and the message loop. Window.h declares the C++ wrapper. Window.cpp hides the Win32 setup details.
CMakeLists.txt
For now, put placeholder CMake content here. We will tighten this up after testing the compiler and Visual Studio workflow.
cmake_minimum_required(VERSION 3.24)
project(DX12Sandbox LANGUAGES CXX)
# Placeholder for now.
# We will fill this in after testing the project setup.
The important thing for this chapter is the file layout. Once the CMake target is finalized, it will include src/main.cpp and src/Window.cpp, use C++20 or newer, and build as a Windows subsystem application.
Window.h
The header declares the public surface of the Window class:
#pragma once
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
class Window
{
public:
bool Create(HINSTANCE hInstance, int clientWidth, int clientHeight, int nCmdShow);
HWND Handle() const { return hwnd_; }
int Width() const { return clientWidth_; }
int Height() const { return clientHeight_; }
bool IsMinimized() const { return minimized_; }
private:
static constexpr const wchar_t* ClassName = L"GameDevInstitute.MainWindow";
static LRESULT CALLBACK StaticWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
LRESULT HandleMessage(UINT msg, WPARAM wParam, LPARAM lParam);
static bool RegisterWindowClass(HINSTANCE hInstance);
static RECT ComputeWindowRect(int clientWidth, int clientHeight, DWORD style, DWORD exStyle);
HWND hwnd_ = nullptr;
int clientWidth_ = 0;
int clientHeight_ = 0;
bool minimized_ = false;
};
The public surface is intentionally small. Outside code should not need to know about WNDCLASSEXW, AdjustWindowRectEx, or the callback forwarding trick. It should just create a window and ask for the native handle when DX12 needs it.
Window.cpp
The implementation file owns the Win32 details.
First, include the header:
#include "Window.h"
Register the Win32 Window Class
Win32 still needs a window class. We hide that registration behind a private static helper:
bool Window::RegisterWindowClass(HINSTANCE hInstance)
{
WNDCLASSEXW wc = {};
wc.cbSize = sizeof(WNDCLASSEXW);
wc.style = CS_HREDRAW | CS_VREDRAW;
wc.lpfnWndProc = Window::StaticWndProc;
wc.hInstance = hInstance;
wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
wc.hbrBackground = nullptr;
wc.lpszClassName = ClassName;
return RegisterClassExW(&wc) != 0 || GetLastError() == ERROR_CLASS_ALREADY_EXISTS;
}
The most important field is lpfnWndProc. Instead of pointing at a free WndProc, it points at Window::StaticWndProc.
The ERROR_CLASS_ALREADY_EXISTS check is useful because window classes are registered per process. If you ever create more than one Window, the second call should not fail just because the class was already registered.
We set hbrBackground to nullptr because this will become a graphics window. DX12 will own drawing. Asking GDI to erase the background can cause flicker or a white flash between frames.
Compute the Outer Window Size
For graphics programming, the client area matters more than the total outer window size. The client area is the drawable rectangle inside the title bar and borders. Your swap chain back buffers will match this area.
RECT Window::ComputeWindowRect(int clientWidth, int clientHeight, DWORD style, DWORD exStyle)
{
RECT rect = { 0, 0, clientWidth, clientHeight };
AdjustWindowRectEx(&rect, style, FALSE, exStyle);
return rect;
}
If you pass 1280, 720 directly to CreateWindowExW, you are asking for an outer window that includes borders and a title bar. AdjustWindowRectEx does the math so the usable client area is the size you requested.
Create the Window Instance
Now Window::Create can register the class, calculate the outer rectangle, create the HWND, and show the window:
bool Window::Create(HINSTANCE hInstance, int clientWidth, int clientHeight, int nCmdShow)
{
if (!RegisterWindowClass(hInstance))
{
return false;
}
const DWORD style = WS_OVERLAPPEDWINDOW;
const DWORD exStyle = 0;
RECT rect = ComputeWindowRect(clientWidth, clientHeight, style, exStyle);
hwnd_ = CreateWindowExW(
exStyle,
ClassName,
L"DX12 Sandbox",
style,
CW_USEDEFAULT,
CW_USEDEFAULT,
rect.right - rect.left,
rect.bottom - rect.top,
nullptr,
nullptr,
hInstance,
this);
if (!hwnd_)
{
return false;
}
clientWidth_ = clientWidth;
clientHeight_ = clientHeight;
minimized_ = false;
ShowWindow(hwnd_, nCmdShow);
UpdateWindow(hwnd_);
return true;
}
The final argument to CreateWindowExW is the key detail:
this
Win32 passes that value to the window procedure during WM_NCCREATE. We will use it to connect the native HWND back to the C++ object that owns it.
Forward Messages to the Window Object
The static callback is the bridge between Win32 and your C++ class:
LRESULT CALLBACK Window::StaticWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
Window* window = nullptr;
if (msg == WM_NCCREATE)
{
auto* create = reinterpret_cast<CREATESTRUCTW*>(lParam);
window = static_cast<Window*>(create->lpCreateParams);
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(window));
window->hwnd_ = hwnd;
}
else
{
window = reinterpret_cast<Window*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
}
if (window)
{
return window->HandleMessage(msg, wParam, lParam);
}
return DefWindowProcW(hwnd, msg, wParam, lParam);
}
WM_NCCREATE is one of the earliest messages a window receives. At that point, lParam points to a CREATESTRUCTW, and its lpCreateParams field contains the value we passed as the last argument to CreateWindowExW.
So the first time through:
- Pull
thisout ofCREATESTRUCTW. - Store it on the
HWNDusingGWLP_USERDATA. - Save the
HWNDback onto the C++ object.
After that, every message can retrieve the same pointer with GetWindowLongPtrW and forward to HandleMessage.
Handle Messages as a Member Function
Now message handling can use normal object state:
LRESULT Window::HandleMessage(UINT msg, WPARAM wParam, LPARAM lParam)
{
switch (msg)
{
case WM_SIZE:
{
clientWidth_ = LOWORD(lParam);
clientHeight_ = HIWORD(lParam);
minimized_ = (wParam == SIZE_MINIMIZED);
if (!minimized_ && clientWidth_ > 0 && clientHeight_ > 0)
{
// Later: resize the DX12 swap chain buffers here.
}
return 0;
}
case WM_CLOSE:
DestroyWindow(hwnd_);
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return DefWindowProcW(hwnd_, msg, wParam, lParam);
}
This is the payoff. Resize messages update the Window object’s width, height, and minimized state. Later, when the renderer exists, this method can notify it or set a pending resize flag.
WM_CLOSE and WM_DESTROY are separate on purpose:
WM_CLOSEmeans the user or system asked the window to close.WM_DESTROYmeans the window is actually being destroyed.
Calling DestroyWindow from WM_CLOSE accepts the close request. Calling PostQuitMessage(0) from WM_DESTROY tells the message loop to stop.
Full Window.cpp
Put those pieces together and Window.cpp looks like this:
#include "Window.h"
bool Window::RegisterWindowClass(HINSTANCE hInstance)
{
WNDCLASSEXW wc = {};
wc.cbSize = sizeof(WNDCLASSEXW);
wc.style = CS_HREDRAW | CS_VREDRAW;
wc.lpfnWndProc = Window::StaticWndProc;
wc.hInstance = hInstance;
wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
wc.hbrBackground = nullptr;
wc.lpszClassName = ClassName;
return RegisterClassExW(&wc) != 0 || GetLastError() == ERROR_CLASS_ALREADY_EXISTS;
}
RECT Window::ComputeWindowRect(int clientWidth, int clientHeight, DWORD style, DWORD exStyle)
{
RECT rect = { 0, 0, clientWidth, clientHeight };
AdjustWindowRectEx(&rect, style, FALSE, exStyle);
return rect;
}
bool Window::Create(HINSTANCE hInstance, int clientWidth, int clientHeight, int nCmdShow)
{
if (!RegisterWindowClass(hInstance))
{
return false;
}
const DWORD style = WS_OVERLAPPEDWINDOW;
const DWORD exStyle = 0;
RECT rect = ComputeWindowRect(clientWidth, clientHeight, style, exStyle);
hwnd_ = CreateWindowExW(
exStyle,
ClassName,
L"DX12 Sandbox",
style,
CW_USEDEFAULT,
CW_USEDEFAULT,
rect.right - rect.left,
rect.bottom - rect.top,
nullptr,
nullptr,
hInstance,
this);
if (!hwnd_)
{
return false;
}
clientWidth_ = clientWidth;
clientHeight_ = clientHeight;
minimized_ = false;
ShowWindow(hwnd_, nCmdShow);
UpdateWindow(hwnd_);
return true;
}
LRESULT CALLBACK Window::StaticWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
Window* window = nullptr;
if (msg == WM_NCCREATE)
{
auto* create = reinterpret_cast<CREATESTRUCTW*>(lParam);
window = static_cast<Window*>(create->lpCreateParams);
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(window));
window->hwnd_ = hwnd;
}
else
{
window = reinterpret_cast<Window*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
}
if (window)
{
return window->HandleMessage(msg, wParam, lParam);
}
return DefWindowProcW(hwnd, msg, wParam, lParam);
}
LRESULT Window::HandleMessage(UINT msg, WPARAM wParam, LPARAM lParam)
{
switch (msg)
{
case WM_SIZE:
{
clientWidth_ = LOWORD(lParam);
clientHeight_ = HIWORD(lParam);
minimized_ = (wParam == SIZE_MINIMIZED);
if (!minimized_ && clientWidth_ > 0 && clientHeight_ > 0)
{
// Later: resize the DX12 swap chain buffers here.
}
return 0;
}
case WM_CLOSE:
DestroyWindow(hwnd_);
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return DefWindowProcW(hwnd_, msg, wParam, lParam);
}
main.cpp
main.cpp owns application startup and the message loop.
#include "Window.h"
int RunRenderLoop()
{
MSG msg = {};
while (msg.message != WM_QUIT)
{
if (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE))
{
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
else
{
// Later: RenderFrame();
}
}
return static_cast<int>(msg.wParam);
}
int WINAPI wWinMain(
HINSTANCE hInstance,
HINSTANCE,
PWSTR,
int nCmdShow)
{
Window window;
if (!window.Create(hInstance, 1280, 720, nCmdShow))
{
MessageBoxW(nullptr, L"Window creation failed.", L"Startup Error", MB_ICONERROR);
return -1;
}
return RunRenderLoop();
}
The app owns a Window object. The Window object owns the HWND. Later, the renderer can use:
HWND hwnd = window.Handle();
That handle is what DXGI will need when we create the swap chain.
Why This Class Is Enough for DX12
DX12 does not need Win32 to draw pixels. It needs Win32 to provide the native window handle:
HWND hwnd = window.Handle();
That HWND is what you will pass into swap chain creation. The swap chain uses it to know which window receives the presented images.
The class also gives us the current client size:
int width = window.Width();
int height = window.Height();
Those values will matter when we create the swap chain buffers and when the user resizes the window.
Common Mistakes
If the window does not appear, check these first:
CreateWindowExWmust receivethisas its final argument.WM_NCCREATEmust store that pointer withSetWindowLongPtrW.- Later messages must retrieve it with
GetWindowLongPtrW. - The class name passed to
CreateWindowExWmust exactly match the registered class name. ShowWindowmust be called after the window is created.- Messages you do not handle should go to
DefWindowProcW.
If the client area is not the size you expected, make sure AdjustWindowRectEx uses the same style and extended style that you pass to CreateWindowExW.
If the app closes but the process keeps running, make sure WM_DESTROY calls PostQuitMessage.
Quick Checkpoint
You are ready to move on if these ideas feel solid:
- The project has separate files for startup and window code.
- A Win32 window class is different from your C++
Windowclass. - A static
WndProccan forward messages to a C++ object. WM_NCCREATEis where we first recover thethispointer.GWLP_USERDATAlets us attach the C++ object pointer to theHWND.Window::Handle()gives DXGI theHWNDit will need for the swap chain.WM_SIZEis where swap chain resizing will eventually start.
What’s Next
Next up: DXGI. We will create a factory, choose an adapter, and prepare the path toward the D3D12 device and swap chain.