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 {} voidSource:https://dev.to/puffball1567/bgfx-instancing-tutorial-draw-625-cubes-with-one-draw-call-m10

Related posts