Files
levlib/draw/textures.odin
Zachary Levy 0d424cbd6e Texture Rendering (#9)
Co-authored-by: Zachary Levy <zachary@sunforge.is>
Reviewed-on: #9
2026-04-22 00:05:08 +00:00

415 lines
13 KiB
Odin

package draw
import "core:log"
import "core:mem"
import sdl "vendor:sdl3"
Texture_Id :: distinct u32
INVALID_TEXTURE :: Texture_Id(0) // Slot 0 is reserved/unused
Texture_Kind :: enum u8 {
Static, // Uploaded once, never changes (QR codes, decoded PNGs, icons)
Dynamic, // Updatable via update_texture_region
Stream, // Frequent full re-uploads (video, procedural)
}
Sampler_Preset :: enum u8 {
Nearest_Clamp,
Linear_Clamp,
Nearest_Repeat,
Linear_Repeat,
}
SAMPLER_PRESET_COUNT :: 4
Fit_Mode :: enum u8 {
Stretch, // Fill rect, may distort aspect ratio (default)
Fit, // Preserve aspect, letterbox (may leave margins)
Fill, // Preserve aspect, center-crop (may crop edges)
Tile, // Repeat at native texture size
Center, // 1:1 pixel size, centered, no scaling
}
Texture_Desc :: struct {
width: u32,
height: u32,
depth_or_layers: u32,
type: sdl.GPUTextureType,
format: sdl.GPUTextureFormat,
usage: sdl.GPUTextureUsageFlags,
mip_levels: u32,
kind: Texture_Kind,
}
// Internal slot — not exported.
@(private)
Texture_Slot :: struct {
gpu_texture: ^sdl.GPUTexture,
desc: Texture_Desc,
generation: u32,
}
// State stored in GLOB
// This file references:
// GLOB.device : ^sdl.GPUDevice
// GLOB.texture_slots : [dynamic]Texture_Slot
// GLOB.texture_free_list : [dynamic]u32
// GLOB.pending_texture_releases : [dynamic]Texture_Id
// GLOB.samplers : [SAMPLER_PRESET_COUNT]^sdl.GPUSampler
Clay_Image_Data :: struct {
texture_id: Texture_Id,
fit: Fit_Mode,
tint: Color,
}
clay_image_data :: proc(id: Texture_Id, fit: Fit_Mode = .Stretch, tint: Color = WHITE) -> Clay_Image_Data {
return {texture_id = id, fit = fit, tint = tint}
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Registration -------------
// ---------------------------------------------------------------------------------------------------------------------
// Register a texture. Draw owns the GPU resource and releases it on unregister.
// `data` is tightly-packed row-major bytes matching desc.format.
// The caller may free `data` immediately after this proc returns.
@(require_results)
register_texture :: proc(desc: Texture_Desc, data: []u8) -> (id: Texture_Id, ok: bool) {
device := GLOB.device
if device == nil {
log.error("register_texture called before draw.init()")
return INVALID_TEXTURE, false
}
assert(desc.width > 0, "Texture_Desc.width must be > 0")
assert(desc.height > 0, "Texture_Desc.height must be > 0")
assert(desc.depth_or_layers > 0, "Texture_Desc.depth_or_layers must be > 0")
assert(desc.mip_levels > 0, "Texture_Desc.mip_levels must be > 0")
assert(desc.usage != {}, "Texture_Desc.usage must not be empty (e.g. {.SAMPLER})")
// Create the GPU texture
gpu_texture := sdl.CreateGPUTexture(
device,
sdl.GPUTextureCreateInfo {
type = desc.type,
format = desc.format,
usage = desc.usage,
width = desc.width,
height = desc.height,
layer_count_or_depth = desc.depth_or_layers,
num_levels = desc.mip_levels,
sample_count = ._1,
},
)
if gpu_texture == nil {
log.errorf("Failed to create GPU texture (%dx%d): %s", desc.width, desc.height, sdl.GetError())
return INVALID_TEXTURE, false
}
// Upload pixel data via a transfer buffer
if len(data) > 0 {
data_size := u32(len(data))
transfer := sdl.CreateGPUTransferBuffer(
device,
sdl.GPUTransferBufferCreateInfo{usage = .UPLOAD, size = data_size},
)
if transfer == nil {
log.errorf("Failed to create texture transfer buffer: %s", sdl.GetError())
sdl.ReleaseGPUTexture(device, gpu_texture)
return INVALID_TEXTURE, false
}
defer sdl.ReleaseGPUTransferBuffer(device, transfer)
mapped := sdl.MapGPUTransferBuffer(device, transfer, false)
if mapped == nil {
log.errorf("Failed to map texture transfer buffer: %s", sdl.GetError())
sdl.ReleaseGPUTexture(device, gpu_texture)
return INVALID_TEXTURE, false
}
mem.copy(mapped, raw_data(data), int(data_size))
sdl.UnmapGPUTransferBuffer(device, transfer)
cmd_buffer := sdl.AcquireGPUCommandBuffer(device)
if cmd_buffer == nil {
log.errorf("Failed to acquire command buffer for texture upload: %s", sdl.GetError())
sdl.ReleaseGPUTexture(device, gpu_texture)
return INVALID_TEXTURE, false
}
copy_pass := sdl.BeginGPUCopyPass(cmd_buffer)
sdl.UploadToGPUTexture(
copy_pass,
sdl.GPUTextureTransferInfo{transfer_buffer = transfer},
sdl.GPUTextureRegion{texture = gpu_texture, w = desc.width, h = desc.height, d = desc.depth_or_layers},
false,
)
sdl.EndGPUCopyPass(copy_pass)
if !sdl.SubmitGPUCommandBuffer(cmd_buffer) {
log.errorf("Failed to submit texture upload: %s", sdl.GetError())
sdl.ReleaseGPUTexture(device, gpu_texture)
return INVALID_TEXTURE, false
}
}
// Allocate a slot (reuse from free list or append)
slot_index: u32
if len(GLOB.texture_free_list) > 0 {
slot_index = pop(&GLOB.texture_free_list)
GLOB.texture_slots[slot_index] = Texture_Slot {
gpu_texture = gpu_texture,
desc = desc,
generation = GLOB.texture_slots[slot_index].generation + 1,
}
} else {
slot_index = u32(len(GLOB.texture_slots))
append(&GLOB.texture_slots, Texture_Slot{gpu_texture = gpu_texture, desc = desc, generation = 1})
}
return Texture_Id(slot_index), true
}
// Queue a texture for release at the end of the current frame.
// The GPU resource is not freed immediately — see "Deferred release" in the README.
unregister_texture :: proc(id: Texture_Id) {
if id == INVALID_TEXTURE do return
append(&GLOB.pending_texture_releases, id)
}
// Re-upload a sub-region of a Dynamic texture.
update_texture_region :: proc(id: Texture_Id, region: Rectangle, data: []u8) {
if id == INVALID_TEXTURE do return
slot := &GLOB.texture_slots[u32(id)]
if slot.gpu_texture == nil do return
device := GLOB.device
data_size := u32(len(data))
if data_size == 0 do return
transfer := sdl.CreateGPUTransferBuffer(
device,
sdl.GPUTransferBufferCreateInfo{usage = .UPLOAD, size = data_size},
)
if transfer == nil {
log.errorf("Failed to create transfer buffer for texture region update: %s", sdl.GetError())
return
}
defer sdl.ReleaseGPUTransferBuffer(device, transfer)
mapped := sdl.MapGPUTransferBuffer(device, transfer, false)
if mapped == nil {
log.errorf("Failed to map transfer buffer for texture region update: %s", sdl.GetError())
return
}
mem.copy(mapped, raw_data(data), int(data_size))
sdl.UnmapGPUTransferBuffer(device, transfer)
cmd_buffer := sdl.AcquireGPUCommandBuffer(device)
if cmd_buffer == nil {
log.errorf("Failed to acquire command buffer for texture region update: %s", sdl.GetError())
return
}
copy_pass := sdl.BeginGPUCopyPass(cmd_buffer)
sdl.UploadToGPUTexture(
copy_pass,
sdl.GPUTextureTransferInfo{transfer_buffer = transfer},
sdl.GPUTextureRegion {
texture = slot.gpu_texture,
x = u32(region.x),
y = u32(region.y),
w = u32(region.width),
h = u32(region.height),
d = 1,
},
false,
)
sdl.EndGPUCopyPass(copy_pass)
if !sdl.SubmitGPUCommandBuffer(cmd_buffer) {
log.errorf("Failed to submit texture region update: %s", sdl.GetError())
}
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Helpers -------------
// ---------------------------------------------------------------------------------------------------------------------
// Compute UV rect, recommended sampler, and inner rect for a given fit mode.
// `rect` is the target drawing area; `texture_id` identifies the texture whose
// pixel dimensions are looked up via texture_size().
// For Fit mode, `inner_rect` is smaller than `rect` (centered). For all other modes, `inner_rect == rect`.
fit_params :: proc(
fit: Fit_Mode,
rect: Rectangle,
texture_id: Texture_Id,
) -> (
uv_rect: Rectangle,
sampler: Sampler_Preset,
inner_rect: Rectangle,
) {
size := texture_size(texture_id)
texture_width := f32(size.x)
texture_height := f32(size.y)
rect_width := rect.width
rect_height := rect.height
inner_rect = rect
if texture_width == 0 || texture_height == 0 || rect_width == 0 || rect_height == 0 {
return {0, 0, 1, 1}, .Linear_Clamp, inner_rect
}
texture_aspect := texture_width / texture_height
rect_aspect := rect_width / rect_height
switch fit {
case .Stretch: return {0, 0, 1, 1}, .Linear_Clamp, inner_rect
case .Fill: if texture_aspect > rect_aspect {
// Texture wider than rect — crop sides
scale := rect_aspect / texture_aspect
margin := (1 - scale) * 0.5
return {margin, 0, 1 - margin, 1}, .Linear_Clamp, inner_rect
} else {
// Texture taller than rect — crop top/bottom
scale := texture_aspect / rect_aspect
margin := (1 - scale) * 0.5
return {0, margin, 1, 1 - margin}, .Linear_Clamp, inner_rect
}
case .Fit:
// Preserve aspect, fit inside rect. Returns a shrunken inner_rect.
if texture_aspect > rect_aspect {
// Image wider — letterbox top/bottom
fit_height := rect_width / texture_aspect
padding := (rect_height - fit_height) * 0.5
inner_rect = Rectangle{rect.x, rect.y + padding, rect_width, fit_height}
} else {
// Image taller — letterbox left/right
fit_width := rect_height * texture_aspect
padding := (rect_width - fit_width) * 0.5
inner_rect = Rectangle{rect.x + padding, rect.y, fit_width, rect_height}
}
return {0, 0, 1, 1}, .Linear_Clamp, inner_rect
case .Tile:
uv_width := rect_width / texture_width
uv_height := rect_height / texture_height
return {0, 0, uv_width, uv_height}, .Linear_Repeat, inner_rect
case .Center:
u_half := rect_width / (2 * texture_width)
v_half := rect_height / (2 * texture_height)
return {0.5 - u_half, 0.5 - v_half, 0.5 + u_half, 0.5 + v_half}, .Nearest_Clamp, inner_rect
}
return {0, 0, 1, 1}, .Linear_Clamp, inner_rect
}
texture_size :: proc(id: Texture_Id) -> [2]u32 {
if id == INVALID_TEXTURE do return {0, 0}
slot := &GLOB.texture_slots[u32(id)]
return {slot.desc.width, slot.desc.height}
}
texture_format :: proc(id: Texture_Id) -> sdl.GPUTextureFormat {
if id == INVALID_TEXTURE do return .INVALID
return GLOB.texture_slots[u32(id)].desc.format
}
texture_kind :: proc(id: Texture_Id) -> Texture_Kind {
if id == INVALID_TEXTURE do return .Static
return GLOB.texture_slots[u32(id)].desc.kind
}
// Internal: get the raw GPU texture pointer for binding during draw.
@(private)
texture_gpu_handle :: proc(id: Texture_Id) -> ^sdl.GPUTexture {
if id == INVALID_TEXTURE do return nil
idx := u32(id)
if idx >= u32(len(GLOB.texture_slots)) do return nil
return GLOB.texture_slots[idx].gpu_texture
}
// Deferred release (called from draw.end / clear_global)
@(private)
process_pending_texture_releases :: proc() {
device := GLOB.device
for id in GLOB.pending_texture_releases {
idx := u32(id)
if idx >= u32(len(GLOB.texture_slots)) do continue
slot := &GLOB.texture_slots[idx]
if slot.gpu_texture != nil {
sdl.ReleaseGPUTexture(device, slot.gpu_texture)
slot.gpu_texture = nil
}
slot.generation += 1
append(&GLOB.texture_free_list, idx)
}
clear(&GLOB.pending_texture_releases)
}
@(private)
get_sampler :: proc(preset: Sampler_Preset) -> ^sdl.GPUSampler {
idx := int(preset)
if GLOB.samplers[idx] != nil do return GLOB.samplers[idx]
// Lazily create
min_filter, mag_filter: sdl.GPUFilter
address_mode: sdl.GPUSamplerAddressMode
switch preset {
case .Nearest_Clamp:
min_filter = .NEAREST; mag_filter = .NEAREST; address_mode = .CLAMP_TO_EDGE
case .Linear_Clamp:
min_filter = .LINEAR; mag_filter = .LINEAR; address_mode = .CLAMP_TO_EDGE
case .Nearest_Repeat:
min_filter = .NEAREST; mag_filter = .NEAREST; address_mode = .REPEAT
case .Linear_Repeat:
min_filter = .LINEAR; mag_filter = .LINEAR; address_mode = .REPEAT
}
sampler := sdl.CreateGPUSampler(
GLOB.device,
sdl.GPUSamplerCreateInfo {
min_filter = min_filter,
mag_filter = mag_filter,
mipmap_mode = .LINEAR,
address_mode_u = address_mode,
address_mode_v = address_mode,
address_mode_w = address_mode,
},
)
if sampler == nil {
log.errorf("Failed to create sampler preset %v: %s", preset, sdl.GetError())
return GLOB.pipeline_2d_base.sampler // fallback to existing default sampler
}
GLOB.samplers[idx] = sampler
return sampler
}
// Internal: destroy all sampler pool entries. Called from draw.destroy().
@(private)
destroy_sampler_pool :: proc() {
device := GLOB.device
for &s in GLOB.samplers {
if s != nil {
sdl.ReleaseGPUSampler(device, s)
s = nil
}
}
}
// Internal: destroy all registered textures. Called from draw.destroy().
@(private)
destroy_all_textures :: proc() {
device := GLOB.device
for &slot in GLOB.texture_slots {
if slot.gpu_texture != nil {
sdl.ReleaseGPUTexture(device, slot.gpu_texture)
slot.gpu_texture = nil
}
}
delete(GLOB.texture_slots)
delete(GLOB.texture_free_list)
delete(GLOB.pending_texture_releases)
}