If you draw 625 copies of the same cube, does the CPU need to submit 625 separate drawing commands? It can instead supply a list of placements and request the whole group at once. Instancing draws a shared mesh repeatedly, using different data for each copy.
GPU Implementation with bgfx is a series about building GPU effects and processing examples with bgfx. We use the library to make small working programs, connecting code to what appears on screen. This second installment uses a colorful field of moving cubes to explore how drawing commands can be combined.
The first demo arranged 25 × 25 cubes and animated their height in a vertex shader. Here we keep the same geometry, wave, and colors while reducing the cube submissions from 625 calls to one. An unchanged picture can be produced through a different data path.
You do not need prior experience with instancing. We will recap the relationship between geometry and drawing, then work through the changes using C++ arrays and loops. We will modify the previous cube renderer: keep its window, vertex and index buffers, and camera setup, then replace placement handling and the drawing loop. This is an independent tutorial, and I am not part of the bgfx team. The demo code was written for this article. bgfx uses the BSD 2-Clause license; SDL3, which creates the window, uses the zlib license.
1. Sharing geometry did not combine the draw calls
The cube is a mesh: a shape described by vertices and triangles. Each of its six square faces is split into two triangles, giving twelve triangles per cube.
The demo's vertex buffer, vb, stores positions and face normals. Its index buffer, ib, stores the vertex numbers that form each triangle. Both buffers are created once and shared by all cubes.
In the individual drawing path, each placement still receives a separate submission:
Shared cube mesh
├─ set placement A → submit
├─ set placement B → submit
└─ …625 submissions
bgfx::submit submits a draw using the configured geometry, shader program, and rendering state. It does not mean the GPU has completed the drawing when the call returns. Drawing API
Instancing keeps the shared geometry and changes how placements are supplied:
Shared mesh ─────────────────────────────┐
├─ one submit → 625 cubes
Placement records [A, B, C, …625 records] ─┘
Each placement record belongs to an instance, one copy of the shared shape. The GPU draws the mesh using each record. This does not divide the triangle count by 625.
2. What changes from the previous implementation
We will change how the CPU supplies placements and how the vertex shader receives them.
| Location | Previous implementation | This implementation |
|---|---|---|
| C++ rendering loop | Set a model matrix and submit each cube | Fill a list of centers and submit the group |
| Vertex shader | Obtain placement from a model matrix | Read placement from instance input |
| Shader input definitions | Vertex positions and normals | Add one center per instance |
| Fragment shader | Compute color and shading | Keep the same computation |
Put the C++ changes in main.cpp and the additional input definition in varying.def.sc. Create vs_instanced.sc for the new vertex shader and pair it with the existing fs_wave.sc. Keep vs_wave.sc for comparison.
The explanation focuses on changes to the previous renderer. The complete files at the end include window creation, rendering, building, and verification, so you can also try the program without having the previous project.
The target is Linux, SDL3/X11, the bgfx API 159 SDK, and the OpenGL backend. Use matching headers, libraries, and shaderc. Other backends need their corresponding shader binaries.
Use SDL3 for the window and input
When adapting the first installment, migrate its SDL initialization too. SDL3 owns the window and input; bgfx handles rendering. No custom library such as CBSS is needed.
Include <SDL3/SDL.h> and, in the file defining main, <SDL3/SDL_main.h>. SDL_Init returns true on success, so check failure with if (!SDL_Init(SDL_INIT_VIDEO)). Update the compiler and linker flags to use pkg-config --cflags --libs sdl3. SDL3 migration guide
After successfully creating window, obtain its X11 handles through properties:
const SDL_PropertiesID properties = SDL_GetWindowProperties(window);
void* display = SDL_GetPointerProperty(
properties, SDL_PROP_WINDOW_X11_DISPLAY_POINTER, nullptr);
const Sint64 nativeWindow = SDL_GetNumberProperty(
properties, SDL_PROP_WINDOW_X11_WINDOW_NUMBER, 0);
if (!display || nativeWindow == 0) {
std::fprintf(stderr, "This demo requires SDL3 X11.\n");
SDL_DestroyWindow(window);
SDL_Quit();
return 1;
}
// init is a bgfx::Init. Set these fields before bgfx::init(init).
init.swapChain.ndt = display;
init.swapChain.nwh = reinterpret_cast<void*>(uintptr_t(nativeWindow));
display identifies the X11 connection; nativeWindow identifies the native window. Do not pass the SDL window pointer itself as the native handle. This example selects X11/XWayland with SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "x11") before initialization. Window properties API
Use SDL_EVENT_QUIT for exit events and SDL_EVENT_KEY_DOWN for key presses. Read the key from event.key.key; the R key constant is SDLK_R. The instance buffers and shaders below belong to bgfx rather than SDL.
3. Each cube only needs a center position
The previous version used a 4 × 4 model matrix for each cube. Since these cubes neither rotate nor scale, a center position is sufficient.
X represents the horizontal direction, Y represents height, and Z represents depth. Looking toward the origin from negative Z, positive X points right, positive Y points up, and positive Z points away from you. The demo camera looks from above at an angle, so screen-right does not necessarily match positive X.
We represent a placement with four floats:
// A center 1.12 units along positive X from the origin.
const float record[4] = {1.12f, 0.0f, 0.0f, 0.0f};
The first three components are X, Y, and Z; the fourth is unused. A transient instance record in bgfx needs a size that is a multiple of 16 bytes. In this SDK, four floats occupy 16 bytes. The distance from one record to the next is called its stride. Instance buffer API
First 16-byte record Second 16-byte record
[ centerX, Y, Z, unused ] [ centerX, Y, Z, unused ] …
↓ ↓
Cube 1 Cube 2
Vertex data and instance data have different jobs. A vertex position identifies a corner within the cube; the instance center places that cube in the scene. All vertices belonging to the same instance use the same center.
4. Allocate space for this frame
Before the rendering loop, define the count and stride:
constexpr uint16_t instanceStride = 16;
constexpr uint32_t instanceCount = gridSize * gridSize; // 25 × 25 = 625
Each frame, check capacity before allocating the records. This excerpt comes from the demo's instancing path:
// The API returns an available record count, not a boolean.
const bool useInstancing = !individual &&
bgfx::getAvailInstanceDataBuffer(instanceCount, instanceStride) == instanceCount;
if (useInstancing) {
bgfx::InstanceDataBuffer instances;
// The function fills the structure passed through its first argument.
bgfx::allocInstanceDataBuffer(&instances, instanceCount, instanceStride);
// Fill the records and configure the draw here.
}
individual is a boolean used to compare the rendering paths. Declare bool individual = false; before the rendering loop. Set it to true for individual draws, or false to use instancing when capacity permits.
instances is a description of the allocation, not an inline array containing 625 cubes. instances.data points to the writable bytes, instances.stride gives the record spacing, and instances.num gives the instance count. In C++, &instances passes the address of that structure.
This allocation is temporary and belongs to the current frame. Do not retain its pointer across the next bgfx::frame(); allocate again in the next frame. It does not require your own delete or bgfx::destroy. If there is insufficient capacity, use the previous individual drawing loop in the else branch instead of leaving cubes out. Allocation and lifetime contract
5. Write the placement records
This loop runs inside the preceding if, immediately after allocation:
for (int z = 0; z < gridSize; ++z) {
for (int x = 0; x < gridSize; ++x) {
// Subtract 12 from indices 0–24 to center the grid on the origin.
const float record[4] = {(x-12)*1.12f, 0.0f, (z-12)*1.12f, 0.0f};
const uint32_t index = uint32_t(z * gridSize + x);
// Move index records into the buffer, then copy four floats as bytes.
std::memcpy(instances.data + index * instances.stride, record, sizeof(record));
}
}
x and z are grid indices, not distances. At x = 13, z = 12, the center becomes (1.12, 0, 0). The spacing of 1.12 leaves a gap of 0.20 between cubes whose side length is 0.92 when the grid is flat.
index flattens the two-dimensional grid into a list. The first row occupies records 0–24, the next 25–49, and so on. At z = 1, x = 2, the index is 27. Its record begins 27 × 16 = 432 bytes from the start.
Include <cstring> for std::memcpy. Copying transfers the local array's contents into bgfx's allocation. Creating another record on the next loop iteration does not remove the bytes already written.
The CPU still executes a nested loop. What changes is the repeated per-cube state setup and submission.
6. Receive instance positions in the shader
Add an instance input to varying.def.sc. This declaration follows the 05-instancing example supplied with the API 159 SDK used here:
vec4 i_data0 : TEXCOORD31;
i_data0 receives the four floats in a record. TEXCOORD31 identifies the input mapping; it does not cause a texture image to be read. Official sample input definitions
The web documentation also contains declarations starting at TEXCOORD7. SDK generations differ, so avoid mixing an input declaration from another generation with your shader compiler. This demo uses the declaration and shaderc from the same SDK.
Declare the input at the beginning of the vertex shader too:
$input a_position, a_normal, i_data0
$output v_normal, v_world, v_local
#include <bgfx_shader.sh>
uniform vec4 u_wave;
a_position and a_normal vary by vertex. i_data0 varies by instance. u_wave is a uniform shared by the draw: its .x component holds elapsed seconds, and .y holds the wave amplitude.
The shader's main() uses these values as follows:
void main()
{
// Every vertex in this cube receives the same center.
vec3 center = i_data0.xyz;
float radius = length(center.xz);
float height = u_wave.y * (
sin(radius * 0.65 - u_wave.x * 1.8)
+ 0.35 * cos(center.x * 0.5 + center.z * 0.35 + u_wave.x));
// Move the local vertex into the scene, then add the wave height.
vec3 world = a_position + center;
world.y += height;
gl_Position = mul(u_viewProj, vec4(world, 1.0));
// Translation changes neither the direction nor length of the normal.
v_normal = a_normal;
v_world = world;
v_local = a_position;
}
center.xz selects the horizontal coordinates, and length finds their distance from the origin. The sine term produces a radial wave, with a smaller cosine wave added to it. Calculating height from the center gives every vertex in one cube the same vertical displacement, preserving its shape.
For example, a vertex at (0.46, 0.46, 0.46) and a center at (1.12, 0, 0) produce (1.58, 0.46, 0.46) before adding the wave. We then add height to Y and use u_viewProj to apply the camera and projection transformations. Predefined bgfx uniforms
This shader does not read a model matrix, so the instanced path does not call setTransform. To add rotation, supply suitable rotation data and implement that transformation too. Instancing does not automatically construct a matrix.
7. Bind the list and submit once
During initialization, the demo's loadProgram helper combines the vertex and fragment shaders:
// assets identifies the directory containing the executable and shader binaries.
auto instancedProgram = loadProgram(assets, "vs_instanced.bin", "fs_wave.bin");
auto uWave = bgfx::createUniform("u_wave", bgfx::UniformType::Vec4);
loadProgram is a demo helper, not a bgfx API. It reads the binaries and calls createShader and createProgram. The reference and instanced paths use different vertex shaders but share the fragment shader.
Each frame, after filling the allocation, configure the draw inside the same if block. wave is the frame's float array containing {time, amplitude, 0, 0}.
bgfx::setVertexBuffer(0, vb); // Shared geometry
bgfx::setIndexBuffer(ib); // Triangle connectivity
bgfx::setInstanceDataBuffer(&instances); // 625 placements
bgfx::setUniform(uWave, wave); // Shared time and amplitude
bgfx::setState(BGFX_STATE_WRITE_RGB | BGFX_STATE_WRITE_A |
BGFX_STATE_WRITE_Z | BGFX_STATE_DEPTH_TEST_LESS);
bgfx::submit(0, instancedProgram); // One submission for the cubes
The bound instance count determines how many copies are drawn. The 0 in setVertexBuffer identifies a vertex stream; the 0 in submit identifies a view.
Depth handling remains the same. WRITE_Z records depth, and DEPTH_TEST_LESS selects nearer surfaces. A separate draw adds the title overlay before bgfx::frame(). One draw refers to the cubes; the complete image uses two submissions including the overlay.
8. Assemble the changes and verify the result
Place the pieces in the per-frame loop in this order:
Build the wave array from time and amplitude
↓
Check transient capacity for 625 records
├─ Enough capacity and individual is false
│ Allocate → write placements → bind buffers → submit once
└─ Otherwise
Use the previous nested loop: set a model matrix → submit 625 times
↓
Draw the title and any other elements
↓
bgfx::frame()
Compile the new vertex shader before loading it. Set BGFX_SDK to the directory containing bgfx, bx, and bimg, then run this from the project root. Adjust the compiler path if your SDK stores shadercRelease elsewhere.
# Compile the new vertex shader for OpenGL.
mkdir -p build
"$BGFX_SDK/bgfx/.build/linux64_gcc/bin/shadercRelease" \
-f shaders/vs_instanced.sc -o build/vs_instanced.bin \
--type vertex --platform linux -p 330 \
--varyingdef shaders/varying.def.sc -i "$BGFX_SDK/bgfx/src"
Rebuild the C++ program using the same dependencies as before. Match the filename and location of vs_instanced.bin to what loadProgram reads. Saving shader source alone does not update the binary loaded by the program.
First check the individual path with individual = true, then change it to false and rebuild. The count, placement, movement, and colors should remain the same. After rendering several frames, inspect the submission count:
// These statistics describe a rendered frame; do not compare immediately after initialization.
const bgfx::Stats* stats = bgfx::getStats();
std::printf("Submitted draws: %u\n", stats->numDraw);
The statistics include more than cubes. The verification program draws one title overlay and omits the empty submission made by touch, giving 626 for individual rendering and 2 for instancing. Account for any additional UI or other drawing in your application.
For a strict visual comparison, fix time and amplitude. With times 0 and 2 seconds and amplitudes 0 and 1.35, all four pairs were pixel-identical in this OpenGL run. Changing time had no effect at zero amplitude, while the enabled wave moved. Capacity exhaustion did not occur in these checks.
Fewer submissions do not imply a fixed speedup
The cube submissions drop from 625 to one. The program still draws twelve triangles for each of 625 cubes, calculates wave heights, and shades the covered pixels. A 625-fold reduction in submissions is not a 625-fold reduction in frame time.
The instance records occupy 625 × 16 = 10,000 bytes. That is the placement data we write, not a measurement of all transferred data. The CPU fills it every frame. Since placement is constant in this demo, a persistent buffer is another possible implementation. Here, transient allocation makes the write and lifetime sequence explicit.
Combining draws also requires compatible inputs and state. These cubes share their mesh, shader, and rendering state; only placement differs. Different geometry, materials, or ordering requirements for transparency may require separate groups. Start by identifying what is shared and what varies by instance.
A future installment can change the image itself: render to a target, extract bright areas, and blur them into a glow. That shifts the focus from passing a list of shapes to passing images between rendering stages.
Complete source by file
Below is the complete SDL3 implementation. Save each code block under its filename. These are full files used by the demo, not abbreviated excerpts. The SDK and SDL3 itself are separate dependencies.
Create a working directory named demo-instanced-cubes and a shaders subdirectory. Save files whose names begin with shaders/ there, and the remaining files in the working directory. The build creates build/ and generates the title image.
You need Linux with X11/XWayland, a C++20-capable g++, pkg-config, SDL3 development headers and libraries, X11 development libraries, Python 3 with Pillow, and DejaVu Sans fonts. Also provide the bgfx API 159 SDK with matching bx, bimg, and shaderc. See the bgfx build guide and SDL3 installation guide. This implementation was run with SDL 3.3.0.
The directory identified by BGFX_SDK contains sibling bgfx/, bx/, and bimg/ directories. By default the script reads shadercRelease, libbgfxRelease.a, libbimgRelease.a, and libbxRelease.a from bgfx/.build/linux64_gcc/bin/. Override BGFX_LIB_DIR and SHADERC if your outputs are elsewhere.
The complete program accepts --individual to select the reference path without editing the source. Its default is instancing, with individual rendering as a fallback when transient capacity is insufficient.
demo_support.h
#pragma once
#include <bgfx/bgfx.h>
#include <bx/math.h>
#include <SDL3/SDL.h>
#include <algorithm>
#include <array>
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <stdexcept>
#include <string>
#include <vector>
constexpr uint32_t width = 1280, height = 800;
constexpr int gridSize = 25;
// bgfx delivers screenshots from its renderer; capture only this application's pixels.
struct Capture final : bgfx::CallbackI {
void fatal(const char* file, uint16_t line, bgfx::Fatal::Enum, const char* msg) override {
std::fprintf(stderr, "bgfx fatal %s:%u: %s\n", file, line, msg);
std::abort();
}
void traceVargs(const char*, uint16_t, const char* format, va_list args) override {
std::vfprintf(stderr, format, args);
}
void profilerBegin(const char*, uint32_t, const char*, uint16_t) override {}
void profilerBeginLiteral(const char*, uint32_t, const char*, uint16_t) override {}
void profilerEnd() override {}
uint32_t cacheReadSize(uint64_t) override { return 0; }
bool cacheRead(uint64_t, void*, uint32_t) override { return false; }
void cacheWrite(uint64_t, const void*, uint32_t) override {}
void screenShot(const char* path, uint32_t w, uint32_t h, uint32_t pitch,
bgfx::TextureFormat::Enum format, const void* data, uint32_t, bool flip) override {
if (format != bgfx::TextureFormat::BGRA8 && format != bgfx::TextureFormat::RGBA8)
fatal(__FILE__, __LINE__, bgfx::Fatal::DebugCheck, "Unsupported screenshot format");
std::ofstream file(path, std::ios::binary);
file << "P6\n" << w << ' ' << h << "\n255\n";
const auto* pixels = static_cast<const uint8_t*>(data);
std::vector<char> rgb(size_t(w)*h*3);
for (uint32_t y = 0; y < h; ++y) {
const auto* row = pixels + (flip ? h - 1 - y : y) * pitch;
for (uint32_t x = 0; x < w; ++x) {
const auto* p = row + 4*x;
auto offset=(size_t(y)*w+x)*3;
rgb[offset] = char(p[format == bgfx::TextureFormat::BGRA8 ? 2 : 0]);
rgb[offset+1] = char(p[1]);
rgb[offset+2] = char(p[format == bgfx::TextureFormat::BGRA8 ? 0 : 2]);
}
}
file.write(rgb.data(), std::streamsize(rgb.size()));
if (!file) fatal(__FILE__, __LINE__, bgfx::Fatal::DebugCheck, "Screenshot write failed");
}
void captureBegin(uint32_t, uint32_t, uint32_t, bgfx::TextureFormat::Enum, bool) override {}
void captureEnd() override {}
void captureFrame(const void*, uint32_t) override {}
};
std::vector<char> readFile(const std::filesystem::path& path) {
std::ifstream input(path, std::ios::binary | std::ios::ate);
if (!input) throw std::runtime_error("Cannot open " + path.string());
const auto length = input.tellg();
if (length <= 0) throw std::runtime_error("Empty file " + path.string());
std::vector<char> bytes(static_cast<size_t>(length));
input.seekg(0);
input.read(bytes.data(), length);
if (!input) throw std::runtime_error("Cannot read " + path.string());
return bytes;
}
bgfx::ProgramHandle loadProgram(const std::filesystem::path& dir, const char* vs, const char* fs) {
auto vb = readFile(dir / vs), fb = readFile(dir / fs);
auto v = bgfx::createShader(bgfx::copy(vb.data(), uint32_t(vb.size())));
auto f = bgfx::createShader(bgfx::copy(fb.data(), uint32_t(fb.size())));
if (!bgfx::isValid(v) || !bgfx::isValid(f)) {
if (bgfx::isValid(v)) bgfx::destroy(v);
if (bgfx::isValid(f)) bgfx::destroy(f);
throw std::runtime_error("Shader creation failed");
}
auto program = bgfx::createProgram(v, f, true); // Program owns these shader references.
if (!bgfx::isValid(program)) throw std::runtime_error("Program creation failed");
return program;
}
struct Vertex { float x, y, z, nx, ny, nz; };
struct OverlayVertex { float x, y, z, u, v; };
main.cpp
#include "demo_support.h"
#include <cstring>
#include <SDL3/SDL_main.h>
int main(int argc, char** argv) {
int frameLimit = 0;
float startTime = 0.0f, amplitude = 1.35f;
bool fixedTime = false, paused = false, individual = false;
std::string screenshot, recordDir;
// Explicit deterministic controls let the validation script compare real rendered frames.
try {
for (int i = 1; i < argc; ++i) {
std::string arg = argv[i];
auto value = [&]() { if (++i >= argc) throw std::runtime_error("Missing argument"); return argv[i]; };
if (arg == "--individual") individual = true;
else if (arg == "--frames") frameLimit = std::stoi(value());
else if (arg == "--time") { startTime = std::stof(value()); fixedTime = true; }
else if (arg == "--amplitude") amplitude = std::stof(value());
else if (arg == "--screenshot") screenshot = value();
else if (arg == "--record") recordDir = value();
else throw std::runtime_error("Unknown argument: " + arg);
}
if ((!screenshot.empty() || !recordDir.empty()) && frameLimit < 4)
throw std::runtime_error("Capture requires --frames >= 4");
if (!recordDir.empty()) std::filesystem::create_directories(recordDir);
} catch (const std::exception& e) { std::fprintf(stderr, "%s\n", e.what()); return 1; }
// This demonstrator deliberately selects SDL's X11 path and bgfx's OpenGL backend.
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "x11");
if (!SDL_Init(SDL_INIT_VIDEO)) { std::fprintf(stderr, "%s\n", SDL_GetError()); return 1; }
SDL_Window* window = SDL_CreateWindow("GPU Sketchbook | Instanced cubes", width, height,
recordDir.empty() && screenshot.empty() ? 0 : SDL_WINDOW_HIDDEN);
if (!window) { std::fprintf(stderr, "%s\n", SDL_GetError()); SDL_Quit(); return 1; }
// SDL3 exposes native handles through window properties instead of SDL_SysWMinfo.
const SDL_PropertiesID properties = SDL_GetWindowProperties(window);
void* display = SDL_GetPointerProperty(properties, SDL_PROP_WINDOW_X11_DISPLAY_POINTER, nullptr);
const Sint64 nativeWindow = SDL_GetNumberProperty(properties, SDL_PROP_WINDOW_X11_WINDOW_NUMBER, 0);
if (!display || nativeWindow == 0) {
std::fprintf(stderr, "This demo requires SDL3 X11 (XWayland is fine).\n");
SDL_DestroyWindow(window); SDL_Quit(); return 1;
}
Capture capture;
bgfx::Init init;
init.type = bgfx::RendererType::OpenGL;
init.fallback = false; // A backend change would require matching shader binaries.
init.swapChain.nwh = reinterpret_cast<void*>(uintptr_t(nativeWindow));
init.swapChain.ndt = display;
init.swapChain.width = width;
init.swapChain.height = height;
init.reset = BGFX_RESET_VSYNC;
init.callback = &capture;
if (!bgfx::init(init)) { SDL_DestroyWindow(window); SDL_Quit(); return 1; }
int result = 0;
try {
const auto assets = std::filesystem::canonical(argv[0]).parent_path();
std::vector<Vertex> vertices;
std::vector<uint16_t> indices;
// Four distinct vertices per face preserve a flat face normal at each corner.
const float normals[6][3] = {{1,0,0},{-1,0,0},{0,1,0},{0,-1,0},{0,0,1},{0,0,-1}};
for (int face = 0; face < 6; ++face) {
const int axis = face / 2, a = (axis + 1) % 3, b = (axis + 2) % 3;
const uint16_t base = uint16_t(vertices.size());
for (int corner = 0; corner < 4; ++corner) {
float p[3]{};
p[axis] = (face % 2 == 0 ? 0.46f : -0.46f);
p[a] = (corner == 1 || corner == 2) ? 0.46f : -0.46f;
p[b] = corner >= 2 ? 0.46f : -0.46f;
vertices.push_back({p[0],p[1],p[2],normals[face][0],normals[face][1],normals[face][2]});
}
for (uint16_t offset : {0,1,2,0,2,3}) indices.push_back(base + offset);
}
bgfx::VertexLayout layout;
layout.begin().add(bgfx::Attrib::Position, 3, bgfx::AttribType::Float)
.add(bgfx::Attrib::Normal, 3, bgfx::AttribType::Float).end();
auto vb = bgfx::createVertexBuffer(bgfx::copy(vertices.data(), uint32_t(vertices.size()*sizeof(Vertex))), layout);
auto ib = bgfx::createIndexBuffer(bgfx::copy(indices.data(), uint32_t(indices.size()*sizeof(uint16_t))));
auto program = loadProgram(assets, "vs_wave.bin", "fs_wave.bin");
auto instancedProgram = loadProgram(assets, "vs_instanced.bin", "fs_wave.bin");
// Each record is one center position plus an unused fourth float.
constexpr uint16_t instanceStride = 16;
constexpr uint32_t instanceCount = gridSize * gridSize;
uint32_t instancedFrames = 0, individualFrames = 0;
auto uWave = bgfx::createUniform("u_wave", bgfx::UniformType::Vec4);
// A separate screen-space pass adds only typography, not the 3D effect.
const OverlayVertex quad[] = {{-1,1,0,0,0},{1,1,0,1,0},{1,-1,0,1,1},{-1,-1,0,0,1}};
const uint16_t quadIndices[] = {0,1,2,0,2,3};
bgfx::VertexLayout overlayLayout;
overlayLayout.begin().add(bgfx::Attrib::Position, 3, bgfx::AttribType::Float)
.add(bgfx::Attrib::TexCoord0, 2, bgfx::AttribType::Float).end();
auto overlayVb = bgfx::createVertexBuffer(bgfx::copy(quad, sizeof(quad)), overlayLayout);
auto overlayIb = bgfx::createIndexBuffer(bgfx::copy(quadIndices, sizeof(quadIndices)));
auto overlayProgram = loadProgram(assets, "vs_overlay.bin", "fs_overlay.bin");
auto overlayBytes = readFile(assets / "overlay.rgba");
if (overlayBytes.size() != width * height * 4) throw std::runtime_error("Wrong overlay size");
auto overlayTexture = bgfx::createTexture2D(width, height, false, 1, bgfx::TextureFormat::RGBA8,
BGFX_SAMPLER_U_CLAMP | BGFX_SAMPLER_V_CLAMP, bgfx::copy(overlayBytes.data(), uint32_t(overlayBytes.size())));
auto overlaySampler = bgfx::createUniform("s_overlay", bgfx::UniformType::Sampler);
if (!bgfx::isValid(vb) || !bgfx::isValid(ib) || !bgfx::isValid(uWave) ||
!bgfx::isValid(overlayVb) || !bgfx::isValid(overlayIb) ||
!bgfx::isValid(overlayTexture) || !bgfx::isValid(overlaySampler))
throw std::runtime_error("Resource creation failed");
bgfx::setViewClear(0, BGFX_CLEAR_COLOR | BGFX_CLEAR_DEPTH, 0x0b1020ff, 1.0f, 0);
bgfx::setViewRect(0, 0, 125, width, 605);
bgfx::setViewRect(1, 0, 0, width, height);
float view[16], projection[16];
bx::mtxLookAt(view, {27.0f, 25.0f, -32.0f}, {0.0f, 0.0f, 0.0f});
bx::mtxOrtho(projection, -25.0f, 25.0f, -11.82f, 11.82f, 0.1f, 120.0f,
0.0f, bgfx::getCaps()->homogeneousDepth);
bgfx::setViewTransform(0, view, projection);
bgfx::setViewClear(1, BGFX_CLEAR_NONE);
std::printf("Renderer: %s; vendor=0x%04x device=0x%04x; cubes=%d\n",
bgfx::getRendererName(bgfx::getRendererType()), bgfx::getCaps()->vendorId,
bgfx::getCaps()->deviceId, gridSize*gridSize);
uint64_t previous = SDL_GetPerformanceCounter();
float time = startTime;
bool running = true;
int frame = 0;
while (running && (frameLimit == 0 || frame < frameLimit)) {
SDL_Event event;
while (SDL_PollEvent(&event)) {
if (event.type == SDL_EVENT_QUIT) running = false;
if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat) {
switch (event.key.key) {
case SDLK_ESCAPE: running = false; break;
case SDLK_SPACE: paused = !paused; break;
case SDLK_UP: amplitude = std::min(3.0f, amplitude + 0.15f); break;
case SDLK_DOWN: amplitude = std::max(0.0f, amplitude - 0.15f); break;
case SDLK_R: time = 0.0f; amplitude = 1.35f; paused = false; break;
default: break;
}
}
}
const uint64_t now = SDL_GetPerformanceCounter();
if (!paused && !fixedTime) time += float(double(now-previous)/double(SDL_GetPerformanceFrequency()));
previous = now;
if (!recordDir.empty()) time = startTime + float(frame)/30.0f;
const float wave[4] = {time, amplitude, 0.0f, 0.0f};
// Check capacity before allocation. Never silently omit cubes.
const bool useInstancing = !individual &&
bgfx::getAvailInstanceDataBuffer(instanceCount, instanceStride) == instanceCount;
if (useInstancing) {
bgfx::InstanceDataBuffer instances;
bgfx::allocInstanceDataBuffer(&instances, instanceCount, instanceStride);
for (int z = 0; z < gridSize; ++z) {
for (int x = 0; x < gridSize; ++x) {
const float record[4] = {(x-12)*1.12f, 0.0f, (z-12)*1.12f, 0.0f};
const uint32_t index = uint32_t(z * gridSize + x);
// Copy bytes without assuming float alignment of the returned pointer.
std::memcpy(instances.data + index * instances.stride, record, sizeof(record));
}
}
bgfx::setVertexBuffer(0, vb);
bgfx::setIndexBuffer(ib);
bgfx::setInstanceDataBuffer(&instances);
bgfx::setUniform(uWave, wave);
bgfx::setState(BGFX_STATE_WRITE_RGB | BGFX_STATE_WRITE_A |
BGFX_STATE_WRITE_Z | BGFX_STATE_DEPTH_TEST_LESS);
bgfx::submit(0, instancedProgram);
++instancedFrames;
} else {
// Reference path also handles insufficient transient capacity.
for (int z = 0; z < gridSize; ++z) {
for (int x = 0; x < gridSize; ++x) {
float model[16];
// CPU places cube centers on a flat grid. GPU calculates their height.
bx::mtxTranslate(model, (x-12)*1.12f, 0.0f, (z-12)*1.12f);
bgfx::setTransform(model);
bgfx::setVertexBuffer(0, vb);
bgfx::setIndexBuffer(ib);
bgfx::setUniform(uWave, wave);
// Culling is deliberately disabled; opposite cube faces have independent vertices.
bgfx::setState(BGFX_STATE_WRITE_RGB | BGFX_STATE_WRITE_A |
BGFX_STATE_WRITE_Z | BGFX_STATE_DEPTH_TEST_LESS);
bgfx::submit(0, program);
}
}
++individualFrames;
}
bgfx::setVertexBuffer(0, overlayVb);
bgfx::setIndexBuffer(overlayIb);
bgfx::setTexture(0, overlaySampler, overlayTexture);
bgfx::setState(BGFX_STATE_WRITE_RGB | BGFX_STATE_WRITE_A | BGFX_STATE_BLEND_ALPHA);
bgfx::submit(1, overlayProgram);
if (!recordDir.empty()) {
char name[64]; std::snprintf(name, sizeof(name), "/frame-%04d.ppm", frame);
bgfx::requestScreenShot(BGFX_INVALID_HANDLE, (recordDir + name).c_str());
} else if (!screenshot.empty() && frame == frameLimit-2) {
bgfx::requestScreenShot(BGFX_INVALID_HANDLE, screenshot.c_str());
}
bgfx::frame();
++frame;
}
const auto* stats = bgfx::getStats();
std::printf("Completed %d frames; last frame draws=%u; amplitude=%.2f; time=%.3f\n",
frame, stats->numDraw, amplitude, time);
std::printf("Instanced frames=%u; individual frames=%u\n",
instancedFrames, individualFrames);
if (bgfx::isValid(instancedProgram)) bgfx::destroy(instancedProgram);
bgfx::destroy(overlaySampler); bgfx::destroy(overlayTexture);
bgfx::destroy(overlayProgram); bgfx::destroy(overlayVb); bgfx::destroy(overlayIb);
bgfx::destroy(uWave); bgfx::destroy(program); bgfx::destroy(vb); bgfx::destroy(ib);
} catch (const std::exception& e) { std::fprintf(stderr, "%s\n", e.what()); result = 1; }
bgfx::shutdown(); // Finish renderer work before destroying the native window or callback.
SDL_DestroyWindow(window);
SDL_Quit();
return result;
}
shaders/varying.def.sc
vec3 a_position : POSITION;
vec3 a_normal : NORMAL;
vec2 a_texcoord0 : TEXCOORD0;
vec3 v_normal : TEXCOORD1;
vec3 v_world : TEXCOORD2;
vec3 v_local : TEXCOORD3;
vec2 v_uv : TEXCOORD0;
vec4 i_data0 : TEXCOORD31;
shaders/vs_instanced.sc
$input a_position, a_normal, i_data0
$output v_normal, v_world, v_local
#include <bgfx_shader.sh>
// x: time in seconds, y: amplitude, z/w: unused.
uniform vec4 u_wave;
void main()
{
// One record per cube: all its vertices receive the same center.
vec3 center = i_data0.xyz;
float radius = length(center.xz);
float height = u_wave.y * (
sin(radius * 0.65 - u_wave.x * 1.8)
+ 0.35 * cos(center.x * 0.5 + center.z * 0.35 + u_wave.x));
vec3 world = a_position + center;
world.y += height;
gl_Position = mul(u_viewProj, vec4(world, 1.0));
// Placement only translates; normals need no rotation or rescaling.
v_normal = a_normal;
v_world = world;
v_local = a_position;
}
shaders/vs_wave.sc
$input a_position, a_normal
$output v_normal, v_world, v_local
#include <bgfx_shader.sh>
// x: time in seconds, y: amplitude, z/w: unused.
uniform vec4 u_wave;
void main()
{
// Transform the cube's center separately: every corner gets the same height.
vec3 center = mul(u_model[0], vec4(0.0, 0.0, 0.0, 1.0)).xyz;
float radius = length(center.xz);
float height = u_wave.y * (
sin(radius * 0.65 - u_wave.x * 1.8)
+ 0.35 * cos(center.x * 0.5 + center.z * 0.35 + u_wave.x));
vec3 world = mul(u_model[0], vec4(a_position, 1.0)).xyz;
world.y += height;
gl_Position = mul(u_viewProj, vec4(world, 1.0));
// Model matrices only translate; normals need no rotation or rescaling here.
v_normal = a_normal;
v_world = world;
v_local = a_position;
}
shaders/fs_wave.sc
$input v_normal, v_world, v_local
#include <bgfx_shader.sh>
void main()
{
// A spatial palette: raised cubes also move through the color gradient.
float phase = v_world.x * 0.022 + v_world.z * 0.018 + v_world.y * 0.055;
vec3 color = 0.55 + 0.45 * cos(6.2831853 *
(vec3(phase, phase, phase) + vec3(0.02, 0.35, 0.65)));
float light = 0.32 + 0.68 * max(dot(normalize(v_normal),
normalize(vec3(-0.45, 0.85, -0.55))), 0.0);
// The second-largest local coordinate approaches 0.46 near a face edge.
vec3 p = abs(v_local);
float edgeCoordinate = max(min(p.x, p.y), max(min(p.y, p.z), min(p.z, p.x)));
float edge = smoothstep(0.425, 0.455, edgeCoordinate);
color = color * light + edge * vec3(0.12, 0.18, 0.22);
gl_FragColor = vec4(color, 1.0);
}
shaders/vs_overlay.sc
$input a_position, a_texcoord0
$output v_uv
#include <bgfx_shader.sh>
void main()
{
gl_Position = vec4(a_position, 1.0);
v_uv = a_texcoord0;
}
shaders/fs_overlay.sc
$input v_uv
#include <bgfx_shader.sh>
SAMPLER2D(s_overlay, 0);
void main()
{
gl_FragColor = texture2D(s_overlay, v_uv);
}
make_overlay.py
"""Create the demo's transparent title card; no screenshot manipulation."""
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
root = Path(__file__).resolve().parent
out = root / "build"
out.mkdir(exist_ok=True)
im = Image.new("RGBA", (1280, 800))
d = ImageDraw.Draw(im)
d.rectangle((0, 0, 1279, 124), fill="#0b1020")
d.rectangle((0, 730, 1279, 799), fill="#0b1020")
fonts = Path("/usr/share/fonts/truetype/dejavu")
def font(size, bold=False):
return ImageFont.truetype(str(fonts / ("DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf")), size)
d.rounded_rectangle((46, 38, 53, 61), 3, fill="#6ee7dc")
d.text((65, 38), "GPU SKETCHBOOK / 02", font=font(16, True), fill="#94b4c3")
d.text((44, 72), "625 cubes. One draw.", font=font(49, True), fill="#f2f5ff")
d.text((47, 136), "Same wave. Shared mesh. Per-instance positions.", font=font(18), fill="#97a7bd")
d.rounded_rectangle((1040, 42, 1232, 79), 18, fill="#152a35", outline="#30525e")
d.ellipse((1057, 56, 1067, 66), fill="#6ee7dc")
d.text((1080, 50), "bgfx / OpenGL", font=font(15), fill="#c7e7e6")
d.line((48, 730, 1232, 730), fill="#293446", width=1)
d.text((48, 751), "SPACE pause UP / DOWN amplitude R reset ESC exit", font=font(15), fill="#a7b7cb")
d.text((1030, 751), "bgfx instancing", font=font(15), fill="#6ee7dc")
(out / "overlay.rgba").write_bytes(im.tobytes())
build.sh
#!/usr/bin/env bash
set -euo pipefail
cd -- "$(dirname -- "$0")"
: "${BGFX_SDK:?Set BGFX_SDK to the directory containing bgfx, bx, and bimg}"
bgfx_lib_dir="${BGFX_LIB_DIR:-$BGFX_SDK/bgfx/.build/linux64_gcc/bin}"
shaderc_path="${SHADERC:-$bgfx_lib_dir/shadercRelease}"
mkdir -p build
"${PYTHON:-python3}" make_overlay.py
for stage in vs_wave vs_instanced fs_wave vs_overlay fs_overlay; do
shader_type=vertex
if [[ "$stage" == fs_* ]]; then shader_type=fragment; fi
"$shaderc_path" -f "shaders/$stage.sc" -o "build/$stage.bin" \
--type "$shader_type" --platform linux -p 330 \
--varyingdef shaders/varying.def.sc -i "$BGFX_SDK/bgfx/src"
done
read -r -a sdl_cflags <<< "$(pkg-config --cflags sdl3)"
read -r -a sdl_libs <<< "$(pkg-config --libs sdl3)"
g++ -std=c++20 -O2 -Wall -Wextra -Werror -fmax-errors=5 -DBX_CONFIG_DEBUG=0 \
"${sdl_cflags[@]}" -I"$BGFX_SDK/bgfx/include" -I"$BGFX_SDK/bx/include" \
main.cpp -o build/instanced-cubes \
"$bgfx_lib_dir/libbgfxRelease.a" "$bgfx_lib_dir/libbimgRelease.a" \
"$bgfx_lib_dir/libbxRelease.a" "${sdl_libs[@]}" -lX11 -ldl -lpthread -lrt
verify_instancing.py
"""Compare actual framebuffer pixels and draw counts, not estimated FPS."""
from pathlib import Path
import json
import re
import subprocess
from PIL import Image, ImageChops
root = Path(__file__).resolve().parent
build = root / 'build'
report = {}
images = {}
for name, time, amplitude in [('flat-0', 0, 0), ('flat-2', 2, 0), ('wave-0', 0, 1.35), ('wave-2', 2, 1.35)]:
for mode in ['instanced', 'individual']:
key = name + '-' + mode
path = build / (key + '.ppm')
args = [str(build / 'instanced-cubes'), '--frames', '8', '--time', str(time),
'--amplitude', str(amplitude), '--screenshot', str(path)]
if mode == 'individual':
args.append('--individual')
run = subprocess.run(args, check=True, capture_output=True, text=True, timeout=30)
draws = int(re.search(r'last frame draws=(\d+)', run.stdout)[1])
assert draws == (2 if mode == 'instanced' else 626), run.stdout
assert ('Instanced frames=8' if mode == 'instanced' else 'individual frames=8') in run.stdout
images[key] = Image.open(path).convert('RGB')
assert images[key].size == (1280, 800)
assert len(images[key].getcolors(1280*800) or []) > 100
report[key] = {'draws_including_overlay': draws, 'stdout': run.stdout.strip()}
delta = ImageChops.difference(images[name+'-instanced'], images[name+'-individual'])
assert delta.getbbox() is None, f'{name}: rendering paths differ'
assert ImageChops.difference(images['flat-0-instanced'], images['flat-2-instanced']).getbbox() is None
changed = sum(max(pixel) > 8 for pixel in ImageChops.difference(images['wave-0-instanced'], images['wave-2-instanced']).getdata())
assert changed > 10000, changed
report['checks'] = {'four_pairs_pixel_identical': True, 'zero_amplitude_time_invariant': True, 'moving_wave_changed_pixels': changed}
(build/'instancing-validation.json').write_text(json.dumps(report, indent=2)+'\n')
print(json.dumps(report['checks']))
Build and run the saved files
Run these commands inside demo-instanced-cubes. Set PYTHON to an interpreter with Pillow installed.
export BGFX_SDK=/path/to/bgfx-sdk
export PYTHON=/usr/bin/python3
pkg-config --modversion sdl3
bash build.sh
./build/instanced-cubes
# Render the same scene with individual draws.
./build/instanced-cubes --individual
# Compare framebuffer images and draw counts under four fixed conditions.
"$PYTHON" verify_instancing.py
Space pauses, Up/Down change amplitude, R resets, and Esc exits. The verification script uses hidden windows but still requires access to X11 and OpenGL. It writes build/instancing-validation.json and fails if images differ or submission counts do not match expectations.
Clay Board Style System
Clay Board Style System is a foundation I am developing for native GUI toolkits, drawing on CSS concepts. This demo runs independently with C++ and SDL3 and does not use CBSS. If you want to combine GPU rendering with UI layout and input, see its bgfx adapter through bgfxim and the GPU host documentation.
Top comments (0)