For the Cmakelist we will need to have SDL3 and SDL3_image, we will put every images and shaders in the folder assets.
The folder contains multiple file so we need to find all the soource files using glob:
cmake_minimum_required(VERSION 3.16)
project(SDL3_Tilemap)
# Set C++ standard
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
file(GLOB ASSETS "assets/*")
file(COPY ${ASSETS} DESTINATION ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/assets)
find_package(SDL3 REQUIRED)
find_package(SDL3_image REQUIRED)
file(GLOB SOURCES "src/*.cpp" "src/*.hpp")
add_executable(SDL3_Tilemap ${SOURCES})
target_include_directories(SDL3_Tilemap PRIVATE ${CMAKE_SOURCE_DIR}/rapidjson/include)
target_link_libraries(SDL3_Tilemap PRIVATE SDL3::SDL3 SDL3_image::SDL3_image)
To do display a tilemap we first need to load the tileset. To store the data of the tileset we need a struct:
struct Tile {
int id;
SDL_FRect rect;
};
struct Tileset {
std::string name;
std::string imagePath;
SDL_Surface *surface = nullptr;
int imageWidth;
int imageHeight;
int columns;
int tileWidth;
int tileHeight;
int tilecount;
std::vector<Tile> tiles;
void load();
void parseTileset(const std::string& filename);
};
void parseTileset(const std::string& filename) {
std::ifstream ifs(filename);
if (!ifs.is_open()) {
std::cerr << "Error opening file: " << filename << std::endl;
return;
}
rapidjson::IStreamWrapper isw(ifs);
rapidjson::Document doc;
doc.ParseStream(isw);
if (doc.HasParseError()) {
std::cerr << "JSON parse error!" << std::endl;
return;
}
// Extract top-level values
columns = doc["columns"].GetInt();
imagePath = doc["image"].GetString();
imageHeight = doc["imageheight"].GetInt();
imageWidth = doc["imagewidth"].GetInt();
name = doc["name"].GetString();
tileWidth = doc["tilewidth"].GetInt();
tileHeight = doc["tileheight"].GetInt();
tilecount = doc["tilecount"].GetInt();
load();
//std::cout << "Tileset Name: " << tilesetName << std::endl;
//std::cout << "Image: " << image << " (" << imageWidth << "x" << imageHeight << ")" << std::endl;
//std::cout << "Columns: " << columns << ", Tile Size: " << tileWidth << "x" << tileHeight << std::endl;
// Parsing the "tiles" array
if (doc.HasMember("tiles") && doc["tiles"].IsArray()) {
const rapidjson::Value& tiles = doc["tiles"];
for (const auto& tile : tiles.GetArray()) {
int tileID = tile["id"].GetInt();
std::cout << "\nTile ID: " << tileID << std::endl;
if (tile.HasMember("properties") && tile["properties"].IsArray()) {
for (const auto& property : tile["properties"].GetArray()) {
std::string propName = property["name"].GetString();
std::string propType = property["type"].GetString();
std::string propValue = property["value"].GetString();
std::cout << " Property: " << propName << " (" << propType << ") = " << propValue << std::endl;
}
}
}
}
}
In tiled tiles can have properties in this tutorial we do not make use of this.
For the load function:
void load() {
std::string path = "../assets/"+imagePath;
surface = IMG_Load(path.c_str());
if (!surface) {
std::cerr << "SDL_image Load Error: " << SDL_GetError() << std::endl;
return;
}
int numRows = imageHeight/tileHeight;
int numCols = columns;
tiles.reserve(tilecount);
for(int i = 0; i < numRows; i++) {
for(int j = 0; j < numCols; j++) {
Tile tile { i*numCols + j,
SDL_FRect{
static_cast<float>(j*tileWidth),
static_cast<float>(i*tileHeight),
static_cast<float>(tileWidth),
static_cast<float>(tileHeight)
}
};
tiles.emplace_back(tile);
}
}
std::cout << tiles.size() << std::endl;
}
After loading the tileset we need to load the tilemap, we will create a struct:
struct TileMap {
int compressionlevel;
int height;
bool infinite;
std::vector<std::vector<int>> layers;
int parseTilemap(std::string path);
};
For the parseTilemap function we still use rapidjson:
int parseTilemap(std::string path) {
// Step 1: Read the JSON file into a string
std::ifstream file(path);
if (!file) {
std::cerr << "Error opening file!" << std::endl;
return 1;
}
std::stringstream buffer;
buffer << file.rdbuf();
std::string jsonData = buffer.str();
// Step 2: Parse the JSON string
Document document;
document.Parse(jsonData.c_str());
// Step 3: Check if parsing was successful
if (document.HasParseError()) {
std::cerr << "Error parsing JSON!" << std::endl;
return 1;
}
// Step 4: Access JSON fields
if (document.HasMember("compressionlevel") && document["compressionlevel"].IsInt()) {
compressionlevel = document["compressionlevel"].GetInt();
}
if (document.HasMember("height") && document["height"].IsInt()) {
height = document["height"].GetInt();
}
if (document.HasMember("infinite") && document["infinite"].IsBool()) {
infinite = (document["infinite"].GetBool() ? "true" : "false");
}
// Step 5: Accessing the layers and data
if (document.HasMember("layers") && document["layers"].IsArray()) {
const Value& layersDoc = document["layers"];
std::cout << "Number of layers: " << layersDoc.Size() << std::endl;
layers.reserve(layersDoc.Size());
for (SizeType i = 0; i < layersDoc.Size(); i++) {
if (layersDoc[i].HasMember("data") && layersDoc[i]["data"].IsArray()) {
const Value& data = layersDoc[i]["data"];
std::vector<int> layer_in;
layer_in.reserve(data.Size());
for (SizeType j = 0; j < data.Size(); j++) {
layer_in.emplace_back(data[j].GetInt());
}
layers.emplace_back(layer_in);
}
}
}
std::cout << "size:" << layers[0].size() << std::endl;
return 0;
}
To enable sprite batching we need to do the same as the previous tutorial about Sprite batching:
static SDL_GPUGraphicsPipeline* RenderPipeline;
static SDL_GPUSampler* Sampler;
static SDL_GPUTexture* Texture;
static SDL_GPUTransferBuffer* SpriteDataTransferBuffer;
static SDL_GPUBuffer* SpriteDataBuffer;
static const Uint32 SPRITE_COUNT = 10576;
static const char* BasePath = NULL;
typedef struct SpriteInstance
{
float x, y, z;
float rotation;
float w, h, padding_a, padding_b;
float tex_u, tex_v, tex_w, tex_h;
float r, g, b, a;
} SpriteInstance;
typedef struct Matrix4x4
{
float m11, m12, m13, m14;
float m21, m22, m23, m24;
float m31, m32, m33, m34;
float m41, m42, m43, m44;
} Matrix4x4;
Matrix4x4 Matrix4x4_CreateOrthographicOffCenter(
float left,
float right,
float bottom,
float top,
float zNearPlane,
float zFarPlane
) {
return (Matrix4x4) {
2.0f / (right - left), 0, 0, 0,
0, 2.0f / (top - bottom), 0, 0,
0, 0, 1.0f / (zNearPlane - zFarPlane), 0,
(left + right) / (left - right), (top + bottom) / (bottom - top), zNearPlane / (zNearPlane - zFarPlane), 1
};
}
For loading the shader:
SDL_GPUShader* LoadShader(
SDL_GPUDevice* device,
const char* shaderFilename,
Uint32 samplerCount,
Uint32 uniformBufferCount,
Uint32 storageBufferCount,
Uint32 storageTextureCount
) {
// Auto-detect the shader stage from the file name for convenience
SDL_GPUShaderStage stage;
if (SDL_strstr(shaderFilename, ".vert"))
{
stage = SDL_GPU_SHADERSTAGE_VERTEX;
}
else if (SDL_strstr(shaderFilename, ".frag"))
{
stage = SDL_GPU_SHADERSTAGE_FRAGMENT;
}
else
{
SDL_Log("Invalid shader stage!");
return NULL;
}
char fullPath[256];
SDL_GPUShaderFormat backendFormats = SDL_GetGPUShaderFormats(device);
SDL_GPUShaderFormat format = SDL_GPU_SHADERFORMAT_INVALID;
const char *entrypoint;
if (backendFormats & SDL_GPU_SHADERFORMAT_SPIRV) {
SDL_snprintf(fullPath, sizeof(fullPath), "%s/%s.spv", BasePath, shaderFilename);
format = SDL_GPU_SHADERFORMAT_SPIRV;
entrypoint = "main";
} else if (backendFormats & SDL_GPU_SHADERFORMAT_MSL) {
SDL_snprintf(fullPath, sizeof(fullPath), "%s/%s.msl", BasePath, shaderFilename);
format = SDL_GPU_SHADERFORMAT_MSL;
entrypoint = "main0";
} else if (backendFormats & SDL_GPU_SHADERFORMAT_DXIL) {
SDL_snprintf(fullPath, sizeof(fullPath), "%s/%s.dxil", BasePath, shaderFilename);
format = SDL_GPU_SHADERFORMAT_DXIL;
entrypoint = "main";
} else {
SDL_Log("%s", "Unrecognized backend shader format!");
return NULL;
}
size_t codeSize;
void* code = SDL_LoadFile(fullPath, &codeSize);
if (code == NULL)
{
SDL_Log("Failed to load shader from disk! %s", fullPath);
return NULL;
}
SDL_GPUShaderCreateInfo shaderInfo = {
.code = (Uint8*)code,
.code_size = codeSize,
.entrypoint = entrypoint,
.format = format,
.stage = stage,
.num_samplers = samplerCount,
.num_uniform_buffers = uniformBufferCount,
.num_storage_buffers = storageBufferCount,
.num_storage_textures = storageTextureCount
};
SDL_GPUShader* shader = SDL_CreateGPUShader(device, &shaderInfo);
if (shader == NULL)
{
SDL_Log("Failed to create shader!");
SDL_free(code);
return NULL;
}
SDL_free(code);
return shader;
}
For loading the image:
SDL_Surface* LoadImage(const char* imageFilename, int desiredChannels)
{
char fullPath[256];
SDL_Surface *result;
SDL_PixelFormat format;
SDL_snprintf(fullPath, sizeof(fullPath), "%s/%s", BasePath, imageFilename);
result = IMG_Load(fullPath);
if (result == NULL)
{
SDL_Log("Failed to load BMP: %s", SDL_GetError());
return NULL;
}
if (desiredChannels == 4)
{
format = SDL_PIXELFORMAT_ABGR8888;
}
else
{
SDL_assert(!"Unexpected desiredChannels");
SDL_DestroySurface(result);
return NULL;
}
if (result->format != format)
{
SDL_Surface *next = SDL_ConvertSurface(result, format);
SDL_DestroySurface(result);
result = next;
}
return result;
}
We need to call everything we have created so far:
#define WIDTH 800
#define HEIGHT 600
int main(int argc, char* argv[])
{
SDL_Init(SDL_INIT_VIDEO);
BasePath = SDL_GetBasePath();
// Create SDL Window
SDL_Window* window = SDL_CreateWindow("SDL3 Tilemap Game", WIDTH, HEIGHT, SDL_WINDOW_VULKAN | SDL_WINDOW_RESIZABLE);
if (!window) {
std::cerr << "Failed to create window: " << SDL_GetError() << std::endl;
return -1;
}
// Create Renderer
SDL_Renderer* renderer = SDL_CreateRenderer(window, NULL);
if (!renderer) {
std::cerr << "Failed to create renderer: " << SDL_GetError() << std::endl;
SDL_DestroyWindow(window);
return -1;
}
SDL_GPUDevice* device = SDL_CreateGPUDevice(
SDL_GPU_SHADERFORMAT_SPIRV | SDL_GPU_SHADERFORMAT_DXIL | SDL_GPU_SHADERFORMAT_MSL,
false,
NULL);
if (!device)
{
printf("SDL_CreateGPUDevice failed: %s\n", SDL_GetError());
SDL_DestroyWindow(window);
SDL_Quit();
return -1;
}
if (!SDL_ClaimWindowForGPUDevice(device, window))
{
SDL_Log("GPUClaimWindow failed");
return -1;
}
SDL_GPUPresentMode presentMode = SDL_GPU_PRESENTMODE_VSYNC;
if (SDL_WindowSupportsGPUPresentMode(
device,
window,
SDL_GPU_PRESENTMODE_IMMEDIATE
)) {
presentMode = SDL_GPU_PRESENTMODE_IMMEDIATE;
}
else if (SDL_WindowSupportsGPUPresentMode(
device,
window,
SDL_GPU_PRESENTMODE_MAILBOX
)) {
presentMode = SDL_GPU_PRESENTMODE_MAILBOX;
}
SDL_SetGPUSwapchainParameters(
device,
window,
SDL_GPU_SWAPCHAINCOMPOSITION_SDR,
presentMode
);
// Create the shaders
SDL_GPUShader* vertShader = LoadShader(
device,
"../assets/PullSpriteBatch.vert",
0,
1,
1,
0
);
SDL_GPUShader* fragShader = LoadShader(
device,
"../assets/TexturedQuadColor.frag",
1,
0,
0,
0
);
SDL_GPUColorTargetDescription target_desc[] {{
.format = SDL_GetGPUSwapchainTextureFormat(device, window),
.blend_state = {
.enable_blend = true,
.color_blend_op = SDL_GPU_BLENDOP_ADD,
.alpha_blend_op = SDL_GPU_BLENDOP_ADD,
.src_color_blendfactor = SDL_GPU_BLENDFACTOR_SRC_ALPHA,
.dst_color_blendfactor = SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA,
.src_alpha_blendfactor = SDL_GPU_BLENDFACTOR_SRC_ALPHA,
.dst_alpha_blendfactor = SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA,
}
}};
SDL_GPUGraphicsPipelineTargetInfo pipeline_target_info{
.num_color_targets = 1,
.color_target_descriptions = target_desc
};
SDL_GPUGraphicsPipelineCreateInfo pipeline_info{
.target_info = pipeline_target_info,
.primitive_type = SDL_GPU_PRIMITIVETYPE_TRIANGLELIST,
.vertex_shader = vertShader,
.fragment_shader = fragShader
};
// Create the sprite render pipeline
RenderPipeline = SDL_CreateGPUGraphicsPipeline(
device,
&pipeline_info
);
SDL_ReleaseGPUShader(device, vertShader);
SDL_ReleaseGPUShader(device, fragShader);
Tileset tileset;
tileset.parseTileset("../assets/alienor.json");
TileMap tilemap;
tilemap.parseTilemap("../assets/map1_test1.json");
// Load the image data
SDL_Surface *imageData = LoadImage("../assets/alienor.png", 4);
if (imageData == NULL)
{
SDL_Log("Could not load image data!");
return -1;
}
SDL_GPUTransferBufferCreateInfo buffer_info {
.usage = SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD,
.size = static_cast<Uint32>(imageData->w * imageData->h * 4)
};
SDL_GPUTransferBuffer* textureTransferBuffer = SDL_CreateGPUTransferBuffer(
device,
&buffer_info
);
Uint8 *textureTransferPtr = (Uint8*) SDL_MapGPUTransferBuffer(
device,
textureTransferBuffer,
false
);
SDL_memcpy(textureTransferPtr, imageData->pixels, imageData->w * imageData->h * 4);
SDL_UnmapGPUTransferBuffer(device, textureTransferBuffer);
SDL_GPUTextureCreateInfo texture_info{
.type = SDL_GPU_TEXTURETYPE_2D,
.format = SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM,
.width = static_cast<Uint32>(imageData->w),
.height = static_cast<Uint32>(imageData->h),
.layer_count_or_depth = 1,
.num_levels = 1,
.usage = SDL_GPU_TEXTUREUSAGE_SAMPLER
};
// Create the GPU resources
Texture = SDL_CreateGPUTexture(
device,
&texture_info
);
SDL_GPUSamplerCreateInfo sampler_info {
.min_filter = SDL_GPU_FILTER_NEAREST,
.mag_filter = SDL_GPU_FILTER_NEAREST,
.mipmap_mode = SDL_GPU_SAMPLERMIPMAPMODE_NEAREST,
.address_mode_u = SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE,
.address_mode_v = SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE,
.address_mode_w = SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE
};
Sampler = SDL_CreateGPUSampler(
device,
&sampler_info
);
SDL_GPUTransferBufferCreateInfo transfer_buffer_info {
.usage = SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD,
.size = SPRITE_COUNT * sizeof(SpriteInstance)
};
SpriteDataTransferBuffer = SDL_CreateGPUTransferBuffer(
device,
&transfer_buffer_info
);
SDL_GPUBufferCreateInfo buffer_info_read {
.usage = SDL_GPU_BUFFERUSAGE_GRAPHICS_STORAGE_READ,
.size = SPRITE_COUNT * sizeof(SpriteInstance)
};
SpriteDataBuffer = SDL_CreateGPUBuffer(
device,
&buffer_info_read
);
// Transfer the up-front data
SDL_GPUCommandBuffer* uploadCmdBuf = SDL_AcquireGPUCommandBuffer(device);
SDL_GPUCopyPass* copyPass = SDL_BeginGPUCopyPass(uploadCmdBuf);
SDL_GPUTextureTransferInfo transfer_info {
.transfer_buffer = textureTransferBuffer,
.offset = 0, /* Zeroes out the rest */
};
SDL_GPUTextureRegion texture_region {
.texture = Texture,
.w = static_cast<Uint32>(imageData->w),
.h = static_cast<Uint32>(imageData->h),
.d = 1
};
SDL_UploadToGPUTexture(
copyPass,
&transfer_info,
&texture_region,
false
);
SDL_GPUTextureSamplerBinding sampler_binding {
.texture = Texture,
.sampler = Sampler
};
SDL_EndGPUCopyPass(copyPass);
SDL_SubmitGPUCommandBuffer(uploadCmdBuf);
SDL_DestroySurface(imageData);
SDL_ReleaseGPUTransferBuffer(device, textureTransferBuffer);
bool running = true;
SDL_Event event;
static float uCoords[4] = { 0.0f, 0.5f, 0.0f, 0.5f };
static float vCoords[4] = { 0.0f, 0.0f, 0.5f, 0.5f };
float factorX = 1;
float factorY = 1;
while (running)
{
// Handle events
while (SDL_PollEvent(&event))
{
if (event.type == SDL_EVENT_QUIT)
{
running = false;
}
if(event.type == SDL_EVENT_KEY_DOWN) {
if(event.key.key == SDLK_A) {
factorX=factorX-1.f;
}
if(event.key.key == SDLK_Z) {
factorX=factorX+1.f;
}
if(event.key.key == SDLK_Q) {
factorY=factorY-1.f;
}
if(event.key.key == SDLK_S) {
factorY=factorY+1.f;
}
}
}
render(device, tilemap, tileset, window);
}
// Cleanup
SDL_ReleaseGPUGraphicsPipeline(device, RenderPipeline);
SDL_ReleaseGPUSampler(device, Sampler);
SDL_ReleaseGPUTexture(device, Texture);
SDL_ReleaseGPUTransferBuffer(device, SpriteDataTransferBuffer);
SDL_ReleaseGPUBuffer(device, SpriteDataBuffer);
return 0;
}
The main look like the previous example except for the loading of the tileset and tilemap and the render function doing the mapping between the tilemap and the buffer for the sprite batching.
Here is the render function:
static int render(SDL_GPUDevice *device, TileMap &tilemap, Tileset &tileset, SDL_Window *window) {
Matrix4x4 cameraMatrix = Matrix4x4_CreateOrthographicOffCenter(
0,
640,
480,
0,
0,
-1
);
SDL_GPUCommandBuffer* cmdBuf = SDL_AcquireGPUCommandBuffer(device);
if (cmdBuf == NULL)
{
SDL_Log("AcquireGPUCommandBuffer failed: %s", SDL_GetError());
return -1;
}
SDL_GPUTexture* swapchainTexture;
if (!SDL_WaitAndAcquireGPUSwapchainTexture(cmdBuf, window, &swapchainTexture, NULL, NULL)) {
SDL_Log("WaitAndAcquireGPUSwapchainTexture failed: %s", SDL_GetError());
return -1;
}
if (swapchainTexture != NULL)
{
// Build sprite instance transfer
SpriteInstance* dataPtr = (SpriteInstance*)SDL_MapGPUTransferBuffer(
device,
SpriteDataTransferBuffer,
true
);
int numCols = 100;
int numRows = 100;
for (int j = 0; j < numRows; j++) { // Iterate over rows first
for (int i = 0; i < numCols; i++) { // Then iterate over columns
int index = j * numCols + i; // Correct row-major indexing
int n = tilemap.layers[0][index]; // Tile index in tileset
// Position in world space
dataPtr[index].x = i * tileset.tileWidth;
dataPtr[index].y = j * tileset.tileHeight;
dataPtr[index].z = 0;
dataPtr[index].rotation = 0;
// Tile dimensions
dataPtr[index].w = tileset.tileWidth;
dataPtr[index].h = tileset.tileHeight;
// Get tile rect from tileset
float tex_x = tileset.tiles[n-1].rect.x;
float tex_y = tileset.tiles[n-1].rect.y;
float tex_w = tileset.tileWidth;
float tex_h = tileset.tileHeight;
float tex_w_max = tex_w;
float tex_h_max = tex_h;
// Convert to normalized UV coordinates
dataPtr[index].tex_u = (tex_x / tileset.imageWidth);
dataPtr[index].tex_v = (tex_y / tileset.imageHeight);
dataPtr[index].tex_w = (tex_w_max / tileset.imageWidth);
dataPtr[index].tex_h = (tex_h_max / tileset.imageHeight);
// Default colors (white)
dataPtr[index].r = 1.0f;
dataPtr[index].g = 1.0f;
dataPtr[index].b = 1.0f;
dataPtr[index].a = 1.0f;
}
}
SDL_UnmapGPUTransferBuffer(device, SpriteDataTransferBuffer);
SDL_GPUTransferBufferLocation buffer_location {
.transfer_buffer = SpriteDataTransferBuffer,
.offset = 0
};
SDL_GPUBufferRegion buffer_region {
.buffer = SpriteDataBuffer,
.offset = 0,
.size = SPRITE_COUNT * sizeof(SpriteInstance)
};
// Upload instance data
SDL_GPUCopyPass* copyPass = SDL_BeginGPUCopyPass(cmdBuf);
SDL_UploadToGPUBuffer(
copyPass,
&buffer_location,
&buffer_region,
true
);
SDL_EndGPUCopyPass(copyPass);
SDL_GPUColorTargetInfo target_info{
.texture = swapchainTexture,
.cycle = false,
.load_op = SDL_GPU_LOADOP_CLEAR,
.store_op = SDL_GPU_STOREOP_STORE,
.clear_color = { 0, 0, 0, 1 }
};
// Render sprites
SDL_GPURenderPass* renderPass = SDL_BeginGPURenderPass(
cmdBuf,
&target_info,
1,
NULL
);
SDL_BindGPUGraphicsPipeline(renderPass, RenderPipeline);
SDL_BindGPUVertexStorageBuffers(
renderPass,
0,
&SpriteDataBuffer,
1
);
SDL_GPUTextureSamplerBinding sampler_binding{
.texture = Texture,
.sampler = Sampler
};
SDL_BindGPUFragmentSamplers(
renderPass,
0,
&sampler_binding,
1
);
SDL_PushGPUVertexUniformData(
cmdBuf,
0,
&cameraMatrix,
sizeof(Matrix4x4)
);
SDL_DrawGPUPrimitives(
renderPass,
SPRITE_COUNT * 6,
1,
0,
0
);
SDL_EndGPURenderPass(renderPass);
}
SDL_SubmitGPUCommandBuffer(cmdBuf);
// Present the frame
SDL_GL_SwapWindow(window);
}
You can download the full project:
For windows you can download this file SDL3_Tilemap_win.7z: