Skip to content

The basic game loop

Chuck Walbourn edited this page Nov 3, 2022 · 85 revisions
Getting Started

The first lesson in the Getting Started guide is to create a basic game loop which creates a Direct3D device, swap chain, and update/render loop.

Setup

Our starting point is to use the Direct3D12 Win32 Game or the Direct3D12 UWP Game project template. Install the VS 2019/2022 VSIX on your development system, and then start (or restart) Visual Studio.

For the Microsoft GDK on Xbox, the equivalent to these templates the is SimpleDeviceAndSwapChain sample.

Background

The basic rendering setup for Direct3D 12 consists of the following interface objects:

  • The Direct3D device is the primary graphics COM interface for creating additional Direct3D resource objects. Each instance of the device is associated with a specific GPU, and in the case of these tutorials we always use the 'default' Direct3D device. This interface is "thread-safe", meaning you can use the same instance across multiple threads.

  • An explicit DXGI factory must always be used when creating the device.

In Direct3D 11, this was done implicitly so the use of this interface by the application was optional.

  • The graphics command list is the primary graphics COM interface for drawing. It's also used for mapping/un-mapping resources into a fixed memory location for CPU access such as loading graphics data. This interface is not "thread-safe" meaning you must ensure only a single thread uses it at a time. Note the command list is associated with a command allocator which manages the command list memory allocations and lifetime.

It can be a DIRECT command list (equivalent to DirectX 11's immediate device context), a BUNDLE command list (similar to DirectX 11's deferred device context), a COMPUTE command list (which cannot do drawing commands; only dispatch and copy operations), or a COPY command list (which can only do copy operations).

  • The command queue interface is a First-In, First-Out (FIFO) queue of command-lists submitted to the GPU for processing. The command list interface simply records all calls to it. None of them are executed until the command list is "closed" and submitted to the command queue.

There's usually a single DIRECT command queue which supports all operations supported by the GPU, and you can optionally have additional COMPUTE queues for doing asynchronous compute work and/or additional COPY command queues for doing Direct Memory Access work between the GPU/CPU in the background.

  • A fence interface is needed so that the GPU can signal back to the application that it has completed processing a submitted command-list. This is used to signal a Win32 event to indicate a frame has completed rendering on the GPU, and the CPU is free to release or reuse the resources.

  • The swap chain is an interface object that manages 2 or more "back-buffers". These are render target resources where one of them is being displayed on the output monitor (which is referred to as the "front-buffer"), while the other is available for rendering the next frame for display. Each time Present is called, the buffers 'flip' (also known as "buffer rotation") so the next back-buffer is made the front-buffer by being displayed and the previous front-buffer is now available for reuse.

  • The render target is a graphics resource which is used to hold a screen's worth of drawn pixels.

In Direct3D 11, the swap chain "owned" the render target resources. In Direct3D 12, these are explicitly created and managed by the application.

  • The depth buffer is a graphics resource which contains a "z-buffer" used for Hidden Surface Removal (HSV). This is typically not used for 2D rendering but is essential for 3D rendering to ensure proper sorting of the drawn pixels. This resource can optionally have some space set aside for a stencil buffer which can be used for specialized rendering techniques such as generating shadows.

  • A descriptor heap containing the render target view descriptor which provides Direct3D the properties of the render target, i.e. the surface on which graphics output is written.

  • Another descriptor heap containing depth/stencil view descriptor tells Direct3D the properties of the depth/stencil resource.

  • In Direct3D 12, the application must explicitly deal with swap chain rotation by keeping submitted command-lists "alive" until they are fully consumed by the GPU. This requires maintaining three arrays, one for each back-buffer: an array of command allocators, an array of 'last submitted' fence values, and an array of render target resources.

Creating a new project

Visual Studio 2019 / 2022

  • From the drop-down menu, select File and then New -> Project... or on the startup dialog select Create a new project
  • Select "Games" on the project types filter. You can optionally type "Win32" or "UWP" in the search box as well.
  • Select "Direct3D12 Win32 Game" or "Direct3D12 UWP Game".
  • Select "Next"
  • Fill in the "Project name" and "Location" fields as desired.
  • Optional: Check "Place solution and project in the same directory" to keep the directory structure as bit flatter.
  • Select "Create".

Create New Project

Windows SDK Selection

Using DirectX 12 APIs requires the Windows SDK, so the project wizard will trigger this dialog to select the Windows SDK version to use for UWP templates.

Windows SDK Selection

For Win32 projects, the default is to use the latest installed Windows SDK.

Newly Created Project

The newly created project contains the following files:

Open Game Cpp

  • Precompiled header files
    • pch.h
    • pch.cpp
  • Main application entry-point and classic Windows procedure function or CoreWindow class
    • Main.cpp
  • Timer helper class
    • StepTimer.h
  • D3DX12 Utility Header
    • d3dx12.h
  • The Game class
    • Game.h
    • Game.cpp

The Win32 version will have the following files as well:

  • Resources
    • directx.ico
    • resource.rc
    • settings.manifest

While the UWP version will have:

  • Package.appxmanifest
  • Name_TemporaryKey.pfx
  • Assets
    • logo PNG files

CMake projects

If you prefer to make use of Visual Studio's integrated CMake support or a standalone install of CMake, there are CMakeLists.txt and CMakePresets.json files available for download on directx-vs-templates.

The simplest way to use these is to clone directx-vs-templates, open up PowerShell, change to the directx-vs-templates/VSIX directory, and run the following script which will create a fresh instance of the template set up for CMake development:

.\createcmake.ps1 d3d12game_win32 Direct3DGame $Env:USERPROFILE\source

COM

The Win32 and UWP templates ensure that the COM library is initialized. This is required for DirectX Tool Kit when using Windows Imaging Component (WIC) functionality. The UWP template also initialize the Windows Runtime, which is required to use Windows.Gaming.Input.

For the Microsoft GDK sample: You'll need to add a call to CoInitializeEx(nullptr, COINIT_MULTITHREADED); to Main.cpp to support WIC.

Running the application

Visual Studio will default to the x64 platform / Debug configuration which builds an x64 (64-bit) application with debugging enabled. The template contains both Debug and Release configurations for both x86 (32-bit) and x64 (x64 native 64-bit) platforms, with UWP also including the ARM/ARM64 platforms.

Press F5 to build and run the application It displays the following window:

Running Project

Click here for troubleshooting advice

If the base template fails to start, there are a few possibilities:

  • First, if your system doesn't have any Direct3D 12 capable device of any feature level, it will fail.
  • Second if it runs fine in Release but fails in Debug, then you likely do not have the Graphics Tools Windows feature enabled which is required for the Debug Device.
  • Third, if it runs in Debug but not in Release, then you probably don't have a Direct3D 12 capable hardware device and are only able to run the software WARP12 device. Try updating your video drivers and checking the capabilities of your video card.

Xbox: the background color may be slightly oversaturated. This is because the basic Xbox template uses a backBufferFormat of DXGI_FORMAT_B8G8R8A8_UNORM_SRGB. The DirectXMath Colors values are defined using standard sRGB colorspace which is slightly different. All the colors defines need to be adjusted slightly for the linear RGB colorspace (aka gamma correct rendering) via XMColorSRGBToRGB.

ARM64: With the ARM64 compiler installed targeting a Windows on ARM64 device such as a Microsoft Surface X, you can build using the ARM64 platform for desktop as well.

Tour of the code

For now, we'll focus on the content of Game.cpp (which is open by default).

Initialize

When the application first starts, execution is passed to the Initialize method. The TODO here by default leaves the applications StepTimer in the 'variable length' mode. You uncomment the code if you want StepTimer in the 'fixed-step' mode. We'll explain this more once we get to Update.

// Initialize the Direct3D resources required to run.
void Game::Initialize(HWND window, int width, int height)
{
    m_window = window;
    m_outputWidth = std::max( width, 1 );
    m_outputHeight = std::max( height, 1 );

    CreateDevice();

    CreateResources();

    // TODO: Change the timer settings if you want something other than the default
    // variable timestep mode.
    // e.g. for 60 FPS fixed timestep update logic, call:
    /*
    m_timer.SetFixedTimeStep(true);
    m_timer.SetTargetElapsedSeconds(1.0 / 60);
    */
}

One of the two functions called by Initialize is the CreateDevice function which sets up a Direct3D 12 device. For Debug builds, it enables the Direct3D "Debug device" which provides additional validation and diagnostic errors (seen in the "Output" window in Visual C++ when showing output from "Debug"). The TODO here is for adding the creation of objects that depend on the m_d3dDevice, but do not care about the size of the rendering window.

// These are the resources that depend on the device.
void Game::CreateDevice()
{
...

    // TODO: Initialize device dependent objects here (independent of window size)
}

The other function called by Initialize is the CreateResources function which sets up the swapchain (which defaults to a B8G8R8A8_UNORM format), and depth/stencil buffer (which defaults to DXGI_FORMAT_D32_FLOAT). The TODO here is for adding the creation of objects that depend on the size of the rendering window. Note that this function could be creating these objects for the first time, it could be re-creating already existing objects due to a window-size change, or could be creating 'fresh' objects after a Direct3D device-removed or device-reset case.

void Game::CreateResources()
{
...
    // TODO: Initialize windows-size dependent objects here
}

Direct3D 12 requires the use of 'flip' style swap effects, either DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL or DXGI_SWAP_EFFECT_FLIP_DISCARD. These DXGI swap chains cannot be created with an DXGI_FORMAT_x_UNORM_SRGB format or use MSAA (aka SampleDesc.Count > 1). Both sRGB gamma-correction and MSAA require special handling. Use of these newer 'flip' style modes are also recommended for Direct3D 11 Win32 desktop applications on Windows 10 (see this blog post). See this blog series and DeviceResources for more details.

Update

The Update method is intended to handle game-world state modification which is typically driven by time passing, simulation, and/or user-input. By default, Update is called once per 'frame' and can have an arbitrary delta-time. This is called a 'variable-step' mode.

If in the Initialize method above you uncomment the TODO code, then each Update will be for a fixed time-step (1/60th of a second), with Update called as many time in a single 'frame' as needed to keep it up-to-date. This is called a 'fixed-step' mode and potentially be more stable for many kinds of simulations.

void Game::Update(DX::StepTimer const& timer)
{
    float elapsedTime = float(timer.GetElapsedSeconds());

    // TODO: Add your game logic here
    elapsedTime;
}

Render

The Render function which should render a single 'frame' of the scene, which starts by a Clear of the render target, and setting the rendering viewport & scissors. It ends with a Present of the rendered frame.

void Game::Render()
{
    // Don't try to render anything before the first Update.
    if (m_timer.GetFrameCount() == 0)
    {
        return;
    }

    // Prepare the command list to render a new frame.
    Clear();

    // TODO: Add your rendering code here.

    // Show the new frame.
    Present();
}

Clear

The Clear function defaults to a background color of the classic "Cornflower blue". For simplicity, the Clear method also performs the initial command list and allocator reset, as well as transition the render target to the proper state which is required to begin rendering a new frame. We also set the viewport and scissor rectangle every frame as all state is reset between frames.

void Game::Clear()
{
    // Reset command list and allocator.
    DX::ThrowIfFailed(m_commandAllocators[m_backBufferIndex]->Reset());
    DX::ThrowIfFailed(m_commandList->Reset(m_commandAllocators[m_backBufferIndex].Get(), nullptr));

    // Transition the render target into the correct state to allow for drawing into it.
    const D3D12_RESOURCE_BARRIER barrier = CD3DX12_RESOURCE_BARRIER::Transition(
        m_renderTargets[m_backBufferIndex].Get(),
        D3D12_RESOURCE_STATE_PRESENT, D3D12_RESOURCE_STATE_RENDER_TARGET);
    m_commandList->ResourceBarrier(1, &barrier);

    // Clear the views.
    const CD3DX12_CPU_DESCRIPTOR_HANDLE rtvDescriptor(
        m_rtvDescriptorHeap->GetCPUDescriptorHandleForHeapStart(),
        static_cast<INT>(m_backBufferIndex), m_rtvDescriptorSize);
    CD3DX12_CPU_DESCRIPTOR_HANDLE dsvDescriptor(m_dsvDescriptorHeap->GetCPUDescriptorHandleForHeapStart());
    m_commandList->OMSetRenderTargets(1, &rtvDescriptor, FALSE, &dsvDescriptor);
    m_commandList->ClearRenderTargetView(rtvDescriptor, Colors::CornflowerBlue, 0, nullptr);
    m_commandList->ClearDepthStencilView(dsvDescriptor, D3D12_CLEAR_FLAG_DEPTH, 1.0f, 0, 0, nullptr);

    // Set the viewport and scissor rect.
    const D3D12_VIEWPORT viewport = { 0.0f, 0.0f,
        static_cast<float>(m_outputWidth), static_cast<float>(m_outputHeight),
        D3D12_MIN_DEPTH, D3D12_MAX_DEPTH };
    const D3D12_RECT scissorRect = { 0, 0, static_cast<LONG>(m_outputWidth), static_cast<LONG>(m_outputHeight) };
    m_commandList->RSSetViewports(1, &viewport);
    m_commandList->RSSetScissorRects(1, &scissorRect);
}

Present

In DirectX 12, the application is responsible for implementing the swapping of the back buffer. The Present method inserts a barrier to transition the render target to the present state, closes and executes the command list, presents the current frame, and then advances to the next frame. If needed, any fencing or blocking of rendering due to too many queued frames takes place here as well.

void Game::Present()
{
    // Transition the render target to the state that allows it to be presented to the display.
    const D3D12_RESOURCE_BARRIER barrier = CD3DX12_RESOURCE_BARRIER::Transition(
        m_renderTargets[m_backBufferIndex].Get(),
        D3D12_RESOURCE_STATE_RENDER_TARGET, D3D12_RESOURCE_STATE_PRESENT);
    m_commandList->ResourceBarrier(1, &barrier);

    // Send the command list off to the GPU for processing.
    DX::ThrowIfFailed(m_commandList->Close());
    m_commandQueue->ExecuteCommandLists(1, CommandListCast(m_commandList.GetAddressOf()));

    // The first argument instructs DXGI to block until VSync, putting the application
    // to sleep until the next VSync. This ensures we don't waste any cycles rendering
    // frames that will never be displayed to the screen.
    HRESULT hr = m_swapChain->Present(1, 0);

    // If the device was reset we must completely reinitialize the renderer.
    if (hr == DXGI_ERROR_DEVICE_REMOVED || hr == DXGI_ERROR_DEVICE_RESET)
    {
        OnDeviceLost();
    }
    else
    {
        DX::ThrowIfFailed(hr);

        MoveToNextFrame();
    }
}

Xbox: Xbox titles do not encounter lost device or device removed scenarios. UWP on Xbox apps still needs to handle these scenarios.

Events

The template includes a number of message handlers that are called for process state changes: OnActivated, OnDeactivated, OnSuspending, OnResuming, and OnWindowSizeChanged. The UWP version also includes ValidateDevice, and display orientation is provided long with the window size.

For Win32 desktop, the OnSuspending / OnResuming messages are triggered when (a) the window is minimized/unminimized or (b) in reaction to the WM_POWERBROADCAST message. On other platforms, this is driven by Process Lifecycle Management (PLM).

Since we are using ComPtr, most cleanup is automatic when the Game class is destroyed. If Present encounters a device-removed or device-reset, then the application needs to release all Direct3D objects and recreate the device, swapchain, and all Direct3D objects again. Therefore, the TODO in OnDeviceLost should be updated to release your application's Direct3D objects.

void Game::OnDeviceLost()
{
    // TODO: Add Direct3D resource cleanup here

...
}

You will not get "device lost" all that often. In legacy Direct3D 9, you would routinely get a 'device lost' if you just Alt+TAB away from the application because the GPU used to be an 'exclusive' rather than 'shared' resource. The situation where you'd get DXGI_ERROR_DEVICE_RESET is if the driver crashes or the video hardware hangs. You get DXGI_ERROR_DEVICE_REMOVED if a new driver is installed while your application is running, or if you are running on a 'GPU is in the dock' style laptop and the laptop is undocked. You can test this case by opening the Developer Command Prompt for Visual Studio as an administrator, and typing dxcap -forcetdr which will immediately cause all currently running Direct3D apps to get a DXGI_ERROR_DEVICE_REMOVED event.

Smart-pointer

We make use of the Microsoft::WRL::ComPtr smart-pointer for managing the lifetime of the Direct3D 12 COM objects, which is why we make use of .Get() in the code above. See ComPtr and Microsoft Docs for more information and usage.

Error handling

Many Direct3D functions return an HRESULT which is the standard for COM APIs. For robustness and easier debugging, it is important that you always check the result of every function that return an HRESULT. If you really can safely assume there is no error condition for a particular function, the function itself will return void instead of HRESULT.

The Win32 game template makes use of the helper function ThrowIfFailed in the DX C++ namespace declared in pch.h. This is the same helper that is used by the Windows Store and Windows phone VS templates. This helper throws a C++ exception if the standard FAILED macro returns true for a given HRESULT. This is used for fail fast error handling.

DX::ThrowIfFailed(m_d3dDevice->CreateCommandQueue(&queueDesc,
    IID_PPV_ARGS(m_commandQueue.ReleaseAndGetAddressOf())));

Do not use hr == S_OK to check for success. Use SUCCEEDED(hr) instead.

Click here for Xbox development note

Note that due to the use of the 'monolithic' DirectX 12.X Runtime, you will need to use IID_GRAPHICS_PPV_ARGS instead of IID_PPV_ARGS for Xbox One XDK / Microsoft GDKX development. See DirectXHelpers.

Gamma

For these tutorials, we make use of the DXGI_FORMAT_B8G8R8A8_UNORM backbuffer format. This does not give gamma-correct results, but is easier to set up. If you want to implement linear-space rendering which would use DXGI_FORMAT_B8G8R8A8_UNORM_SRGB, see DeviceResources.

Linear-Space Lighting (i.e. Gamma)
Chapter 24. The Importance of Being Linear, GPU Gems 3
Gamma-correct rendering
The Care and Feeding of Modern Swap Chains

Fullscreen

The Win32 desktop and UWP templates implement immersive fullscreen. You can toggle this using the traditional hotkey Alt+Enter. If you want to default to full-screen at startup, see the TODO comments in Main.cpp.

Message 'pump'

For the Win32 desktop versions of the template, the Main.cpp source file contains the WndProc and the basic loop:

MSG msg = {};
while (WM_QUIT != msg.message)
{
    if (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE))
    {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
    else
    {
        g_game->Tick();
    }
}

This ensures that all pending Win32 messages are consumed before updating/rendering the next frame. This is critical for maintaining responsiveness.

For UWP, the equivalent thing is done inside the ViewProvider Run method.

Next lesson: Using DeviceResources

Further reading

Direct3D Win32 Game Visual Studio template
Direct3D Game Visual Studio templates (Redux)
Anatomy of Direct3D 12 Create Device
Manifest Madness
64-bit programming for Game Developers

For Use

  • Universal Windows Platform apps
  • Windows desktop apps
  • Windows 11
  • Windows 10
  • Xbox One
  • Xbox Series X|S

Architecture

  • x86
  • x64
  • ARM64

For Development

  • Visual Studio 2022
  • Visual Studio 2019 (16.11)
  • clang/LLVM v12 - v18
  • MinGW 12.2, 13.2
  • CMake 3.20

Related Projects

DirectX Tool Kit for DirectX 11

DirectXMesh

DirectXTex

DirectXMath

Tools

Test Suite

Model Viewer

Content Exporter

DxCapsViewer

See also

DirectX Landing Page

Clone this wiki locally