Initial pipeline work

Migrates the toad example from SDL’s 2D Renderer API to SDL_GPU, keeping the same Character_Data / Sprite engine API and baked asset pipeline.

- Replaces CreateWindowAndRenderer / RenderTexture with GPU device + swapchain framing (begin_frame / end_frame)
- Adds sprite vertex/fragment GLSL shaders and compiled SPIR-V (shaders/sprite.*.glsl / .spv)
- Uploads atlas PNGs as GPUTexture via transfer buffer + copy pass (with fence sync)
- Draws sprites as UV quads (DrawGPUPrimitives) using atlas frame rects from char.json
This commit is contained in:
2026-07-31 19:17:47 -07:00
committed by GitHub
8 changed files with 475 additions and 54 deletions
+240 -13
View File
@@ -3,9 +3,29 @@ package engine
import "core:fmt"
import sdl "vendor:sdl3"
Vertex :: struct {
pos: [2]f32, // clip space, -1..1
uv: [2]f32, // 0..1 atlas coords (used later in draw_sprite)
}
SPRITE_VERT_COUNT :: 6
VERTEX_BUFFER_SIZE :: SPRITE_VERT_COUNT * size_of(Vertex)
VERT_SPV := #load("../shaders/sprite.vert.spv")
FRAG_SPV := #load("../shaders/sprite.frag.spv")
App :: struct {
window: ^sdl.Window,
renderer: ^sdl.Renderer,
window: ^sdl.Window,
device: ^sdl.GPUDevice,
pipeline: ^sdl.GPUGraphicsPipeline,
sampler: ^sdl.GPUSampler,
cmd: ^sdl.GPUCommandBuffer,
render_pass: ^sdl.GPURenderPass,
swapchain_texture: ^sdl.GPUTexture,
swapchain_w: u32,
swapchain_h: u32,
vertex_buffer: ^sdl.GPUBuffer,
transfer_buffer: ^sdl.GPUTransferBuffer,
}
init :: proc(app: ^App, title: cstring, width, height: i32) -> bool {
@@ -14,8 +34,64 @@ init :: proc(app: ^App, title: cstring, width, height: i32) -> bool {
return false
}
if !sdl.CreateWindowAndRenderer(title, width, height, {}, &app.window, &app.renderer) {
fmt.eprintfln("CreateWindowAndRenderer failed: %s", sdl.GetError())
app.window = sdl.CreateWindow(title, width, height, {})
if app.window == nil {
fmt.eprintfln("CreateWindow failed: %s", sdl.GetError())
return false
}
app.device = sdl.CreateGPUDevice({.SPIRV}, true, nil)
if app.device == nil {
fmt.eprintfln("CreateGPUDevice failed: %s", sdl.GetError())
return false
}
if !sdl.ClaimWindowForGPUDevice(app.device, app.window) {
fmt.eprintfln("ClaimWindowForGPUDevice failed: %s", sdl.GetError())
return false
}
app.pipeline = create_sprite_pipeline(app.device, app.window)
if app.pipeline == nil {
fmt.eprintfln("create_sprite_pipeline failed: %s", sdl.GetError())
return false
}
app.sampler = sdl.CreateGPUSampler(
app.device,
{
min_filter = .NEAREST,
mag_filter = .NEAREST,
mipmap_mode = .NEAREST,
address_mode_u = .CLAMP_TO_EDGE,
address_mode_v = .CLAMP_TO_EDGE,
address_mode_w = .CLAMP_TO_EDGE,
},
)
if app.sampler == nil {
fmt.eprintfln("CreateGPUSampler failed: %s", sdl.GetError())
return false
}
app.vertex_buffer = sdl.CreateGPUBuffer(
app.device,
{usage = {.VERTEX}, size = VERTEX_BUFFER_SIZE},
)
if app.vertex_buffer == nil {
fmt.eprintfln("CreateGPUBuffer failed: %s", sdl.GetError())
return false
}
app.transfer_buffer = sdl.CreateGPUTransferBuffer(
app.device,
{usage = .UPLOAD, size = VERTEX_BUFFER_SIZE},
)
if app.transfer_buffer == nil {
fmt.eprintfln("CreateGPUTransferBuffer failed: %s", sdl.GetError())
return false
}
@@ -23,14 +99,43 @@ init :: proc(app: ^App, title: cstring, width, height: i32) -> bool {
}
shutdown :: proc(app: ^App) {
if app.renderer != nil do sdl.DestroyRenderer(app.renderer)
if app.window != nil do sdl.DestroyWindow(app.window)
if app.device != nil {
ok := sdl.WaitForGPUIdle(app.device)
if !ok {
fmt.eprintfln("WaitForGPUIdle failed")
}
if app.transfer_buffer != nil {
sdl.ReleaseGPUTransferBuffer(app.device, app.transfer_buffer)
}
if app.vertex_buffer != nil {
sdl.ReleaseGPUBuffer(app.device, app.vertex_buffer)
}
if app.sampler != nil {
sdl.ReleaseGPUSampler(app.device, app.sampler)
}
if app.pipeline != nil {
sdl.ReleaseGPUGraphicsPipeline(app.device, app.pipeline)
}
if app.window != nil {
sdl.ReleaseWindowFromGPUDevice(app.device, app.window)
}
sdl.DestroyGPUDevice(app.device)
}
if app.window != nil {
sdl.DestroyWindow(app.window)
}
sdl.Quit()
app^ = {}
}
pump_events :: proc() -> bool {
events :: proc() -> bool {
event: sdl.Event
for sdl.PollEvent(&event) {
@@ -42,15 +147,137 @@ pump_events :: proc() -> bool {
return true
}
begin_frame :: proc(app: ^App, r: u8 = 30, g: u8 = 30, b: u8 = 40, a: u8 = 255) {
sdl.SetRenderDrawColor(app.renderer, r, g, b, a)
sdl.RenderClear(app.renderer)
begin_frame :: proc(app: ^App, clear: sdl.FColor = {0.12, 0.12, 0.16, 1}) {
app.cmd = sdl.AcquireGPUCommandBuffer(app.device)
if app.cmd == nil {
fmt.eprintfln("AcquireGPUCommandBuffer failed: %s", sdl.GetError())
return
}
ok := sdl.WaitAndAcquireGPUSwapchainTexture(
app.cmd,
app.window,
&app.swapchain_texture,
&app.swapchain_w,
&app.swapchain_h,
)
if !ok || app.swapchain_texture == nil {
return
}
color_info := sdl.GPUColorTargetInfo {
texture = app.swapchain_texture,
clear_color = clear,
load_op = .CLEAR,
store_op = .STORE,
}
app.render_pass = sdl.BeginGPURenderPass(app.cmd, &color_info, 1, nil)
sdl.BindGPUGraphicsPipeline(app.render_pass, app.pipeline)
}
end_frame :: proc(app: ^App) {
sdl.RenderPresent(app.renderer)
if app.render_pass != nil {
sdl.EndGPURenderPass(app.render_pass)
app.render_pass = nil
}
if app.cmd != nil {
ok := sdl.SubmitGPUCommandBuffer(app.cmd)
if !ok {
fmt.eprintfln("SubmitGPUCommandBuffer failed")
}
app.cmd = nil
}
app.swapchain_texture = nil
}
renderer :: proc(app: ^App) -> ^sdl.Renderer {
return app.renderer
load_spirv_shader :: proc(
device: ^sdl.GPUDevice,
code: []u8,
stage: sdl.GPUShaderStage,
num_samplers: u32,
) -> ^sdl.GPUShader {
return sdl.CreateGPUShader(
device,
{
code_size = len(code),
code = raw_data(code),
entrypoint = "main",
format = {.SPIRV},
stage = stage,
num_samplers = num_samplers,
num_storage_textures = 0,
num_storage_buffers = 0,
num_uniform_buffers = 0,
},
)
}
create_sprite_pipeline :: proc(
device: ^sdl.GPUDevice,
window: ^sdl.Window,
) -> ^sdl.GPUGraphicsPipeline {
vert := load_spirv_shader(device, VERT_SPV[:], .VERTEX, 0)
if vert == nil do return nil
frag := load_spirv_shader(device, FRAG_SPV[:], .FRAGMENT, 1)
if frag == nil {
sdl.ReleaseGPUShader(device, vert)
return nil
}
swap_format := sdl.GetGPUSwapchainTextureFormat(device, window)
blend := sdl.GPUColorTargetBlendState {
src_color_blendfactor = .SRC_ALPHA,
dst_color_blendfactor = .ONE_MINUS_SRC_ALPHA,
color_blend_op = .ADD,
src_alpha_blendfactor = .ONE,
dst_alpha_blendfactor = .ONE_MINUS_SRC_ALPHA,
alpha_blend_op = .ADD,
enable_blend = true,
}
color_target := sdl.GPUColorTargetDescription {
format = swap_format,
blend_state = blend,
}
vb_desc := sdl.GPUVertexBufferDescription {
slot = 0,
pitch = u32(size_of(Vertex)),
input_rate = .VERTEX,
}
attrs := [2]sdl.GPUVertexAttribute {
{location = 0, buffer_slot = 0, format = .FLOAT2, offset = 0},
{location = 1, buffer_slot = 0, format = .FLOAT2, offset = u32(offset_of(Vertex, uv))},
}
pipeline_info := sdl.GPUGraphicsPipelineCreateInfo {
vertex_shader = vert,
fragment_shader = frag,
vertex_input_state = {
vertex_buffer_descriptions = &vb_desc,
num_vertex_buffers = 1,
vertex_attributes = raw_data(attrs[:]),
num_vertex_attributes = 2,
},
primitive_type = .TRIANGLELIST,
rasterizer_state = {fill_mode = .FILL},
target_info = {color_target_descriptions = &color_target, num_color_targets = 1},
}
pipeline := sdl.CreateGPUGraphicsPipeline(device, pipeline_info)
sdl.ReleaseGPUShader(device, vert)
sdl.ReleaseGPUShader(device, frag)
return pipeline
}
+112 -13
View File
@@ -2,6 +2,7 @@ package engine
import "core:encoding/json"
import "core:fmt"
import "core:mem"
import "core:os"
import "core:path/filepath"
import "core:strings"
@@ -30,16 +31,12 @@ Char_Def :: struct {
Character_Data :: struct {
def: Char_Def,
texture: ^sdl.Texture,
texture: ^sdl.GPUTexture,
width: int,
height: int,
}
load_character_data :: proc(
renderer: ^sdl.Renderer,
character_json_path: string,
) -> (
Character_Data,
bool,
) {
load_character_data :: proc(app: ^App, character_json_path: string) -> (Character_Data, bool) {
file_data, read_err := os.read_entire_file(character_json_path, context.allocator)
if read_err != nil {
fmt.eprintfln("failed to read %s: %v", character_json_path, read_err)
@@ -75,18 +72,120 @@ load_character_data :: proc(
}
defer sdl.DestroySurface(surface)
out.texture = sdl.CreateTextureFromSurface(renderer, surface)
rgba_surface := surface
converted_surface: ^sdl.Surface
defer {
if converted_surface != nil do sdl.DestroySurface(converted_surface)
}
if surface.format != .ABGR8888 {
converted_surface = sdl.ConvertSurface(surface, .ABGR8888)
if converted_surface == nil {
fmt.eprintfln("ConvertSurface(ABGR8888) failed: %s", sdl.GetError())
return {}, false
}
rgba_surface = converted_surface
}
out.width = int(rgba_surface.w)
out.height = int(rgba_surface.h)
out.texture = sdl.CreateGPUTexture(
app.device,
{
type = .D2,
format = .R8G8B8A8_UNORM,
usage = {.SAMPLER},
width = u32(out.width),
height = u32(out.height),
layer_count_or_depth = 1,
num_levels = 1,
sample_count = ._1,
},
)
if out.texture == nil {
fmt.eprintfln("CreateTextureFromSurface failed: %s", sdl.GetError())
fmt.eprintfln("CreateGPUTexture failed: %s", sdl.GetError())
return {}, false
}
sdl.SetTextureScaleMode(out.texture, .NEAREST)
upload_size := int(rgba_surface.pitch) * int(rgba_surface.h)
tbuf := sdl.CreateGPUTransferBuffer(app.device, {usage = .UPLOAD, size = u32(upload_size)})
if tbuf == nil {
fmt.eprintfln("CreateGPUTransferBuffer failed: %s", sdl.GetError())
sdl.ReleaseGPUTexture(app.device, out.texture)
return {}, false
}
map_ptr := sdl.MapGPUTransferBuffer(app.device, tbuf, false)
if map_ptr == nil {
fmt.eprintfln("MapGPUTransferBuffer failed: %s", sdl.GetError())
sdl.ReleaseGPUTransferBuffer(app.device, tbuf)
sdl.ReleaseGPUTexture(app.device, out.texture)
return {}, false
}
mem.copy(map_ptr, rgba_surface.pixels, upload_size)
sdl.UnmapGPUTransferBuffer(app.device, tbuf)
cmd := sdl.AcquireGPUCommandBuffer(app.device)
if cmd == nil {
fmt.eprintfln("AcquireGPUCommandBuffer failed: %s", sdl.GetError())
sdl.ReleaseGPUTransferBuffer(app.device, tbuf)
sdl.ReleaseGPUTexture(app.device, out.texture)
return {}, false
}
copy_pass := sdl.BeginGPUCopyPass(cmd)
transfer := sdl.GPUTextureTransferInfo {
transfer_buffer = tbuf,
offset = 0,
pixels_per_row = u32(rgba_surface.pitch / 4), // RGBA8888 => 4 bytes/pixel
rows_per_layer = u32(rgba_surface.h),
}
region := sdl.GPUTextureRegion {
texture = out.texture,
mip_level = 0,
layer = 0,
x = 0,
y = 0,
z = 0,
w = u32(rgba_surface.w),
h = u32(rgba_surface.h),
d = 1,
}
sdl.UploadToGPUTexture(copy_pass, transfer, region, false)
sdl.EndGPUCopyPass(copy_pass)
fence := sdl.SubmitGPUCommandBufferAndAcquireFence(cmd)
if fence == nil {
fmt.eprintfln("SubmitGPUCommandBufferAndAcquireFence (texture upload) failed: %s", sdl.GetError())
sdl.ReleaseGPUTransferBuffer(app.device, tbuf)
sdl.ReleaseGPUTexture(app.device, out.texture)
return {}, false
}
defer sdl.ReleaseGPUFence(app.device, fence)
if !sdl.WaitForGPUFences(app.device, true, &fence, 1) {
fmt.eprintfln("WaitForGPUFences (texture upload) failed: %s", sdl.GetError())
sdl.ReleaseGPUTransferBuffer(app.device, tbuf)
sdl.ReleaseGPUTexture(app.device, out.texture)
return {}, false
}
sdl.ReleaseGPUTransferBuffer(app.device, tbuf)
return out, true
}
destroy_character_data :: proc(data: ^Character_Data) {
destroy_character_data :: proc(app: ^App, data: ^Character_Data) {
if data.texture != nil {
sdl.DestroyTexture(data.texture)
// Ensure no in-flight draw command is still sampling this texture.
if app != nil && app.device != nil {
_ = sdl.WaitForGPUIdle(app.device)
}
sdl.ReleaseGPUTexture(app.device, data.texture)
}
data^ = {}
}
+95 -15
View File
@@ -1,5 +1,6 @@
package engine
import "core:mem"
import sdl "vendor:sdl3"
Vec2 :: [2]f32
@@ -26,26 +27,105 @@ update_sprite :: proc(sprite: ^Sprite, dt: f32) {
_ = dt
}
draw_sprite :: proc(renderer: ^sdl.Renderer, sprite: ^Sprite) {
assert(sprite.data != nil)
to_clip :: proc(px, py, sw, sh: f32) -> [2]f32 {
return {
px / sw * 2 - 1,
1 - py / sh * 2, // flip y (window pixels are y-down)
}
}
draw_sprite :: proc(app: ^App, sprite: ^Sprite) {
if app.render_pass == nil || app.cmd == nil || app.swapchain_texture == nil {
return // begin_frame may have skipped (minimized window, etc.)
}
if sprite == nil || sprite.data == nil || sprite.data.texture == nil {
return
}
rect, ok := character_frame_rect(sprite.data, sprite.clip, sprite.frame)
if !ok {
return
}
w := f32(rect[2])
h := f32(rect[3])
dst := sdl.FRect {
x = sprite.position.x - w * 0.5,
y = sprite.position.y - h,
w = w,
h = h,
fw := f32(rect[2])
fh := f32(rect[3])
// Feet-centered placement, same as renderer version
x0_px := sprite.position.x - fw * 0.5
y0_px := sprite.position.y - fh
x1_px := x0_px + fw
y1_px := y0_px + fh
sw := f32(app.swapchain_w)
sh := f32(app.swapchain_h)
p0 := to_clip(x0_px, y0_px, sw, sh) // top-left
p1 := to_clip(x1_px, y0_px, sw, sh) // top-right
p2 := to_clip(x1_px, y1_px, sw, sh) // bottom-right
p3 := to_clip(x0_px, y1_px, sw, sh) // bottom-left
tex_w := f32(sprite.data.width)
tex_h := f32(sprite.data.height)
u0 := f32(rect[0]) / tex_w
v0 := f32(rect[1]) / tex_h
u1 := f32(rect[0] + rect[2]) / tex_w
v1 := f32(rect[1] + rect[3]) / tex_h
// Two triangles: (0,1,2) and (0,2,3)
verts := [6]Vertex {
{pos = p0, uv = {u0, v0}},
{pos = p1, uv = {u1, v0}},
{pos = p2, uv = {u1, v1}},
{pos = p0, uv = {u0, v0}},
{pos = p2, uv = {u1, v1}},
{pos = p3, uv = {u0, v1}},
}
src := sdl.FRect {
x = f32(rect[0]),
y = f32(rect[1]),
w = w,
h = h,
map_ptr := sdl.MapGPUTransferBuffer(app.device, app.transfer_buffer, false)
if map_ptr == nil {
return
}
sdl.RenderTexture(renderer, sprite.data.texture, &src, &dst)
mem.copy(map_ptr, raw_data(verts[:]), size_of(verts))
sdl.UnmapGPUTransferBuffer(app.device, app.transfer_buffer)
// IMPORTANT: SDL does not allow beginning a copy pass while a render pass
// is active on the same command buffer. Upload on a separate command buffer.
copy_cmd := sdl.AcquireGPUCommandBuffer(app.device)
if copy_cmd == nil {
return
}
copy_pass := sdl.BeginGPUCopyPass(copy_cmd)
src := sdl.GPUTransferBufferLocation {
transfer_buffer = app.transfer_buffer,
offset = 0,
}
dst := sdl.GPUBufferRegion {
buffer = app.vertex_buffer,
offset = 0,
size = u32(size_of(verts)),
}
sdl.UploadToGPUBuffer(copy_pass, src, dst, false)
sdl.EndGPUCopyPass(copy_pass)
fence := sdl.SubmitGPUCommandBufferAndAcquireFence(copy_cmd)
if fence == nil {
return
}
defer sdl.ReleaseGPUFence(app.device, fence)
if !sdl.WaitForGPUFences(app.device, true, &fence, 1) {
return
}
sampler_binding := sdl.GPUTextureSamplerBinding {
texture = sprite.data.texture,
sampler = app.sampler,
}
sdl.BindGPUFragmentSamplers(app.render_pass, 0, &sampler_binding, 1)
vb_binding := sdl.GPUBufferBinding {
buffer = app.vertex_buffer,
offset = 0,
}
sdl.BindGPUVertexBuffers(app.render_pass, 0, &vb_binding, 1)
sdl.DrawGPUPrimitives(app.render_pass, 6, 1, 0, 0)
}
+7 -13
View File
@@ -4,25 +4,19 @@ import eng "pkg:engine"
main :: proc() {
app: eng.App
if !eng.init(&app, "toad example", 800, 600) {
return
}
if !eng.init(&app, "toad", 800, 600) do return
defer eng.shutdown(&app)
data, ok := eng.load_character_data(
app.renderer,
"assets_baked/characters/toad/toad.char.json",
)
if !ok {
return
}
defer eng.destroy_character_data(&data)
data, ok := eng.load_character_data(&app, "assets_baked/characters/toad/toad.char.json")
if !ok do return
defer eng.destroy_character_data(&app, &data)
toad := eng.spawn_sprite(&data, {400, 500}, "idle", 0)
for eng.pump_events() {
for eng.events() {
eng.begin_frame(&app)
eng.draw_sprite(app.renderer, &toad)
eng.draw_sprite(&app, &toad)
eng.end_frame(&app)
}
}
+10
View File
@@ -0,0 +1,10 @@
#version 450
layout(set = 2, binding = 0) uniform sampler2D u_tex; // SDL_GPU fragment sampler slot 0
layout(location = 0) in vec2 v_uv;
layout(location = 0) out vec4 out_color;
void main() {
out_color = texture(u_tex, v_uv);
}
Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
#version 450
layout(location = 0) in vec2 in_pos;
layout(location = 1) in vec2 in_uv;
layout(location = 0) out vec2 v_uv;
void main() {
v_uv = in_uv;
gl_Position = vec4(in_pos, 0.0, 1.0);
}
Binary file not shown.