WGFX is a C++23 framework for graphics, GPU compute, and interactive engineering tools. It brings typed GPU resources, a scene renderer, FFT plans, and ray queries together with SDL windows and ImGui controls. The same foundation can run a windowless signal-processing job, animate a particle system, inspect a model, or progressively path trace it.

The project is built on SDL3 GPU and Slang. Ordinary graphics and compute shaders are compiled and embedded at build time: SPIR-V for Vulkan on Windows and Linux, and Metal Shading Language through SDL_shadercross on macOS. This replaces the project’s earlier Dawn/WebGPU and WGSL architecture. Native Vulkan and Metal ray backends extend that foundation while sharing the SDL device and command stream.

Progressive ray traced satellite with textured metal surfaces and blue solar panels
The progressive path tracer combines imported PBR textures, area lights, soft shadows, and diffuse and glossy reflections. This capture uses the Vulkan RT backend.

Five libraries, one workflow

WGFX separates reusable computation and asset loading from the application shell. A compute tool does not need to create a window, and a ray query does not need to render an image.

Library What it provides
wgfx::compute Device contexts, typed buffers and textures, compute pipelines, command lists, transfers, and fence-backed submissions
wgfx::fft Reusable single-precision 1D and 2D FFT plans, complex and real transforms, batching, and command-list composition
wgfx::assets Shared CPU model imports: geometry, materials, hierarchy, images, and lights
wgfx::ray Closest-hit and occlusion queries, moving instances, query filters, custom transport, GPU queues, and image output
wgfx::app SDL windows, ImGui and plotting, cameras, ECS scenes, PBR rendering, shadows, ambient occlusion, picking, and custom render features

FFT builds on compute; ray queries use compute and assets; the app layer adds presentation and adapters for live scenes. CMake’s exported targets bring in their dependencies automatically.

The common unit of work is a command list. Uploads, compute dispatches, FFTs, ray queries, and downloads can be recorded in order, then submitted together. Submission exposes ready() and wait() for completion. Resources retain their device context, and applications keep resources and pipelines alive until their submitted work finishes.

Interactive scenes and tools

Subclass wgfx::App to build an application around a scene, orbit camera, and renderer. The framework handles events, per-frame updates, UI construction, scene rendering, tone mapping, and presentation.

This complete example creates a cube with an interactive rotation control:

#include <wgfx/wgfx.hpp>
#include <imgui.h>

class CubeViewer final : public wgfx::App {
public:
    CubeViewer() : App(wgfx::AppConfig{
        .window_width = 1280,
        .window_height = 800,
        .title = "WGFX Cube",
        .schedule = {.render_policy = wgfx::AppRenderPolicy::Reactive}
    }) {
        cube = renderer->create_cube(scene, "Cube");
    }

    int ui() override {
        ImGui::Begin("Scene Controls");
        if (ImGui::SliderFloat("Rotation", &angle, 0.0f, 360.0f)) {
            auto& transform = scene.registry().get<wgfx::Transform>(cube);
            transform.rotation = glm::angleAxis(
                glm::radians(angle), glm::vec3(0.0f, 1.0f, 0.0f));
            requestRender();
        }
        ImGui::End();
        return 0;
    }

private:
    entt::entity cube = entt::null;
    float angle = 0.0f;
};

int main() {
    CubeViewer app;
    return app.run();
}

Continuous rendering is the default for animation. This example selects Reactive, which waits for events and wakes when requestRender() signals a change. update(float) supplies the per-frame simulation hook; fixed-step scheduling helpers are also available for workloads whose simulation timestep should be independent of display frames.

Scene3D is the default application profile, with 4× scene MSAA. UiOnly and UiCompute skip the built-in scene renderer and shadow resources, while retaining UI and custom rendering. ImPlot and ImPlot3D supply 2D and 3D plotting. Reusable AppModule components can share application services and receive update, input, and UI callbacks.

Materials, light, and depth

The scene renderer uses EnTT entities, GLM transforms, and metallic/roughness PBR materials. It supports procedural meshes, imported model hierarchies, normal and displacement maps, and emissive surfaces. Material textures have mip chains; filtered normal variance and screen-space normal gradients reduce specular shimmer, while displacement uses a height mip appropriate to the mesh’s sampling density.

WGFX PBR material demonstration with a textured displaced sphere
The PBR example exposes material, lighting, displacement, and ambient-occlusion controls.

Up to eight directional, point, or spot lights contribute to a scene. The first directional light uses a shared shadow map; up to three point lights and three spotlights have local shadow maps. Shadow updates can run every frame, only when the scene changes, or on explicit request.

Environment maps add diffuse irradiance and GGX-prefiltered reflections from EXR or standard images. Their CPU preparation can run on a worker thread before GPU upload. The scene renders into linear HDR, followed by exposure and a selectable tone mapper—None, Reinhard, ACES Filmic, or AgX—before the UI is composited.

Optional ground-truth ambient occlusion (GTAO) adds local contact and cavity shading to indirect lighting. Inside an app, enabling it is a small configuration change:

renderer->set_ambient_occlusion({
    .enabled = true,
    .radius = 0.75f, // World-space radius.
    .strength = 1.0f,
    .quality = wgfx::AmbientOcclusionQuality::Medium
});

Full- and half-resolution evaluation modes trade detail for cost. This is screen-space occlusion: it sees visible depth, does not supply bounce lighting, and does not automatically include custom render features. It is off by default in the library.

WGFX lighting demonstration with colored objects and cast shadows
Direct lighting and shadow maps complement the renderer's separate ambient and environment-lighting terms.

Shared models, instances, and picking

wgfx::assets imports a model once into shared CPU data, without a GPU or window. Both raster rendering and ray geometry can consume that import. The bundled Assimp configuration supports glTF/GLB; system Assimp installations may provide other importers. The import preserves source hierarchy, materials, and lights, and the app can spawn embedded lights as scene entities.

Raster-rendered satellite with textured metal surfaces, solar panels, and shadows on the ground
The model viewer renders the satellite with the raster PBR renderer. The same imported asset also appears in the progressive path tracer above.

Visible entities sharing a mesh and material are automatically batched into instanced draws. For larger populations, InstanceSet draws directly from a dedicated GPU buffer, with compact transforms and partial updates. SegmentSet represents dynamic graphs as world-space cylinders with independently updated endpoints, topology, color, and width.

App::pick() queues an ID-rendering pass and readback, then delivers entity and instance IDs on the app thread. Custom RenderFeature implementations can participate in main, shadow, and picking passes, so application-owned geometry can fit into the same rendering lifecycle.

A large field of colored cubes in WGFX's instancing demonstration
Explicit instance buffers support large populations without one scene entity per object.

GPU particle simulation

The particles example puts compute and rendering together in a compact simulation. It starts with 16,384 particles and three emitters, supports up to eight emitters, and accepts a fixed pool of up to one million particles through --count. Adding emitters redistributes that pool. Controls expose gravity, six color palettes, and each emitter’s position, spawn radius, launch speed, and lifetime.

GPU particle simulation with orange, pale blue, and purple particle plumes
Multiple emitters share one GPU particle pool; the UI changes compact simulation parameters while particle state stays on the device.

Each unpaused frame records one compute dispatch to integrate motion, age particles, and respawn expired ones. A single instanced draw then renders camera-facing, alpha-blended sprites from the same buffer. Sprites shrink and fade with age. Initialization and reset upload particle state; normal updates upload parameters and changed emitter data, with no particle readback.

This excerpt from ParticleSystem::update() shows the simulation dispatch, using the example’s existing buffers, pipeline, and uniforms:

auto commands = device_.begin_commands("particle simulation");
auto pass = commands.begin_compute_pass(compute_pipeline_, "Particle simulation");
pass.bind_readonly_storage(0, *emitter_buffer_);
pass.bind_readwrite_storage(*particles_);
pass.set_uniform(uniforms);
pass.dispatch((particle_count_ + 255u) / 256u);
pass.end();
commands.submit();

ParticleSystem belongs to the example, while the buffers, compute passes, and render-feature hooks are reusable library APIs. The particle source and guide provide a starting point for other GPU simulations.

Ray queries and path tracing

wgfx::ray supplies geometry queries for visibility, sensors, and application-defined transport as well as image rendering. It supports closest-hit and occlusion batches, query masks, instance exclusions, and results that preserve source instance, geometry, and primitive IDs. Shader surface queries also return geometric normals.

There are three execution paths:

Backend How traversal runs
Compute shader GPU traversal of a bounding-volume hierarchy; the portable fallback, named Backend::Software in C++
Vulkan RT Native Vulkan acceleration structures and ray queries on the shared SDL device
Metal RT Native Metal acceleration structures and ray queries on supported macOS devices

The examples select Auto, preferring an available native backend and falling back to compute traversal. Library callers opt in by requesting native device features and choosing Backend::Auto. An explicit native backend request fails when unavailable. Native API availability is reported separately from dedicated hardware acceleration.

A complete closest-hit query

This function builds one triangle and traces a ray along +Z. It requests native support when available while retaining the compute fallback:

#include <wgfx/ray/ray.hpp>
#include <array>

wgfx::ray::Hit trace_triangle() {
    namespace ray = wgfx::ray;
    auto options = wgfx::DeviceContextOptions::headless_compute();
    options.native_ray_features = wgfx::NativeRayFeatures::Optional;
    auto gpu = wgfx::DeviceContext::create(options);

    std::array vertices{ray::Position{-1, -1, 2},
                        ray::Position{ 1, -1, 2},
                        ray::Position{ 0,  1, 2}};
    std::array indices{ray::TriangleIndices{0, 1, 2}};
    std::array meshes{ray::TriangleMeshView{vertices, indices, 7}};
    auto geometry = ray::AccelerationStructure::create(
        gpu, meshes, {.backend = ray::Backend::Auto});
    auto plan = ray::QueryPlan::create(gpu);

    wgfx::Buffer<ray::Ray> rays(
        gpu, "Rays", 1, wgfx::BufferUsage::ComputeStorageRead);
    wgfx::Buffer<ray::Hit> hits(
        gpu, "Hits", 1, wgfx::BufferUsage::ComputeStorageWrite);
    rays.write(std::array{ray::Ray{}}); // Origin (0,0,0), direction +Z.

    auto commands = gpu.begin_commands();
    plan.record_closest(commands, geometry, rays, hits, {.count = 1});
    commands.submit_and_wait();

    std::array<ray::Hit, 1> result;
    hits.read(result);
    return result[0]; // Hit at t=2, geometry_id=7, primitive_id=0.
}

The final readback makes this example easy to inspect. Repeated queries can retain the geometry and plan, and feed hits directly into later GPU passes. record_occlusion() provides the corresponding visibility test when a full hit record is unnecessary.

Moving scenes and custom transport

A GeometryLibrary stores shared object geometry; an InstanceScene places copies using affine transforms. Transform changes update the top-level structure without rebuilding the shared objects. Prepared updates can be recorded before queries in one command list. Geometry deformation still requires rebuilding object geometry, and live app-scene snapshots must be explicitly refreshed after edits.

For custom shaders, query.slang, QueryPipeline, and wgfx_add_query_shader() provide a common interface across the three backends. Applications can trace multiple bounces inside one dispatch or use GPU-managed ray queues and indirect dispatch for staged workloads, without reading queue counts back between bounces.

The progressive renderer

The ray_render example loads the same satellite model as the raster model viewer and places it in a studio with rectangular area lights and a reflective floor. Base-color, metallic/roughness, and tangent-space normal maps drive GGX materials. Shadow rays produce soft shadows; secondary rays produce diffuse and glossy reflections.

Each sample executes its configured bounces in one dispatch and accumulates linear HDR radiance. ray::ImageTarget resolves that buffer into a sampleable texture; ImageLayer presents it through the app’s normal tone mapping and UI. The display path stays on the GPU. Camera, material, and lighting changes restart accumulation; changing exposure reuses the existing image. The example supports interactive backend switching, headless rendering, and PNG export.

The shading and sampling policy live in the example. The ray library remains usable for non-rendering tasks. The tracer currently has no denoiser, transmission, or alpha masking, so it is also a useful place to experiment with those extensions.

Native backends are still gaining platform coverage. The project documents Vulkan RT validation on Windows with an AMD RX 9070 XT and Metal RT on an Apple M3 Max; native Linux and broader device coverage remain incomplete. The backend guide records requirements, selection behavior, and validation status, while the ray API guide covers ownership, filtering, and queue composition.

Typed compute and Slang shaders

The compute layer is useful on its own. DeviceContextOptions::headless_compute() selects offscreen Vulkan on Linux, without X11 or Wayland; elsewhere it uses ordinary unwindowed defaults. GPU workloads still require a supported device and driver.

Buffer<T> gives host code typed storage. ComputePass exposes SDL’s separate read-only storage, read-write storage, and uniform bindings. The following function multiplies an input vector by a scalar, given the compiled shader shown below:

#include <wgfx/compute.hpp>
#include <cstdint>
#include <limits>
#include <span>
#include <stdexcept>

void scale_values(const wgfx::DeviceContext& gpu,
                  wgfx::ShaderBinary shader,
                  std::span<const float> input,
                  float factor,
                  std::span<float> output) {
    if (input.size() != output.size() ||
        input.size() > std::numeric_limits<uint32_t>::max() / sizeof(float)) {
        throw std::invalid_argument("Input and output sizes must match and fit SDL buffers");
    }
    if (input.empty()) {
        return;
    }
    const auto count = static_cast<uint32_t>(input.size());
    wgfx::Buffer<float> source(gpu, "Input", count,
                              wgfx::BufferUsage::ComputeStorageRead);
    wgfx::Buffer<float> result(gpu, "Output", count,
                              wgfx::BufferUsage::ComputeStorageWrite);
    source.write(input);

    wgfx::ComputePipeline pipeline(gpu, shader, {
        .readonly_storage_buffers = 1,
        .readwrite_storage_buffers = 1,
        .uniform_buffers = 1,
        .threadcount_x = 64
    });
    struct Parameters {
        uint32_t element_count;
        float scale_factor;
    };

    auto commands = gpu.begin_commands("scale values");
    auto pass = commands.begin_compute_pass(pipeline, "scale");
    pass.bind_readonly_storage(0, source);
    pass.bind_readwrite_storage(result);
    pass.set_uniform(Parameters{count, factor});
    pass.dispatch((count + 63) / 64);
    pass.end();

    auto completion = commands.submit_async();
    completion.wait();
    result.read(output);
}

Companion scale.slang:

StructuredBuffer<float> input_values : register(t0, space0);
RWStructuredBuffer<float> output_values : register(u0, space1);
cbuffer Parameters : register(b0, space2) {
    uint element_count;
    float scale_factor;
};

[numthreads(64, 1, 1)]
void main(uint3 id : SV_DispatchThreadID) {
    if (id.x < element_count) {
        output_values[id.x] = input_values[id.x] * scale_factor;
    }
}

Within the WGFX source tree, its CMake helper compiles and embeds the shader into a target:

add_library(scale STATIC scale.cpp)
target_link_libraries(scale PUBLIC wgfx::compute)
wgfx_add_embedded_shader(scale scale_shader scale.slang main compute)

The generated scale_shader.hpp exposes wgfx::embedded::scale_shader(), which supplies the ShaderBinary. Slang is pinned and built by default, with Release shader optimization enabled. macOS builds translate the intermediate SPIR-V to MSL through SDL_shadercross.

Buffer<T>::write() and read() are synchronous convenience transfers. For repeated work, retain buffers and pipelines, reuse TransferBuffer staging allocations, and record uploads, compute, and downloads into a command list. Wait for GPU completion before reading downloaded staging memory or reusing it. This avoids treating convenience readback as an asynchronous operation.

GPU FFTs and scientific visualization

wgfx::fft supports batched 1D and 2D complex-to-complex, real-to-complex, and complex-to-real transforms. Plans accept power-of-two and non-power-of-two dimensions, subject to device and buffer limits; real transforms require a final dimension of at least two and use a Hermitian half-spectrum layout. C2C transforms also support in-place execution.

Plans cache pipelines and scratch buffers. execute() returns a Submission, while record() appends passes to an existing command list. None, Unitary, and Backwards normalization cover unscaled, symmetric, and inverse-scaled conventions.

This 2D round trip uses inverse scaling so the recovered image matches the input within floating-point error:

#include <wgfx/fft/fft.hpp>
#include <vector>

void transform_image(const wgfx::DeviceContext& gpu) {
    constexpr uint32_t width = 512;
    constexpr uint32_t height = 512;
    const auto usage = wgfx::BufferUsage::ComputeStorageRead |
                       wgfx::BufferUsage::ComputeStorageWrite;

    wgfx::Buffer<wgfx::fft::Complex> image(gpu, "Image", width * height, usage);
    wgfx::Buffer<wgfx::fft::Complex> spectrum(gpu, "Spectrum", width * height, usage);
    wgfx::Buffer<wgfx::fft::Complex> restored(gpu, "Restored", width * height, usage);
    std::vector<wgfx::fft::Complex> pixels(width * height);
    pixels[0] = {1.0f, 0.0f};
    image.write(pixels);

    auto plan = wgfx::fft::FFTPlan::create2D(
        gpu, width, height, wgfx::fft::TransformType::C2C,
        1, wgfx::fft::Normalization::Backwards);

    auto commands = gpu.begin_commands("FFT round trip");
    plan.record(commands, image, spectrum, wgfx::fft::Direction::Forward);
    // Additional frequency-domain compute passes can be recorded here.
    plan.record(commands, spectrum, restored, wgfx::fft::Direction::Inverse);
    commands.submit_and_wait();
    restored.read(pixels);
}

The implementation selects among shared-memory radix-2 kernels, Stockham fallbacks, large-transform decompositions, tiled transposes, packed real transforms, and Bluestein convolution. Applications work with a plan rather than selecting kernels themselves. The FFT guide documents layouts and lifetime requirements.

An ocean surface generated from a GPU FFT wave spectrum
The ocean example records spectrum evolution, inverse FFT, and water shading together, combining Fresnel reflections, sun glints, and foam.

Examples to explore

The examples exercise the framework as complete applications, from small dispatches to simulations with live controls and telemetry.

Area Examples
Compute and scheduling device, add, pi, and flows: device inspection, vector addition, Monte Carlo estimation, and Taskflow orchestration
Rendering and materials triangle, texture, shapes, model, earth, pbr, displacement, hdri, lights, and tonemapping
Dynamic scenes particles, instancing, dynamic_graph, and rocket: particle fountains, instance buffers, changing graphs, and flight telemetry
Signal processing and physics fft_ocean, fft_diffraction, fft_image_filter, and antenna: waves, optical propagation, frequency filters, and phased-array patterns
Ray applications ray_sensor, ray_bounces, ray_queues, ray_viewer, and ray_render: model queries, transport, queue processing, interactive inspection, and path tracing
Plotting plots and plots3d: ImPlot and ImPlot3D integration
WGFX rocket flight simulation alongside trajectory and telemetry plots
The rocket example combines an interactive scene with simulation controls and ImPlot telemetry.

The example catalog links each application’s controls and implementation. Dedicated FFT, ray, ambient-occlusion, and antenna benchmarks complement the demos. Their timing boundaries differ: displayed frame rates and command-submission times should not be read as isolated GPU kernel timings.

Build and use WGFX

WGFX requires C++23, CMake 3.25+, Git, and Python 3, plus a working Vulkan driver on Windows/Linux or Metal device on macOS. Interactive examples also need a window environment. The default build fetches pinned dependencies through CPM.cmake and builds the shader tools; macOS needs the Xcode command-line toolchain and SDK, and Linux needs development packages for the enabled SDL backends.

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --config Release --parallel
cmake --install build --config Release --prefix build/dist
ctest --test-dir build -C Release --output-on-failure

Installed examples and assets live under build/dist/bin. From the repository root, try:

./build/dist/bin/particles --count 65536
./build/dist/bin/ray_render --list-ray-backends
./build/dist/bin/ray_render --ray-backend auto
./build/dist/bin/ray_render --headless --width 960 --height 600 --samples 256 --output satellite.png

On Windows, use the corresponding .exe files. Keep installed model assets and the hdris directory with the applications that use them.

Consumers can use find_package(wgfx CONFIG REQUIRED) and link the library they need:

cmake_minimum_required(VERSION 3.25)
project(cube_viewer LANGUAGES CXX)
find_package(wgfx CONFIG REQUIRED)
add_executable(cube_viewer main.cpp)
target_compile_features(cube_viewer PRIVATE cxx_std_23)
target_link_libraries(cube_viewer PRIVATE wgfx::app)

Set CMAKE_PREFIX_PATH to the absolute path of build/dist. The installed package includes helpers for ordinary embedded shaders and portable ray-query shaders; compiling new shaders requires compatible shader tools.

Option Default Purpose
WGFX_ENABLE_EXAMPLES ON Build example applications
WGFX_ENABLE_TESTS ON Build and register tests
WGFX_ENABLE_BENCHMARKS ON Build benchmark executables
WGFX_ENABLE_IMGUI ON Enable UI and plotting support
WGFX_BUILD_SHARED OFF Build shared WGFX libraries
WGFX_ENABLE_X11 ON Enable X11 in bundled SDL on Linux
WGFX_USE_SYSTEM_DEPS OFF Use installed dependencies where supported
WGFX_ENABLE_VULKAN_RAYS Platform-dependent Native Vulkan rays; defaults on for Windows/Linux with bundled static dependencies
WGFX_ENABLE_METAL_RAYS Platform-dependent Native Metal rays; defaults on for bundled static builds with a macOS 14+ SDK
WGFX_SLANGC_EXECUTABLE empty Use an existing Slang compiler

Native ray backends require bundled static SDL and runtime device support. Existing CMake caches retain their selected options. System-dependency builds currently require ImGui to be disabled; also disable the examples when building without ImGui. Tests are labeled headless or display; headless GPU tests still need a working GPU backend.

WGFX is MIT licensed, with dependencies and example assets retaining their own licenses. The repository, compute guide, and application guide provide the detailed API and build documentation behind this overview.