sdl3/gpu

Code

main.odin ¶
705 linesSource

1package sdl3_gpu_example
2
3import "core:encoding/json"
4import "core:fmt"
5import "core:math"
6import "core:math/linalg"
7import "core:mem"
8import "core:os"
9import "core:strings"
10import "core:time"
11import "vendor:sdl3"
12
13Mat4     :: matrix[4, 4]f32
14Vec2     :: [2]f32
15Vec4     :: [4]f32
16Quad     :: [4]Vec2
17Color    :: [4]f32
18
19WHITE    :: Color{1, 1, 1, 1}
20BG_COLOR :: Color{0.09, 0.09, 0.11, 1.0}
21
22Draw_Mode :: enum i32 {
23	MSDF     = 0,
24	Squircle = 1,
25	Texture  = 2,
26}
27
28// Vertex / uniform layouts (must match shaders/shader.vert & .frag)
29Vertex :: struct {
30	pos:   Vec2,
31	uv:    Vec2,
32	color: Color,
33}
34
35Vert_Uniforms :: struct {
36	mvp: Mat4,
37}
38
39Frag_Uniforms :: struct {
40	mode:            Draw_Mode,
41	screen_px_range: f32,
42	half_size:       Vec2,
43	corner_radius:   f32,
44	squircle_blend:  f32,
45	_pad0:           f32,
46	_pad1:           f32,
47}
48
49Draw_Call :: struct {
50	texture:     ^sdl3.GPUTexture,
51	frag:        Frag_Uniforms,
52	index_off:   u32,
53	index_count: u32,
54}
55
56Renderer :: struct {
57	device:          ^sdl3.GPUDevice,
58	window:          ^sdl3.Window,
59	pipeline:        ^sdl3.GPUGraphicsPipeline,
60	white_tex:       Texture,         // 1x1 white pixel, used for solid rects
61	nearest_sampler: ^sdl3.GPUSampler,
62	linear_sampler:  ^sdl3.GPUSampler,
63
64	// CPU-side batch, rebuilt every frame.
65	verts:           [dynamic]Vertex,
66	idxs:            [dynamic]u16,
67	calls:           [dynamic]Draw_Call,
68
69	// GPU-side buffers, grown (re-created) on demand.
70	vbo:             ^sdl3.GPUBuffer,
71	ibo:             ^sdl3.GPUBuffer,
72	vbo_cap:         int,
73	ibo_cap:         int,
74
75	w, h:            u32,
76}
77
78Texture :: struct {
79	gpu:  ^sdl3.GPUTexture,
80	size: Vec2,
81}
82
83Glyph :: struct {
84	advance:    f32,
85	has_bounds: bool,
86	plane:      Vec4, // em units, relative to baseline
87	uv:         Quad,   // uv coords
88}
89
90Font :: struct {
91	texture:          Texture,
92	atlas_w, atlas_h: f32,
93	distance_range:   f32,
94	atlas_size_px:    f32, // px-per-em the atlas was generated at
95	variants:         []Font_Variant,
96}
97
98Font_Variant :: struct {
99	glyphs:      map[rune]Glyph,
100	line_height: f32, // em units
101	ascender:    f32,
102	descender:   f32,
103}
104
105Bounds :: struct {
106	left, bottom, right, top: f32,
107}
108
109/*
110Font generated using:
111
112msdf-atlas-gen -font DejaVuSans.ttf ^
113	  -and -font DejaVuSans-Oblique.ttf ^
114	  -and -font DejaVuSans-Bold.ttf ^
115	  -type mtsdf -size 128 -pxrange 8 -coloringstrategy inktrap -errorcorrection auto-full ^
116	  -imageout font.png -json font.json || exit /b 1
117
118NOTE: Kerning pairs aren't yet loaded, nor handled during drawing.
119*/
120Atlas_JSON :: struct {
121	atlas: struct {
122		distance_range: f32 `json:"distanceRange"`,
123		size:           f32,
124		width:          f32,
125		height:         f32,
126	},
127	variants: []struct {
128		metrics: struct {
129			line_height: f32 `json:"lineHeight"`,
130			ascender:    f32,
131			descender:   f32,
132		},
133		glyphs: []struct {
134			unicode:     int,
135			advance:     f32,
136			plane_bounds: Bounds `json:"planeBounds"`,
137			atlas_bounds: Bounds `json:"atlasBounds"`,
138		},
139	},
140}
141
142destroy_renderer :: proc(r: ^Renderer) {
143	delete(r.verts)
144	delete(r.idxs)
145	delete(r.calls)
146}
147
148destroy_font :: proc(font: Font) {
149	for variant in font.variants {
150		delete(variant.glyphs)
151	}
152	delete(font.variants)
153}
154
155read_file_or_die :: proc(path: string, allocator := context.allocator) -> []u8 {
156	data, err := os.read_entire_file(path, allocator)
157	fmt.assertf(err == nil, "Failed to read file: %s", path)
158	return data
159}
160
161load_shader :: proc(r: ^Renderer, path: string, stage: sdl3.GPUShaderStage, num_samplers, num_uniform_buffers: u32) -> ^sdl3.GPUShader {
162	code := read_file_or_die(path)
163	defer delete(code)
164
165	info := sdl3.GPUShaderCreateInfo{
166		code_size            = len(code),
167		code                 = raw_data(code),
168		entrypoint           = "main",
169		format               = {.SPIRV},
170		stage                = stage,
171		num_samplers         = num_samplers,
172		num_storage_textures = 0,
173		num_storage_buffers  = 0,
174		num_uniform_buffers  = num_uniform_buffers,
175	}
176
177	shader := sdl3.CreateGPUShader(r.device, info)
178	fmt.assertf(shader != nil, "CreateGPUShader failed for %s: %s", path, sdl3.GetError())
179	return shader
180}
181
182renderer_init :: proc(window: ^sdl3.Window) -> Renderer {
183	r: Renderer
184	r.window = window
185
186	r.device = sdl3.CreateGPUDevice({.SPIRV}, true, nil)
187	fmt.assertf(r.device != nil, "CreateGPUDevice failed: %s", sdl3.GetError())
188	fmt.assertf(sdl3.ClaimWindowForGPUDevice(r.device, window), "ClaimWindowForGPUDevice failed: %s", sdl3.GetError())
189
190	// Shaders compiled using:
191	// glslc -fshader-stage=vertex   shader.vert -o shader.vert.spv
192	// glslc -fshader-stage=fragment shader.frag -o shader.frag.spv
193	vert_shader := load_shader(&r, "res/shader.vert.spv", .VERTEX,   0, 1)
194	frag_shader := load_shader(&r, "res/shader.frag.spv", .FRAGMENT, 1, 1)
195
196	vertex_attrs := []sdl3.GPUVertexAttribute{
197		{location = 0, buffer_slot = 0, format = .FLOAT2, offset = u32(offset_of(Vertex, pos))},
198		{location = 1, buffer_slot = 0, format = .FLOAT2, offset = u32(offset_of(Vertex, uv))},
199		{location = 2, buffer_slot = 0, format = .FLOAT4, offset = u32(offset_of(Vertex, color))},
200	}
201
202	vertex_buffers := []sdl3.GPUVertexBufferDescription{
203		{slot = 0, pitch = u32(size_of(Vertex)), input_rate = .VERTEX, instance_step_rate = 0},
204	}
205
206	color_target := sdl3.GPUColorTargetDescription{
207		format = sdl3.GetGPUSwapchainTextureFormat(r.device, window),
208		blend_state = sdl3.GPUColorTargetBlendState{
209			enable_blend            = true,
210			src_color_blendfactor   = .SRC_ALPHA,
211			dst_color_blendfactor   = .ONE_MINUS_SRC_ALPHA,
212			color_blend_op          = .ADD,
213			src_alpha_blendfactor   = .ONE,
214			dst_alpha_blendfactor   = .ONE_MINUS_SRC_ALPHA,
215			alpha_blend_op          = .ADD,
216		},
217	}
218
219	pipeline_info := sdl3.GPUGraphicsPipelineCreateInfo{
220		vertex_shader   = vert_shader,
221		fragment_shader = frag_shader,
222		vertex_input_state = sdl3.GPUVertexInputState{
223			vertex_buffer_descriptions = raw_data(vertex_buffers),
224			num_vertex_buffers         = u32(len(vertex_buffers)),
225			vertex_attributes          = raw_data(vertex_attrs),
226			num_vertex_attributes      = u32(len(vertex_attrs)),
227		},
228		primitive_type = .TRIANGLELIST,
229		rasterizer_state = sdl3.GPURasterizerState{
230			fill_mode = .FILL,
231			cull_mode = .NONE,
232		},
233		target_info = sdl3.GPUGraphicsPipelineTargetInfo{
234			color_target_descriptions = &color_target,
235			num_color_targets         = 1,
236		},
237	}
238
239	r.pipeline = sdl3.CreateGPUGraphicsPipeline(r.device, pipeline_info)
240	fmt.assertf(r.pipeline != nil, "CreateGPUGraphicsPipeline failed: %s", sdl3.GetError())
241
242	// Shaders are only needed to build the pipeline.
243	sdl3.ReleaseGPUShader(r.device, vert_shader)
244	sdl3.ReleaseGPUShader(r.device, frag_shader)
245
246	r.nearest_sampler = sdl3.CreateGPUSampler(r.device, sdl3.GPUSamplerCreateInfo{
247		min_filter = .NEAREST, mag_filter = .NEAREST, mipmap_mode = .NEAREST,
248		address_mode_u = .CLAMP_TO_EDGE, address_mode_v = .CLAMP_TO_EDGE, address_mode_w = .CLAMP_TO_EDGE,
249	})
250	r.linear_sampler = sdl3.CreateGPUSampler(r.device, sdl3.GPUSamplerCreateInfo{
251		min_filter = .LINEAR, mag_filter = .LINEAR, mipmap_mode = .LINEAR,
252		address_mode_u = .CLAMP_TO_EDGE, address_mode_v = .CLAMP_TO_EDGE, address_mode_w = .CLAMP_TO_EDGE,
253	})
254
255	white_pixel := [4]u8{255, 255, 255, 255}
256	r.white_tex = create_texture_from_pixels(&r, raw_data(white_pixel[:]), 1, 1)
257
258	return r
259}
260
261create_texture_from_pixels :: proc(r: ^Renderer, pixels: [^]u8, w, h: i32) -> (res: Texture) {
262	tex_info := sdl3.GPUTextureCreateInfo{
263		type                 = .D2,
264		format               = .R8G8B8A8_UNORM,
265		usage                = sdl3.GPUTextureUsageFlags{.SAMPLER},
266		width                = u32(w),
267		height               = u32(h),
268		layer_count_or_depth = 1,
269		num_levels           = 1,
270	}
271	tex := sdl3.CreateGPUTexture(r.device, tex_info)
272
273	byte_size := u32(w * h * 4)
274	transfer := sdl3.CreateGPUTransferBuffer(r.device, sdl3.GPUTransferBufferCreateInfo{
275		usage = .UPLOAD, size = byte_size,
276	})
277
278	mapped := ([^]u8)(sdl3.MapGPUTransferBuffer(r.device, transfer, false))
279	copy(mapped[:byte_size], pixels[:byte_size])
280	sdl3.UnmapGPUTransferBuffer(r.device, transfer)
281
282	cmd := sdl3.AcquireGPUCommandBuffer(r.device)
283	copy_pass := sdl3.BeginGPUCopyPass(cmd)
284
285	src := sdl3.GPUTextureTransferInfo{transfer_buffer = transfer, offset = 0}
286	dst := sdl3.GPUTextureRegion{texture = tex, w = u32(w), h = u32(h), d = 1}
287	sdl3.UploadToGPUTexture(copy_pass, src, dst, false)
288
289	sdl3.EndGPUCopyPass(copy_pass)
290	assert(sdl3.SubmitGPUCommandBuffer(cmd))
291
292	sdl3.ReleaseGPUTransferBuffer(r.device, transfer)
293	return Texture{gpu = tex, size = {f32(w), f32(h)}}
294}
295
296load_texture :: proc(r: ^Renderer, path: string) -> (res: Texture) {
297	cpath := strings.clone_to_cstring(path)
298	defer delete(cpath)
299
300	surface := sdl3.LoadPNG(cpath)
301	fmt.assertf(r.pipeline != nil, "failed to load image %s: %s", path, sdl3.GetError())
302	defer sdl3.DestroySurface(surface)
303
304	converted := sdl3.ConvertSurface(surface, .ABGR8888)
305	defer sdl3.DestroySurface(converted)
306
307	pixels := ([^]u8)(converted.pixels)
308	return create_texture_from_pixels(r, pixels, converted.w, converted.h)
309}
310
311destroy_texture :: proc(r: ^Renderer, tex: Texture) {
312	ensure(r != nil)
313	if tex.gpu != nil {
314		return
315	}
316	sdl3.ReleaseGPUTexture(r.device, tex.gpu)
317}
318
319renderer_begin_frame :: proc(r: ^Renderer) {
320	clear(&r.verts)
321	clear(&r.idxs)
322	clear(&r.calls)
323}
324
325push_call :: proc(r: ^Renderer, tex: ^sdl3.GPUTexture, frag: Frag_Uniforms, index_count: int) {
326	n := len(r.calls)
327	if n > 0 {
328		last := &r.calls[n - 1]
329		if last.texture == tex && last.frag == frag {
330			last.index_count += u32(index_count)
331			return
332		}
333	}
334	append(&r.calls, Draw_Call{
335		texture     = tex,
336		frag        = frag,
337		index_off   = u32(len(r.idxs) - index_count),
338		index_count = u32(index_count),
339	})
340}
341
342make_quad :: proc(pos, size: Vec2) -> (q: Quad) {
343	return {pos, pos + Vec2{size.x, 0}, pos + size, pos + Vec2{0, size.y}}
344}
345
346push_quad_verts :: proc(r: ^Renderer, pos: Quad, uv: [4]Vec2, color: Color) {
347	base := u16(len(r.verts))
348	append(&r.verts, Vertex{pos[0], uv[0], color})
349	append(&r.verts, Vertex{pos[1], uv[1], color})
350	append(&r.verts, Vertex{pos[2], uv[2], color})
351	append(&r.verts, Vertex{pos[3], uv[3], color})
352	append(&r.idxs, base + 0, base + 1, base + 2)
353	append(&r.idxs, base + 0, base + 2, base + 3)
354}
355
356draw_squircle :: proc(r: ^Renderer, pos, size: Vec2, corner_radius: f32, blend: f32, color: Color = WHITE) {
357	half := size * 0.5
358	quad := make_quad(pos, size)
359	uv   := make_quad(-half, size)
360	push_quad_verts(r, quad, uv, color)
361	push_call(r, r.white_tex.gpu, Frag_Uniforms{
362		mode           = .Squircle,
363		half_size      = half,
364		corner_radius  = clamp(corner_radius, 0, min(half.x, half.y)),
365		squircle_blend = clamp(blend, 0, 1),
366	}, 6)
367}
368
369draw_rect :: proc(r: ^Renderer, pos, size: Vec2, color: Color) {
370	draw_squircle(r, pos, size, 0, 0, color)
371}
372
373draw_texture :: proc(r: ^Renderer, tex: Texture, pos, size: Vec2, color: Color = WHITE, uv0: Vec2 = {0, 0}, uv1: Vec2 = {1, 1}) {
374	ensure(r != nil)
375	if tex.gpu == nil {
376		// Draw magenta rect is texture is missing
377		draw_rect(r, pos, size, {1, 0, 1, 0.5})
378		return
379	}
380	quad := make_quad(pos, size)
381	uv   := make_quad(uv0, uv1)
382	push_quad_verts(r, quad, uv, color)
383	push_call(r, tex.gpu, Frag_Uniforms{mode = .Texture}, 6)
384}
385
386load_font :: proc(r: ^Renderer, png_path, json_path: string) -> (res: Font) {
387	res.texture = load_texture(r, png_path)
388
389	data := read_file_or_die(json_path)
390	defer delete(data)
391
392	parsed: Atlas_JSON
393	err := json.unmarshal(data, &parsed)
394	fmt.assertf(err == nil, "Failed to parse font JSON %s: %v", json_path, err)
395
396	res.atlas_w        = parsed.atlas.width
397	res.atlas_h        = parsed.atlas.height
398	res.distance_range = parsed.atlas.distance_range
399	res.atlas_size_px  = parsed.atlas.size
400
401	fmt.assertf(len(parsed.variants) > 0, "Font JSONs %s has %v variants", json_path, len(parsed.variants))
402
403	res.variants = make([]Font_Variant, len(parsed.variants))
404
405	for &variant, i in res.variants {
406		parsed_variant := parsed.variants[i]
407		variant.line_height = parsed_variant.metrics.line_height
408		variant.ascender    = parsed_variant.metrics.ascender
409		variant.descender   = parsed_variant.metrics.descender
410		variant.glyphs      = make(map[rune]Glyph)
411
412		for g in parsed_variant.glyphs {
413			glyph: Glyph
414			glyph.advance = g.advance
415			if g.plane_bounds != {} {
416				glyph.plane = transmute(Vec4)g.plane_bounds
417				glyph.has_bounds = true
418			}
419			if g.atlas_bounds != {} {
420				atlas := transmute(Vec4)g.atlas_bounds
421
422				u0 := atlas.x / res.atlas_w
423				v0 := 1.0 - (atlas.w / res.atlas_h)
424				u1 := atlas.z / res.atlas_w
425				v1 := 1.0 - (atlas.y / res.atlas_h)
426
427				glyph.uv = {{u0, v0}, {u1, v0}, {u1, v1}, {u0, v1}}
428			}
429			variant.glyphs[rune(g.unicode)] = glyph
430		}
431	}
432
433	for v in parsed.variants {
434		delete(v.glyphs)
435	}
436	delete(parsed.variants)
437
438	return res
439}
440
441measure :: proc(font: ^Font, face_id: int, text: string, font_size: f32) -> (res: Vec2) {
442	variant    := font.variants[face_id]
443	line_count := f32(1)
444	width      := f32(0)
445
446	for ch in text {
447		if ch == '\n' {
448			line_count += 1
449			res.x = max(res.x, width)
450			width = 0
451			continue
452		}
453
454		glyph, _ := variant.glyphs[ch]
455		width += glyph.advance * font_size
456	}
457
458	res.x = width
459	res.y = variant.line_height * font_size * line_count
460
461	return
462}
463
464draw_text :: proc(r: ^Renderer, font: ^Font, face_id: int, text: string, pos: Vec2, font_size: f32, color: Color = WHITE) {
465	cursor := Vec2{pos.x, pos.y + font_size} // baseline of the first line
466	screen_px_range := font.distance_range * (font_size / font.atlas_size_px)
467	variant := font.variants[face_id]
468
469	start_index_count := 0
470
471	for ch in text {
472		if ch == '\n' {
473			cursor.x = pos.x
474			cursor.y += variant.line_height * font_size
475			continue
476		}
477
478		glyph, found := variant.glyphs[ch]
479		if !found || !glyph.has_bounds {
480			if found {
481				cursor.x += glyph.advance * font_size
482			}
483			continue
484		}
485
486		x0 := cursor.x + glyph.plane.x * font_size
487		y0 := cursor.y - glyph.plane.w * font_size
488		x1 := cursor.x + glyph.plane.z * font_size
489		y1 := cursor.y - glyph.plane.y * font_size
490
491		push_quad_verts(r,
492			{{x0, y0}, {x1, y0}, {x1, y1}, {x0, y1}},
493			glyph.uv,
494			color)
495
496		start_index_count += 6
497		cursor.x += glyph.advance * font_size
498	}
499
500	if start_index_count > 0 {
501		push_call(r, font.texture.gpu, Frag_Uniforms{
502			mode            = .MSDF,
503			screen_px_range = screen_px_range,
504		}, start_index_count)
505	}
506}
507
508ensure_buffer_capacity :: proc(r: ^Renderer, buf: ^^sdl3.GPUBuffer, cap: ^int, needed_bytes: int, usage: sdl3.GPUBufferUsageFlags) {
509	if needed_bytes <= cap^ {
510		return
511	}
512	if buf^ != nil {
513		sdl3.ReleaseGPUBuffer(r.device, buf^)
514	}
515	new_cap := max(needed_bytes, cap^ * 2, 4096)
516	buf^ = sdl3.CreateGPUBuffer(r.device, sdl3.GPUBufferCreateInfo{usage = usage, size = u32(new_cap)})
517	cap^ = new_cap
518}
519
520upload_buffer :: proc(r: ^Renderer, dst: ^sdl3.GPUBuffer, data: []$T) {
521	if len(data) == 0 {
522		return
523	}
524	size := len(data) * size_of(T)
525
526	transfer := sdl3.CreateGPUTransferBuffer(r.device, sdl3.GPUTransferBufferCreateInfo{usage = .UPLOAD, size = u32(size)})
527	mapped   := ([^]T)(sdl3.MapGPUTransferBuffer(r.device, transfer, false))
528	copy(mapped[:len(data)], data[:])
529	sdl3.UnmapGPUTransferBuffer(r.device, transfer)
530
531	cmd := sdl3.AcquireGPUCommandBuffer(r.device)
532	copy_pass := sdl3.BeginGPUCopyPass(cmd)
533	src := sdl3.GPUTransferBufferLocation{transfer_buffer = transfer, offset = 0}
534	dst_region := sdl3.GPUBufferRegion{buffer = dst, offset = 0, size = u32(size)}
535	sdl3.UploadToGPUBuffer(copy_pass, src, dst_region, false)
536	sdl3.EndGPUCopyPass(copy_pass)
537	assert(sdl3.SubmitGPUCommandBuffer(cmd))
538
539	sdl3.ReleaseGPUTransferBuffer(r.device, transfer)
540}
541
542// Uploads the current batch and issues every draw call in one render pass.
543renderer_flush :: proc(r: ^Renderer) {
544	vbytes := len(r.verts) * size_of(Vertex)
545	ibytes := len(r.idxs)  * size_of(u16)
546
547	ensure_buffer_capacity(r, &r.vbo, &r.vbo_cap, vbytes, sdl3.GPUBufferUsageFlags{.VERTEX})
548	ensure_buffer_capacity(r, &r.ibo, &r.ibo_cap, ibytes, sdl3.GPUBufferUsageFlags{.INDEX})
549
550	if len(r.verts) > 0 {
551		upload_buffer(r, r.vbo, r.verts[:])
552	}
553	if len(r.idxs) > 0 {
554		upload_buffer(r, r.ibo, r.idxs[:])
555	}
556
557	cmd := sdl3.AcquireGPUCommandBuffer(r.device)
558
559	swap_tex: ^sdl3.GPUTexture
560	if !sdl3.WaitAndAcquireGPUSwapchainTexture(cmd, r.window, &swap_tex, &r.w, &r.h) {
561		fmt.eprintfln("WaitAndAcquireGPUSwapchainTexture failed: %s", sdl3.GetError())
562		assert(sdl3.SubmitGPUCommandBuffer(cmd))
563		return
564	}
565	if swap_tex == nil {
566		assert(sdl3.SubmitGPUCommandBuffer(cmd))
567		return
568	}
569
570	color_target := sdl3.GPUColorTargetInfo{
571		texture     = swap_tex,
572		load_op     = .CLEAR,
573		clear_color = sdl3.FColor(BG_COLOR),
574		store_op    = .STORE,
575	}
576
577	pass := sdl3.BeginGPURenderPass(cmd, &color_target, 1, nil)
578	sdl3.BindGPUGraphicsPipeline(pass, r.pipeline)
579
580	vbuf_binding := sdl3.GPUBufferBinding{buffer = r.vbo, offset = 0}
581	sdl3.BindGPUVertexBuffers(pass, 0, &vbuf_binding, 1)
582	ibuf_binding := sdl3.GPUBufferBinding{buffer = r.ibo, offset = 0}
583	sdl3.BindGPUIndexBuffer(pass, ibuf_binding, ._16BIT)
584
585	vert_u := Vert_Uniforms{mvp = linalg.matrix_ortho3d(0, f32(r.w), f32(r.h), 0, -1, 1)}
586	sdl3.PushGPUVertexUniformData(cmd, 0, &vert_u, size_of(vert_u))
587
588	for &call in r.calls {
589		sampler := r.nearest_sampler if call.frag.mode == .Squircle else r.linear_sampler
590		binding := sdl3.GPUTextureSamplerBinding{texture = call.texture, sampler = sampler}
591		sdl3.BindGPUFragmentSamplers(pass, 0, &binding, 1)
592
593		sdl3.PushGPUFragmentUniformData(cmd, 0, &call.frag, size_of(Frag_Uniforms))
594		sdl3.DrawGPUIndexedPrimitives(pass, call.index_count, 1, call.index_off, 0, 0)
595	}
596
597	sdl3.EndGPURenderPass(pass)
598	assert(sdl3.SubmitGPUCommandBuffer(cmd))
599}
600
601_main :: proc() {
602	fmt.assertf(sdl3.Init(sdl3.InitFlags{.VIDEO}), "SDL_Init failed: %s", sdl3.GetError())
603	defer sdl3.Quit()
604
605	window := sdl3.CreateWindow("SDL3 GPU - Odin", 1280, 720, sdl3.WindowFlags{.RESIZABLE})
606	fmt.assertf(window != nil, "CreateWindow failed: %s", sdl3.GetError())
607	defer sdl3.DestroyWindow(window)
608
609	r := renderer_init(window)
610	defer {
611		destroy_texture(&r, r.white_tex)
612		destroy_renderer(&r)
613	}
614
615	photo := load_texture(&r, "res/emblem.png")
616	defer destroy_texture(&r, photo)
617
618	font := load_font(&r, "res/font.png", "res/font.json")
619	defer destroy_font(font)
620
621	running   := true
622	minimized := .MINIMIZED in sdl3.GetWindowFlags(window)
623
624	handle_event :: proc(event: ^sdl3.Event, running, minimized: ^bool) {
625		#partial switch event.type {
626		case .QUIT:
627			running^ = false
628		case .KEY_DOWN:
629			if event.key.key == sdl3.K_ESCAPE {
630				running^ = false
631			}
632		case .WINDOW_MINIMIZED:
633			minimized^ = true
634		case .WINDOW_RESTORED:
635			minimized^ = false
636		}
637	}
638
639	dt:      time.Duration
640	face_id: int
641	angle:   f32
642
643	for running {
644		if minimized {
645			// Nothing is being presented, so WaitAndAcquireGPUSwapchainTexture
646			// can't pace us against vsync here. Block on SDL_WaitEvent instead.
647			event: sdl3.Event
648			if sdl3.WaitEventTimeout(&event, 100) {
649				handle_event(&event, &running, &minimized)
650			}
651			continue
652		}
653
654		start := time.tick_now()
655		defer dt = time.tick_since(start)
656
657		event: sdl3.Event
658		for sdl3.PollEvent(&event) {
659			handle_event(&event, &running, &minimized)
660		}
661
662		renderer_begin_frame(&r)
663
664		if angle += (f32(dt) / 1e9 * math.PI); angle > math.TAU {
665			angle = math.mod(angle, math.TAU)
666			face_id += 1
667			if face_id >= len(font.variants) {
668				face_id = 0
669			}
670		}
671
672		size   := Vec2{160, 160}
673		pos    := Vec2{1280 / 2 - size.x  / 2, size.y + 60}
674		radius := f32(24)
675
676		draw_rect(&r,           pos - {0, 180}, size,              {0.95, 0.25, 0.95, 1.0})
677		draw_squircle(&r,       pos - {180, 0}, size, radius, 1.0, {0.95, 0.25, 0.25, 1.0})
678		draw_squircle(&r,       pos,            size, radius, 0.5, {0.25, 0.95, 0.25, 1.0})
679		draw_texture(&r, photo, pos + {180, 0}, size)
680
681		radius += math.sin(angle) * 12
682		factor := f32(0.5) + math.sin(angle) * 0.5
683
684		draw_squircle(&r, pos + {0, size.y + 20}, size, radius, factor, {0.95, 0.95, 0.25, 1.0})
685
686		font_size := f32(64) + math.sin(angle) * 32
687		extents   := measure(&font, face_id, "Hellope, World!", font_size)
688		draw_text(&r, &font, face_id, "Hellope, World!", {1280 / 2, 650} - extents / 2, font_size, WHITE)
689
690		renderer_flush(&r)
691	}
692}
693
694main :: proc() {
695	track: mem.Tracking_Allocator
696	mem.tracking_allocator_init(&track, context.allocator)
697	defer mem.tracking_allocator_destroy(&track)
698	context.allocator = mem.tracking_allocator(&track)
699
700	_main()
701
702	for _, leak in track.allocation_map {
703		fmt.eprintfln("%v leaked %m", leak.location, leak.size)
704	}
705}

Shaders

res/shader.frag ¶
106 linesSource

#version 450

layout(location = 0) in  vec2 in_uv;
layout(location = 1) in  vec4 in_color;
layout(location = 0) out vec4 out_color;

// set = 2 -> fragment sampled textures (SDL_GPU SPIR-V convention)
layout(set = 2, binding = 0) uniform sampler2D tex;

// set = 3 -> fragment uniform buffers (SDL_GPU SPIR-V convention)
//
// Field order/sizes here must exactly match Odin's `Frag_Uniforms` struct
// in main.odin. Only the fields relevant to the active `mode` are
// meaningful on any given draw call.
layout(set = 3, binding = 0) uniform FragUniforms {
	int   mode;             // 0 = solid, 1 = textured, 2 = msdf text, 3 = squircle
	float screen_px_range;  // mode == 2
	vec2  half_size;        // mode == 3, pixels, local-space quad half-extents
	float corner_radius;    // mode == 3, pixels
	float squircle_blend;   // mode == 3, 0 = rounded rect, 1 = ellipse/circle
	float _pad0;
	float _pad1;
} u;

// Median of the three MSDF channels - standard multi-channel signed distance field decode step
// (see Chlumsky, "Shape Decomposition for Multi-Channel Distance Fields").
float median3(float r, float g, float b) {
	return max(min(r, g), min(max(r, g), b));
}

vec4 mtsdf(float mix_factor) {
#if true
	vec4 samp = texture(tex, in_uv);

	// 1. Reconstruct MSDF distance (median of RGB)
	float msdf_dist = max(min(samp.r, samp.g), min(max(samp.r, samp.g), samp.b));

	// 2. Reconstruct true SDF distance (alpha channel, MTSDF-specific)
	float sdf_dist = samp.a;

	// 3. Screen-space pixel range
	vec2  screen_px_range = u.screen_px_range / fwidth(in_uv);
	float px_range = max(0.5 * dot(screen_px_range, vec2(1.0)), 1.0);

	// 4. Inner (MSDF) and outer (true SDF) opacity
	float inner = px_range * (msdf_dist - 0.5) + 0.5;
	float inner_opacity = clamp(inner, 0.0, 1.0);

	float outer = px_range * (sdf_dist - 0.5) + 0.5;
	float outer_opacity = clamp(outer, 0.0, 1.0);

	// 5. Mix
	return mix(in_color * inner_opacity, in_color * outer_opacity, mix_factor);
#else
        vec3  msdf = texture(tex, in_uv).rgb;
        float sd   = median3(msdf.r, msdf.g, msdf.b);
        float screen_px_dist = u.screen_px_range * (sd - 0.5);
        float coverage = clamp(screen_px_dist + 0.5, 0.0, 1.0);
        return vec4(in_color.rgb, in_color.a * coverage);
#endif
}

// Signed distance to an axis-aligned rounded box, centered at the origin.
// b = half-extents, r = corner radius. Standard Inigo Quilez formulation.
float sd_round_box(vec2 p, vec2 b, float r) {
	vec2 q = abs(p) - b + r;
	return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}

// Cheap approximate signed distance to an axis-aligned ellipse, centered at
// the origin, with half-extents b. Not exact (a true ellipse SDF needs an
// iterative solve), but plenty accurate near the boundary, which is all
// that matters for antialiasing - and it degenerates to an exact circle SDF
// when b.x == b.y.
float sd_ellipse_approx(vec2 p, vec2 b) {
	return (length(p / b) - 1.0) * min(b.x, b.y);
}

void main() {
	if        (u.mode == 0) {
		// MTSDF glyph: reconstruct a smooth per-pixel coverage value from the
		// distance field and use it as alpha, tinted by the vertex color.
		out_color = mtsdf(0.8);

	} else if (u.mode == 1) {
		// Squircle: blend a rounded-box SDF and an ellipse SDF, then
		// antialias the zero crossing using screen-space derivatives so it
		// stays crisp at any size without needing a precomputed px range.
		// `in_uv` was repurposed by draw_squircle to carry local pixel-space
		// position rather than a texture coordinate.
		vec2  p = in_uv;
		float d_box     = sd_round_box(p, u.half_size, u.corner_radius);
		float d_ellipse = sd_ellipse_approx(p, u.half_size);
		float d = mix(d_box, d_ellipse, u.squircle_blend);

		float aa = max(fwidth(d), 1e-4);
		float coverage = 1.0 - smoothstep(-aa, aa, d);
		out_color = vec4(in_color.rgb, in_color.a * coverage);
		// // Flat colored rectangle - vertex color carries everything.
		// out_color = in_color;
	} else if (u.mode == 2) {
		// Regular textured quad, straight alpha blended.
		vec4 tex_color = texture(tex, in_uv);
		out_color = tex_color * in_color;
	}
}

res/shader.vert ¶
26 linesSource

#version 450

// One vertex layout serves all three draw kinds (solid rect, textured quad,
// msdf glyph). Which behaviour is used is decided in the fragment shader via
// the `mode` fragment uniform.

layout(location = 0) in vec2 in_position;
layout(location = 1) in vec2 in_uv;
layout(location = 2) in vec4 in_color;

layout(location = 0) out vec2 out_uv;
layout(location = 1) out vec4 out_color;

// SDL_GPU resource binding convention for SPIR-V:
//   vertex shaders   -> uniform buffers live in set = 1
//   fragment shaders -> sampled textures in set = 2, uniform buffers in set = 3
// (see SDL_CreateGPUShader documentation)
layout(set = 1, binding = 0) uniform UniformBlock {
	mat4 mvp;
} ubo;

void main() {
	gl_Position = ubo.mvp * vec4(in_position, 0.0, 1.0);
	out_uv      = in_uv;
	out_color   = in_color;
}

Other Files

Assets 4

Declarations Used 94