Kostiantyn's Blog

rss icon

Win32 API: Connecting a window to OpenGL via Silk.NET

Colored dots on black background
Colors.
Photo by Isis Fran├ža on Unsplash

Introduction

Let's recap what we can already do. In the last post, we learnt how to open a Win32 window with C# and draw something on it. However, usually, for performance reasons, one would use the video card for drawing instead of doing it on the CPU as we did there. To use the video card, one would use some kind of graphics backend like OpenGL, DirectX or Vulkan. In this post, we will add OpenGL to our little happy window.

TLDR: We are adding OpenGL support to a Win32 window in C#, final code is in the Win32Opengl repository

Adding OpenGL

Let's take the basic structure of the win32 application from the post where we opened a Win32 window.

Base program
c#
using System.Runtime.InteropServices;
using TerraFX.Interop.Windows;
using static TerraFX.Interop.Windows.Windows;

var window = CreateWindow();
RunMainLoop(window);

static unsafe void RunMainLoop(HWND window)
{
    MSG msg;
    while (!Closed)
    {
        while (GetMessageW(&msg, window, 0, 0) && !Closed)
        {
            DispatchMessageW(&msg);
        }
    }
}

[UnmanagedCallersOnly]
static unsafe LRESULT WinProc(HWND window, uint message, WPARAM wParam, LPARAM lParam)
{
    if (message == WM.WM_PAINT)
    {
        var ps = new PAINTSTRUCT();
        var deviceContextHandle = BeginPaint(window, &ps);
        EndPaint(window, &ps);
        return 0;
    }

    if (message == WM.WM_CLOSE)
    {
        Closed = true;
        return 0;
    }

    return DefWindowProcW(window, message, wParam, lParam);
}

static unsafe HWND CreateWindow()
{
    var className = "windowClass";

    fixed (char* classNamePtr = className)
    {
        var windowClass = new WNDCLASSEXW();
        windowClass.cbSize = (uint)sizeof(WNDCLASSEXW);
        windowClass.hbrBackground = HBRUSH.NULL;
        windowClass.hCursor = HCURSOR.NULL;
        windowClass.hIcon = HICON.NULL;
        windowClass.hIconSm = HICON.NULL;
        windowClass.hInstance = HINSTANCE.NULL;
        windowClass.lpszClassName = (ushort*)classNamePtr;
        windowClass.lpszMenuName = null;
        windowClass.style = 0;
        windowClass.lpfnWndProc = &WinProc;

        var classId = RegisterClassExW(&windowClass);
    }

    var windowName = "windowName";
    fixed (char* windowNamePtr = windowName)
    fixed (char* classNamePtr = className)
    {
        var width = 500;
        var height = 500;
        var x = 0;
        var y = 0;

        var styles = WS.WS_OVERLAPPEDWINDOW | WS.WS_VISIBLE;
        var exStyles = 0;

        return CreateWindowExW((uint)exStyles,
            (ushort*)classNamePtr,
            (ushort*)windowNamePtr,
            (uint)styles,
            x, y,
            width, height,
            HWND.NULL, HMENU.NULL, HINSTANCE.NULL, null);
    }
}

partial class Program
{
    private static bool Closed;
}

Setting pixel format

In order to take advantage of OpenGL, we firstly need to instruct the Windows OS what pixels we do expect in the bitmap that it provides for the window.

The API is kinda interesting. We don't actually have an ability to set an exact pixel format. We need to choose from the ones that Windows gives us. Conceptually, we form a query with the desired characteristics (specified in PIXELFORMATDESCRIPTOR structure). Then, Windows takes that into account and gives us the most appropriate match given our query via ChoosePixelFormat. We then set that match to be our desired pixel format by calling SetPixelFormat.

c#
static unsafe void SetOpenglPixelFormat(HWND window)
{
    // Contains desired pixel format characteristics
    PIXELFORMATDESCRIPTOR pfd = new();
    
    // the size of the struct
    pfd.nSize = (ushort)sizeof(PIXELFORMATDESCRIPTOR);
    
    // hardcoded version of the struct
    pfd.nVersion = 1;
    
    // we will draw to the window, we will draw via opengl, and we will use two buffers to swap between them each frame 
    pfd.dwFlags = PFD.PFD_DRAW_TO_WINDOW | PFD.PFD_SUPPORT_OPENGL | PFD.PFD_DOUBLEBUFFER;
     
    // We expect to use RGBA pixels
    pfd.iPixelType = PFD.PFD_TYPE_RGBA;
    
    // pixels with 3 * 8 = 24 bits for color 
    pfd.cColorBits = 24;
    
    // Depth of z-buffer (we don't actually care about that for now)
    pfd.cDepthBits = 32;

    HDC hdc = GetDC(window);
    int iPixelFormat;

    // get the device context's best, available pixel format match  
    iPixelFormat = ChoosePixelFormat(hdc, &pfd);

    // make that match the device context's current pixel format  
    SetPixelFormat(hdc, iPixelFormat, &pfd);

    ReleaseDC(window, hdc);
}

Creating rendering context

Now, we need to tell that we will be drawing on the window via OpenGL. We can do that by creating a rendering context (wglCreateContext). And then marking it as the one we are going to draw right now (wglMakeCurrent)

c#
static HGLRC StartOpenglRenderingContext(HWND window)
{
    var dc = GetDC(window);
    var gctx = wglCreateContext(dc);
    wglMakeCurrent(dc, gctx);
    ReleaseDC(window, dc);

    return gctx;
}

Swapping buffers

We want to have two bitmaps. One is being shown to the user, while we are preparing and drawing on another one. At some point, we need to swap them. We can do it by calling (SwapBuffers). Let's do that after message processing

c#
static unsafe void RunMainLoop(HWND window)
{
    MSG msg;
    var dc = GetDC(window);
    while (!Closed)
    {
        while (GetMessageW(&msg, window, 0, 0) && !Closed)
        {
            DispatchMessageW(&msg);
        }

        // Show the buffer we have drawn on (the next frame)
        SwapBuffers(dc);
    }

    ReleaseDC(window, dc);
}

Non-blocking windows message consumption

Unfortunately, our previous implementation of message pumping is a blocking one because we use GetMessageW that waits until a new message is posted to the queue. To fix that, we can use a non-blocking PeekMessageW that will fast return false when no messages are there.

c#
static unsafe void RunMainLoop(HWND window)
{
    MSG msg;
    var dc = GetDC(window);
    while (!Closed)
    {
        while (/* we don't block here now */PeekMessageW(&msg, window, 0, 0, PM.PM_REMOVE) && !Closed)
        {
            DispatchMessageW(&msg);
        }

        SwapBuffers(dc);
    }

    ReleaseDC(window, dc);
}

Using OpenGL APIs

For OpenGL bindings, we will take advantage of Silk.NET. It has many more features than the bindings themselves, but we are not interested in those yet.

By design, not all OpenGL functions are available in all versions. So before using an OpenGL function that may be absent, we need to query to check whether it exists and to get the location of the function.

In Silk.NET, that functionality should be implemented by a INativeContext interface. How do we implement that?

Windows has an API to get the address of an OpenGL function - wglGetProcAddress. It returns the address of the function if it exists, and a null pointer if not. One caveat is that it expects an ASCII null-terminated string, in contrast to UTF-16 that .NET uses by default, so we need to perform a little but of a conversion.

c#
public class WindowsGlNativeContext : INativeContext
{
    private readonly UnmanagedLibrary _l;

    public WindowsGlNativeContext()
    {
        // The base library, with functions that exist in all versions
        _l = new UnmanagedLibrary("opengl32.dll");
    }

    public unsafe bool TryGetProcAddress(string proc, out nint addr, int? slot = null)
    {
        // Firstly, we try to get the function in the base library
        if (_l.TryLoadFunction(proc, out addr))
        {
            return true;
        }
        
        // If we fail, we assume that this is an extended function that we need to query Windows for

        // Buffer for out ASCII null-terminated string
        var asciiName = new byte[proc.Length + 1];
        Encoding.ASCII.GetBytes(proc, asciiName);

        // We ask the GC not to move the buffer
        fixed (byte* name = asciiName)
        {
            // Query Windows for the extended OpenGL function
            addr = wglGetProcAddress((sbyte*)name);
            
            // If the address is not null -> we succeeded
            if (addr != IntPtr.Zero)
            {
                return true;
            }
        }

        // We failed to get the function
        return false;
    }
    
    public nint GetProcAddress(string proc, int? slot = null)
    {
        if (TryGetProcAddress(proc, out var address, slot))
        {
            return address;
        }

        throw new InvalidOperationException("No function was found with the name " + proc + ".");
    }
    
    public void Dispose() => _l.Dispose();
}

Then, we can create the API with OpenGL Bindings.

c#
var gl = new Silk.NET.OpenGL.GL(new WindowsGlNativeContext());

Let's try them out! We will clear the screen with a blue color via OpenGL.

c#
static unsafe void RunMainLoop(HWND window)
{
    var gl = new Silk.NET.OpenGL.GL(new WindowsGlNativeContext());
    
    MSG msg;
    var dc = GetDC(window);
    while (!Closed)
    {
        while (PeekMessageW(&msg, window, 0, 0, PM.PM_REMOVE) && !Closed)
        {
            DispatchMessageW(&msg);
        }

        // We paint the window contents to blue
        gl.ClearColor(Color.Blue);
        gl.Clear(ClearBufferMask.ColorBufferBit);

        SwapBuffers(dc);
    }

    ReleaseDC(window, dc);
}

We can see that it works because the window contents are blue:

Win32 window with blue contents

Playing around: Color transition

Just a blue colored screen isn't interesting. Let's implement something a little bit more dynamic - window with changing background color.

For that, let's track the amount of seconds since the start of the app. To make the color changes smooth, we will use sin/cos functions. OpenGL colors are from 0 to 1 range. And sin/cos is in range from -1 to 1, so we can transform the sin/cos values into the color range with some easy math.

Now, we can calculate the color from the time by applying a trigonometric function and fitting the result to the target color range:

c#
static unsafe void RunMainLoop(HWND window)
{
    // Track the time since the start of the app
    var startTime = DateTime.UtcNow;
    var gl = new Silk.NET.OpenGL.GL(new WindowsGlNativeContext());
    
    MSG msg;
    var dc = GetDC(window);
    while (!Closed)
    {
        while (PeekMessageW(&msg, window, 0, 0, PM.PM_REMOVE) && !Closed)
        {
            DispatchMessageW(&msg);
        }

        // Calculate the r/g/b values of the color based on time
        var dTime = (float)(DateTime.UtcNow - startTime).TotalSeconds;
        var r = NormalizeSinCos(MathF.Sin(dTime));
        var g = NormalizeSinCos(MathF.Cos(dTime));
        var b = NormalizeSinCos(MathF.Sin(-dTime));

        // Show the color
        gl.ClearColor(r, g, b, 0);
        gl.Clear(ClearBufferMask.ColorBufferBit);

        SwapBuffers(dc);

        // Transform [-1, 1] range of sin/cos to [0, 1] range of OpenGL color
        float NormalizeSinCos(float sinCos)
        {
            return 0.5f + sinCos / 2.0f;
        }
    }

    ReleaseDC(window, dc);
}

Final Program.cs is in the Win32Opengl repository. The result - Win32 window with changing background via OpenGL API:


Tags: csharp interop opengl win32