const std = @import("std"); const log = std.log.scoped(.graphics); const assert = std.debug.assert; const Allocator = std.mem.Allocator; const sokol = @import("sokol"); const sg = sokol.gfx; const sapp = sokol.app; const sglue = sokol.glue; const Math = @import("math"); const Vec2 = Math.Vec2; const Vec4 = Math.Vec4; const Mat4 = Math.Mat4; const Rect = Math.Rect; const STBRectPack = @import("stb_rect_pack"); const ImGUI = @import("imgui.zig"); pub const Color = @import("./color.zig"); const shd = @import("shader"); const SlotMapType = @import("slot_map.zig").SlotMapType; const Frame = struct { index: u32, quads: std.ArrayList(VertexQuad), transforms: std.ArrayList(TransformFrame), window_size: Vec2, clear_color: Color = .black, pass_started: bool = false }; const State = struct { gpa: std.mem.Allocator, frame: Frame, shader: sg.Shader, pipeline: sg.Pipeline, bindings: sg.Bindings, default_sprite: Sprite.Id, error_sprite: Sprite.Id, default_spritesheet: Spritesheet.Id, linear_sampler: sg.Sampler, nearest_sampler: sg.Sampler, default_sampler: Sampler, quads_buffer: [8192]VertexQuad, transforms_buffer: [32]TransformFrame, textures_buffer: [Texture.max_textures]Texture.SlotMap.Slot, textures: Texture.SlotMap, spritesheets_buffer: [Spritesheet.max_spritesheets]Spritesheet.SlotMap.Slot, spritesheets: Spritesheet.SlotMap, sprites_buffer: [Sprite.max_sprites]Sprite.SlotMap.Slot, sprites: Sprite.SlotMap, }; var g_state: State = undefined; pub fn init(gpa: std.mem.Allocator, logger: sg.Logger) !void { const self = &g_state; sg.setup(.{ .environment = sglue.environment(), .logger = logger }); const shader = sg.makeShader(shd.mainShaderDesc(sg.queryBackend())); assert(shader.id != sg.invalid_id); const linear_sampler = sg.makeSampler(.{ .min_filter = .LINEAR, .mag_filter = .LINEAR, .label = "linear-sampler" }); assert(linear_sampler.id != sg.invalid_id); const nearest_sampler = sg.makeSampler(.{ .min_filter = .NEAREST, .mag_filter = .NEAREST, .label = "nearest-sampler" }); assert(linear_sampler.id != sg.invalid_id); const pipeline = sg.makePipeline(.{ .primitive_type = .TRIANGLES, .index_type = .UINT16, .cull_mode = .BACK, .colors = init: { var s: [8]sg.ColorTargetState = @splat(.{}); s[0] = .{ .blend = .{ .enabled = true, .src_factor_rgb = .ONE, .dst_factor_rgb = .ONE_MINUS_SRC_ALPHA, .op_rgb = .ADD, .src_factor_alpha = .ONE, .dst_factor_alpha = .ONE_MINUS_SRC_ALPHA, .op_alpha = .ADD, } }; break :init s; }, .shader = shader, .layout = init: { var l = sg.VertexLayoutState{}; l.attrs[shd.ATTR_main_position].format = .FLOAT2; l.attrs[shd.ATTR_main_texcoord0].format = .FLOAT2; l.attrs[shd.ATTR_main_color0].format = .FLOAT4; break :init l; }, .label = "main-pipeline" }); assert(pipeline.id != sg.invalid_id); var bindings: sg.Bindings = .{}; { const max_quads = self.quads_buffer.len; bindings.vertex_buffers[0] = sg.makeBuffer(.{ .size = @sizeOf(VertexQuad) * max_quads, .usage = .{ .vertex_buffer = true, .dynamic_update = true }, .label = "quad-vertices" }); const index_count = max_quads * 6; var indices: []u16 = try gpa.alloc(u16, index_count); defer gpa.free(indices); for (0..max_quads) |i| { indices[6*i + 0] = @intCast(4*i + 0); indices[6*i + 1] = @intCast(4*i + 1); indices[6*i + 2] = @intCast(4*i + 2); indices[6*i + 3] = @intCast(4*i + 1); indices[6*i + 4] = @intCast(4*i + 3); indices[6*i + 5] = @intCast(4*i + 2); } bindings.index_buffer = sg.makeBuffer(.{ .data = sg.asRange(indices), .usage = .{ .index_buffer = true, }, .label = "indices" }); } self.* = State{ .gpa = gpa, .shader = shader, .pipeline = pipeline, .bindings = bindings, .quads_buffer = undefined, .transforms_buffer = undefined, .default_sprite = undefined, .error_sprite = undefined, .default_spritesheet = undefined, .linear_sampler = linear_sampler, .nearest_sampler = nearest_sampler, .default_sampler = .nearest, .textures_buffer = undefined, .textures = .initBuffer(&self.textures_buffer), .sprites_buffer = undefined, .sprites = .initBuffer(&self.sprites_buffer), .spritesheets_buffer = undefined, .spritesheets = .initBuffer(&self.spritesheets_buffer), .frame = .{ .index = 0, .quads = .initBuffer(&self.quads_buffer), .transforms = .initBuffer(&self.transforms_buffer), .clear_color = .black, .window_size = .init(0, 0) } }; self.default_spritesheet = initSpritesheet(.{}); { const width = 6; const height = 6; var pixels: [width * height * 4]u8 = undefined; @memset(&pixels, 0xFF); self.default_sprite = initSprite(.{ .padding = 1 }); setSprite(self.default_sprite, .{ .mipchain = .initSingle(Image{ .width = width, .height = height, .format = .rgba8, .pixels = &pixels, }) }); } { const width = 6; const height = 6; var pixels: [width * height * 4]u8 = undefined; for (0..height) |y| { for (0..width) |x| { const pixel_index = y * width + x; var color: Color = undefined; if ((pixel_index+y) % 2 == 0) { color = .purple; } else { color = .black; } const pixel = pixels[(4*pixel_index)..]; pixel[0] = @intFromFloat(color.r*255); pixel[1] = @intFromFloat(color.g*255); pixel[2] = @intFromFloat(color.b*255); pixel[3] = @intFromFloat(color.a*255); } } self.error_sprite = initSprite(.{ .padding = 1 }); setSprite(self.error_sprite, .{ .mipchain = .initSingle(.{ .width = width, .height = height, .format = .rgba8, .pixels = &pixels, }) }); } } pub fn deinit() void { const self = &g_state; var sprite_iter = self.sprites.iterator(); while (sprite_iter.next()) |sprite_id| { const sprite = self.sprites.getAssumeExists(sprite_id); sprite.arena.deinit(); } sg.shutdown(); } fn toSokolColor(color: Color) sokol.gfx.Color { // TODO: Assert and do cast return sokol.gfx.Color{ .r = color.r, .g = color.g, .b = color.b, .a = color.a, }; } fn getTransformPtr() ?*TransformFrame { const self = &g_state; if (self.frame.transforms.items.len == 0) { return null; } return &self.frame.transforms.items[self.frame.transforms.items.len-1]; } pub fn transformTranslate(offset: Vec2) void { const top_frame = getTransformPtr() orelse return; top_frame.offset = top_frame.offset.add(offset.multiply(top_frame.scale)); } pub fn transformScale(scale: Vec2) void { const top_frame = getTransformPtr() orelse return; top_frame.scale = top_frame.scale.multiply(scale); } pub fn transformPush() void { const self = &g_state; const top_frame = getTransformPtr() orelse return; self.frame.transforms.appendBounded(top_frame.*) catch { log.warn("Transform stack limit reached!", .{}); }; } pub fn transformPop() void { const self = &g_state; _ = self.frame.transforms.pop(); } pub fn getTransform() TransformFrame { if (getTransformPtr()) |top_frame| { return top_frame.*; } else { return TransformFrame{ .offset = .init(0, 0), .scale = .init(1, 1) }; } } pub fn beginFrame() void { const self = &g_state; self.frame.quads.clearRetainingCapacity(); self.frame.transforms.clearRetainingCapacity(); self.frame = Frame{ .index = self.frame.index, .quads = self.frame.quads, .transforms = self.frame.transforms, .window_size = Vec2.init(sapp.widthf(), sapp.heightf()) }; self.frame.transforms.appendAssumeCapacity(TransformFrame{ .offset = .init(0, 0), .scale = .init(1, 1), }); self.bindings.views[shd.VIEW_tex].id = sg.invalid_id; self.bindings.samplers[shd.SMP_smp] = self.linear_sampler; } pub fn showDebug() void { const self = &g_state; _ = ImGUI.beginTabBar("graphics tab bar"); defer ImGUI.endTabBar(); if (ImGUI.beginTabItem("Spritesheets")) { defer ImGUI.endTabItem(); if(ImGUI.beginTable("spritesheets", 2, .{})) { defer ImGUI.endTable(); ImGUI.tableSetupColumn("ID", .{}); ImGUI.tableSetupColumn("Size", .{}); ImGUI.tableHeadersRow(); var spritesheet_iter = self.spritesheets.iterator(); while (spritesheet_iter.next()) |spritesheet_id| { const spritesheet = self.spritesheets.getAssumeExists(spritesheet_id); ImGUI.pushID(.{ .int = spritesheet_id.index }); defer ImGUI.popID(); ImGUI.tableNextRow(); ImGUI.tableNextColumn(); ImGUI.text("{{ {}, {} }}", .{spritesheet_id.index, spritesheet_id.generation}); ImGUI.tableNextColumn(); ImGUI.text("{}x{}", .{spritesheet.size.x, spritesheet.size.x}); } } } if (ImGUI.beginTabItem("Sprites")) { defer ImGUI.endTabItem(); if(ImGUI.beginTable("sprites", 2, .{})) { defer ImGUI.endTable(); ImGUI.tableSetupColumn("ID", .{}); ImGUI.tableSetupColumn("Position", .{}); ImGUI.tableHeadersRow(); var sprite_iter = self.sprites.iterator(); while (sprite_iter.next()) |sprite_id| { const sprite = self.sprites.getAssumeExists(sprite_id); ImGUI.pushID(.{ .int = sprite_id.index }); defer ImGUI.popID(); ImGUI.tableNextRow(); ImGUI.tableNextColumn(); ImGUI.text("{{ {}, {} }}", .{sprite_id.index, sprite_id.generation}); ImGUI.tableNextColumn(); if (sprite.position) |position| { ImGUI.text("{:3}, {:3}", .{position.x, position.y}); } else { ImGUI.text("null", .{}); } } } } } pub fn setClearColor(color: Color) void { const self = &g_state; self.frame.clear_color = color; } pub fn getWindowSize() Vec2 { const self = &g_state; return self.frame.window_size; } pub fn getFrameIndex() u32 { const self = &g_state; return self.frame.index; } pub fn flush() void { const self = &g_state; if (self.frame.quads.items.len == 0) { return; } defer self.frame.quads.clearRetainingCapacity(); if (!self.frame.pass_started) { var pass_action: sg.PassAction = .{}; pass_action.colors[0] = .{ .load_action = .CLEAR, .clear_value = toSokolColor(self.frame.clear_color) }; sg.beginPass(.{ .action = pass_action, .swapchain = sglue.swapchain() }); self.frame.pass_started = true; } var spritesheet_iter = self.spritesheets.iterator(); while (spritesheet_iter.next()) |spritesheet_id| { const spritesheet = self.spritesheets.getAssumeExists(spritesheet_id); const texture = self.textures.get(spritesheet.texture) orelse continue; if (isViewBound(texture.view)) { rebuildSpritesheetTextureIfNeeded(spritesheet_id); } } if (sg.queryViewState(getBoundView().*) != .VALID) { log.warn("Attempt to use view which isn't valid", .{}); return; } const vertex_buffer = self.bindings.vertex_buffers[0]; const data = sg.asRange(self.frame.quads.items); if (sg.queryBufferWillOverflow(vertex_buffer, data.size)) { log.warn("Vertex buffer overflow", .{}); return; } self.bindings.vertex_buffer_offsets[0] = sg.appendBuffer(vertex_buffer, data); const window_size = Vec2.init(sapp.widthf(), sapp.heightf()); var mvp = Mat4.initIdentity(); { // Convert normalized [-1; 1] coordinates to [0; 1] mvp = mvp.multiply(Mat4.initTranslate(.{ .x = -1, .y = -1, .z = 0 })); mvp = mvp.multiply(Mat4.initScale(.{ .x = 2, .y = 2, .z = 0 })); // Make the top-left corner (0,0) mvp = mvp.multiply(Mat4.initTranslate(.{ .x = 0, .y = 1, .z = 0 })); mvp = mvp.multiply(Mat4.initScale(.{ .x = 1, .y = -1, .z = 0 })); // Scretch [0; 1] so that they map 1-to-1 on to screen coordinates. mvp = mvp.multiply(Mat4.initScale(.{ .x = 1/window_size.x, .y = 1/window_size.y, .z = 0 })); } var vs_params: shd.VsParams = .{ .mvp = mvp }; var fs_params: shd.FsParams = .{ .texture_mode = 0, }; const image = sg.queryViewImage(getBoundView().*); if (sg.queryImagePixelformat(image) == .R8) { fs_params.texture_mode = 1; } sg.applyPipeline(self.pipeline); sg.applyBindings(self.bindings); sg.applyUniforms(shd.UB_vs_params, sg.asRange(&vs_params)); sg.applyUniforms(shd.UB_fs_params, sg.asRange(&fs_params)); assert(self.frame.quads.items.len > 0); sg.draw(0, @intCast(self.frame.quads.items.len * 6), 1); } pub fn endFrame() void { const self = &g_state; flush(); if (self.frame.pass_started) { sg.endPass(); sg.commit(); } var spritesheet_iter = self.spritesheets.iterator(); while (spritesheet_iter.next()) |spritesheet_id| { repackSpritesheetIfNeeded(spritesheet_id); } if (self.frame.transforms.items.len > 1) { log.warn("Too many calls to transformPush()", .{}); } else if (self.frame.transforms.items.len == 0) { log.warn("Too many calls to transformPop()", .{}); } self.frame.index += 1; } pub fn drawRectangle(pos: Vec2, size: Vec2, color: Color) void { drawQuad(Quad.initRect(pos, size), color); } pub fn drawQuad(quad: Quad, color: Color) void { const self = &g_state; drawSpriteQuad(self.default_sprite, quad, color); } pub fn drawRectangleOutline(pos: Vec2, size: Vec2, color: Color, width: f32, alignment: f32) void { const self = &g_state; const outer_rect = (Rect{ .pos = pos, .size = size }).grow(Vec2.init(width, width).multiplyScalar(1-alignment)); const inner_rect = (Rect{ .pos = pos, .size = size }).shrink(Vec2.init(width, width).multiplyScalar(alignment)); drawSpriteQuad( self.default_sprite, .init(.{ .init(outer_rect.right(), outer_rect.top()), .init(outer_rect.left(), outer_rect.top()), .init(inner_rect.right(), inner_rect.top()), .init(inner_rect.left(), inner_rect.top()), }), color ); drawSpriteQuad( self.default_sprite, .init(.{ .init(outer_rect.right(), outer_rect.top()), .init(inner_rect.right(), inner_rect.top()), .init(outer_rect.right(), outer_rect.bottom()), .init(inner_rect.right(), inner_rect.bottom()), }), color ); drawSpriteQuad( self.default_sprite, .init(.{ .init(outer_rect.left(), outer_rect.top()), .init(inner_rect.left(), inner_rect.top()), .init(outer_rect.left(), outer_rect.bottom()), .init(inner_rect.left(), inner_rect.bottom()), }), color ); drawSpriteQuad( self.default_sprite, .init(.{ .init(outer_rect.right(), outer_rect.bottom()), .init(outer_rect.left(), outer_rect.bottom()), .init(inner_rect.right(), inner_rect.bottom()), .init(inner_rect.left(), inner_rect.bottom()), }), color ); } pub fn drawLine(from: Vec2, to: Vec2, color: Color, width: f32) void { const self = &g_state; const step = to.sub(from).normalized().multiplyScalar(width/2); const top_left = from.add(step.rotateLeft90()); const bottom_left = from.add(step.rotateRight90()); const top_right = to.add(step.rotateLeft90()); const bottom_right = to.add(step.rotateRight90()); drawSpriteQuad( self.default_sprite, .init(.{ top_right, top_left, bottom_right, bottom_left }), color ); } const TextureOptions = struct { dynamic: bool = false }; pub fn initTexture(opts: TextureOptions) Texture.Id { const self = &g_state; const id = self.textures.insertUndefined() catch { log.warn("Failed to create texture, limit reached! limit: {}", .{self.textures.slots.capacity}); return .nil; }; const view = sg.allocView(); if (sg.queryViewState(view) != .ALLOC) { log.warn("Failed to alloc texture view", .{}); return .nil; } const texture = self.textures.getAssumeExists(id); texture.* = .{ .dynamic = opts.dynamic, .updated_at = null, .image = .{ .id = sg.invalid_id }, .view = view, }; return id; } fn getBoundView() *sg.View { const self = &g_state; return &self.bindings.views[shd.VIEW_tex]; } fn isViewBound(view: sg.View) bool { return getBoundView().id == view.id; } pub fn deinitTexture(id: Texture.Id) void { const self = &g_state; if (id == Texture.Id.nil) { return; } if (self.textures.get(id)) |texture| { const bound_view = getBoundView(); if (bound_view.id == texture.view.id) { bound_view.id = sg.invalid_id; } sg.destroyImage(texture.image); sg.destroyView(texture.view); self.textures.removeAssumeExists(id); } } const SetTextureOptions = struct { mipchain: Mipchain, premultiplied: bool = false }; pub fn setTexture(id: Texture.Id, opts: SetTextureOptions) void { const self = &g_state; const texture = self.textures.get(id) orelse { log.warn("Attempt to set texture that doesn't exist: {}", .{id}); return; }; var scratch = std.heap.ArenaAllocator.init(self.gpa); var mipchain = opts.mipchain; if (!opts.premultiplied) { var new_mipchain = mipchain.clone(scratch.allocator()) catch |e| { log.warn("Failed to clone mipmap: {}", .{e}); return; }; new_mipchain.premultiplyAlpha(); mipchain = new_mipchain; } var image_data: sg.ImageData = .{}; assert(mipchain.length() <= image_data.mip_levels.len); for (0..mipchain.length()) |index| { const image = mipchain.getLevel(@intCast(index)); image_data.mip_levels[index] = sg.asRange(image.pixelsSlice()); } var new_image_desc = sg.ImageDesc{ .type = ._2D, .width = @intCast(mipchain.width), .height = @intCast(mipchain.height), .num_mipmaps = @intCast(@max(1, mipchain.length())), .pixel_format = switch (mipchain.format) { .rgba8 => .RGBA8, .r8 => .R8, } }; if (texture.dynamic) { new_image_desc.usage.dynamic_update = true; } else { new_image_desc.data = image_data; } var texture_updated: bool = false; if (sg.queryImageState(texture.image) == .VALID) { const image_desc = sg.queryImageDesc(texture.image); if (image_desc.width == new_image_desc.width and image_desc.height == new_image_desc.height and image_desc.num_mipmaps == new_image_desc.num_mipmaps and image_desc.pixel_format == new_image_desc.pixel_format and image_desc.usage.dynamic_update == new_image_desc.usage.dynamic_update and image_desc.usage.immutable == new_image_desc.usage.immutable and image_desc.usage.dynamic_update ) { log.debug("[{}] Update texture '{f}'", .{self.frame.index, id}); if (texture.updated_at == null or texture.updated_at.? != self.frame.index) { sg.updateImage(texture.image, image_data); texture_updated = true; texture.updated_at = self.frame.index; } else { log.warn("Attempt to update the same texture multiple times per frame, texture: {f}", .{id}); } } } if (!texture_updated) { log.debug("[{}] Create image-view, texture={f}", .{self.frame.index, id}); const new_image = sg.makeImage(new_image_desc); if (sg.queryImageState(new_image) != .VALID) { log.warn("makeImage() failed", .{}); return; } if (sg.queryViewState(texture.view) == .VALID) { sg.uninitView(texture.view); } sg.initView(texture.view, .{ .texture = .{ .image = new_image } }); if (sg.queryViewState(texture.view) != .VALID) { log.warn("makeView() failed", .{}); return; } sg.destroyImage(texture.image); texture.image = new_image; texture.updated_at = null; log.debug("is dynamic {f}, {}", .{id, texture.dynamic}); if (texture.dynamic) { log.debug("update texture {f}", .{id}); sg.updateImage(new_image, image_data); texture.updated_at = self.frame.index; } } } pub fn drawTexture(id: Texture.Id, pos: Vec2, size: Vec2, color: Color) void { var quad: VertexQuad = undefined; quad.setColor(color); quad.setRect(.{ .pos = pos, .size = size }); quad.setUVRect(.init(0, 0, 1, 1)); draw(.{ .texture = id, .quad = quad }); } pub fn setDefaultSampler(sampler: Sampler) void { const self = &g_state; self.default_sampler = sampler; } pub const Sampler = enum { linear, nearest, }; const DrawOptions = struct { sampler: ?Sampler = null, texture: Texture.Id, quad: VertexQuad }; pub fn draw(opts: DrawOptions) void { const self = &g_state; var quad = opts.quad; var view: sg.View = .{ .id = sg.invalid_id }; if (self.textures.get(opts.texture)) |texture| { view = texture.view; } if (sg.queryViewState(view) != .VALID and sg.queryViewState(view) != .ALLOC) { if (self.sprites.get(self.error_sprite)) |sprite| { if (self.spritesheets.get(sprite.spritesheet)) |spritesheet| { if (self.textures.get(spritesheet.texture)) |texture| { if (getSpriteUVRect(self.error_sprite)) |uv| { quad.setUVRect(uv); quad.setColor(.white); view = texture.view; } } } } } if (sg.queryViewState(view) != .VALID and sg.queryViewState(view) != .ALLOC) { log.warn("Failed to bind view", .{}); return; } const view_state = sg.queryViewState(view); if (view_state == .VALID or view_state == .ALLOC) { const bound_view = getBoundView(); if (bound_view.id != view.id) { flush(); bound_view.* = view; } } else { log.warn("Attempt to bind non-valid view: state={}", .{view_state}); return; } const sampler = switch (opts.sampler orelse self.default_sampler) { .linear => self.linear_sampler, .nearest => self.nearest_sampler, }; const bound_sampler = &self.bindings.samplers[shd.SMP_smp]; if (bound_sampler.id != sampler.id) { flush(); bound_sampler.* = sampler; } if (self.frame.quads.items.len == self.frame.quads.capacity) { flush(); } if (getTransformPtr()) |transform| { for (&quad.vertices) |*vertex| { vertex.position = transform.apply(vertex.position); } } self.frame.quads.appendAssumeCapacity(quad); } pub const SpriteOptions = struct { spritesheet: ?Spritesheet.Id = null, padding: u32 = 0, }; pub fn initSprite(opts: SpriteOptions) Sprite.Id { const self = &g_state; const id = self.sprites.insertUndefined() catch { log.warn("Failed to create sprite, limit reached! limit: {}", .{self.sprites.slots.capacity}); return .nil; }; const sprite = self.sprites.getAssumeExists(id); sprite.* = .{ .arena = .init(self.gpa), .spritesheet = opts.spritesheet orelse self.default_spritesheet, .padding = opts.padding, .mipchain = null, .position = null, }; return id; } pub fn deinitSprite(id: Sprite.Id) void { const self = &g_state; if (id == Sprite.Id.nil) { return; } if (self.sprites.get(id)) |sprite| { sprite.arena.deinit(); self.sprites.removeAssumeExists(id); if (self.spritesheets.get(sprite.spritesheet)) |spritesheet| { spritesheet.needs_repack = true; } } } const SetSpriteOptions = struct { mipchain: Mipchain, rect: ?Rect = null, premultiplied: bool = false }; pub fn setSprite(id: Sprite.Id, opts: SetSpriteOptions) void { const self = &g_state; const sprite = self.sprites.get(id) orelse { log.warn("Attempt to set sprite that doesn't exist: {}", .{id}); return; }; const spritesheet = self.spritesheets.get(sprite.spritesheet) orelse { log.warn("Attempt to set sprite which doesn't have spritesheet", .{}); return; }; if (opts.mipchain.format != spritesheet.format) { log.warn("Attempt to use image of format '{}' on a spritesheet with '{}' format", .{opts.mipchain.format, spritesheet.format}); return; } if (opts.mipchain.length() != spritesheet.mipmaps) { log.warn("Attempt to use image with '{}' mipmaps on a spritesheet with '{}' mipmaps", .{opts.mipchain.length(), spritesheet.mipmaps}); return; } var next_mipchain: Mipchain = .none; var next_arena = std.heap.ArenaAllocator.init(self.gpa); errdefer next_arena.deinit(); if (opts.rect) |rect| { const image_opts = Image.InitOptions{ .width = @trunc(rect.size.x), .height = @trunc(rect.size.y), .format = opts.mipchain.format }; next_mipchain = Mipchain.initAlloc(next_arena.allocator(), image_opts, opts.mipchain.length()) catch |e| { log.warn("Failed to create texture data: {}", .{e}); return; }; next_mipchain.copyFromRect(opts.mipchain, 0, 0, .initTrunc(rect)); } else { next_mipchain = opts.mipchain.clone(next_arena.allocator()) catch |e| { log.warn("Failed to clone mipchain: {}", .{e}); return; }; } if (!opts.premultiplied) { next_mipchain.premultiplyAlpha(); } var prev_size: ?Vec2 = null; if (sprite.mipchain) |prev_mipchain| { prev_size = Vec2.initFromInt(u32, prev_mipchain.width, prev_mipchain.height); } sprite.arena.deinit(); sprite.mipchain = next_mipchain; sprite.arena = next_arena; const new_size = Vec2.initFromInt(u32, next_mipchain.width, next_mipchain.height); log.debug("set sprite on {f}", .{sprite.spritesheet}); if (prev_size == null or !prev_size.?.eql(new_size) or sprite.position == null) { sprite.position = null; spritesheet.needs_repack = true; } spritesheet.needs_texture_rebuild = true; } // WARNING: This will change the UV coordinates of all sprites. // It's only safe to do this operation between frames where it's guarenteed that sprites aren't being used. fn repackSpritesheet(id: Spritesheet.Id) void { const self = &g_state; const spritesheet = self.spritesheets.get(id) orelse { log.warn("Attempt to repack spritesheet which doesn't exist", .{}); return; }; log.debug("[{}] Repack spritesheet: {f}", .{self.frame.index, id}); // TODO: Add a smarter startegy for picking the initial texture size. // One idea is to calculate the sum area of all sprites and pick a atlas size based on that. const texture_sizes = [_]u32{ 256, 512, 1024, 2048, 4096 }; var sprites_buffer: [Sprite.max_sprites]*Sprite = undefined; var sprites = std.ArrayList(*Sprite).initBuffer(&sprites_buffer); var sprite_iter = self.sprites.iterator(); while (sprite_iter.next()) |sprite_id| { const sprite = self.sprites.getAssumeExists(sprite_id); if (sprite.mipchain != null and sprite.spritesheet == id) { sprites.appendAssumeCapacity(sprite); } } var rects: [Sprite.max_sprites]STBRectPack.Rect = undefined; @memset(&rects, .{}); for (0.., sprites.items) |i, sprite| { const sprite_mipchain = sprite.mipchain.?; const padding = sprite.padding << @intCast(sprite_mipchain.length()-1); rects[i] = .{ .w = @intCast(sprite_mipchain.width + padding * 2), .h = @intCast(sprite_mipchain.height + padding * 2), }; } spritesheet.packer = .{}; const packer = &spritesheet.packer.?; for (&texture_sizes) |texture_size| { packer.init(texture_size, texture_size, &spritesheet.nodes); packer.setupAllowOutOfMem(true); if (!packer.packRects(&rects)) { continue; } for (0.., sprites.items) |i, sprite| { assert(rects[i].was_packed == 1); const padding = sprite.padding << @intCast(sprite.mipchain.?.length()-1); const x: u32 = @as(u32, @intCast(rects[i].x)) + padding; const y: u32 = @as(u32, @intCast(rects[i].y)) + padding; sprite.position = .initFromInt(u32, x, y); } spritesheet.size = .initFromInt(u32, texture_size, texture_size); spritesheet.needs_texture_rebuild = true; spritesheet.needs_repack = false; return; } const max_texture_size = texture_sizes[texture_sizes.len-1]; log.err("Failed to pack sprites, couldn't create a texture that is large enough. Max size tried: {}x{}", .{max_texture_size, max_texture_size}); return; } fn repackSpritesheetIfNeeded(id: Spritesheet.Id) void { const self = &g_state; const spritesheet = self.spritesheets.get(id) orelse { log.warn("Attempt to pack spritesheet that doesn't exist: {f}", .{id}); return; }; if (!spritesheet.needs_repack) { return; } repackSpritesheet(id); } // TODO: This function is a bit large, there are some easy things that could be moved to separate function. // e.g. extruding padding. fn rebuildSpritesheetTextureIfNeeded(id: Spritesheet.Id) void { // TODO: Allow generating mipmaps // https://github.com/floooh/sokol/issues/102#issuecomment-2926566603 const self = &g_state; const spritesheet = self.spritesheets.get(id) orelse return; if (!spritesheet.needs_texture_rebuild) { return; } log.debug("[{}] Rebuild spritesheet texture: {f}", .{self.frame.index, id}); var mipchain_arena = std.heap.ArenaAllocator.init(self.gpa); defer mipchain_arena.deinit(); const mipchain = Mipchain.initAlloc( mipchain_arena.allocator(), .{ .width = @intFromFloat(spritesheet.size.x), .height = @intFromFloat(spritesheet.size.y), .format = spritesheet.format }, spritesheet.mipmaps ) catch |e| { log.err("Mipchain allocation failed: {}", .{e}); return; }; var sprite_iter = self.sprites.iterator(); while (sprite_iter.next()) |sprite_id| { const sprite = self.sprites.getAssumeExists(sprite_id); if (sprite.spritesheet != id) { continue; } const sprite_mipchain = sprite.mipchain orelse continue; const sprite_position = sprite.position orelse continue; assert(sprite_mipchain.length() == spritesheet.mipmaps); for (0..spritesheet.mipmaps) |index| { const level = mipchain.getLevel(index); const sprite_level = sprite_mipchain.getLevel(index); const padding = sprite.padding << @intCast(sprite_mipchain.length()-1 - index); var x: u32 = @intFromFloat(sprite_position.x); x = x >> @intCast(index); var y: u32 = @intFromFloat(sprite_position.y); y = y >> @intCast(index); level.setRectZero( x - padding, y - padding, sprite_level.width + padding * 2, sprite_level.height + padding * 2 ); level.copyFrom(sprite_level, x, y); // TODO: Make `edge_extrusion` amount configurable var edge_extrusion: u32 = 0; if (padding > 0) { edge_extrusion = 1; } if (edge_extrusion > 0) { level.extrudeEdge( .{ .x = x, .y = y, .width = sprite_level.width, .height = sprite_level.height, }, edge_extrusion ); } } } setTexture(spritesheet.texture, .{ .mipchain = mipchain, .premultiplied = true }); spritesheet.needs_texture_rebuild = false; } fn ensureSpritePacked(id: Sprite.Id) bool { const self = &g_state; const sprite = self.sprites.get(id) orelse return false; if (sprite.position == null) { const spritesheet = self.spritesheets.get(sprite.spritesheet) orelse return false; const sprite_mipchain = sprite.mipchain orelse return false; if (spritesheet.packer) |*packer| { assert(sprite_mipchain.length() > 0); const padding = sprite.padding << @intCast(sprite_mipchain.length()-1); var rects: [1]STBRectPack.Rect = .{ .{ .w = @intCast(sprite_mipchain.width + padding * 2), .h = @intCast(sprite_mipchain.height + padding * 2), } }; if (packer.packRects(&rects)) { // Suboptimal sprite packing, will repack at end of frame assert(rects[0].was_packed == 1); const x: u32 = @as(u32, @intCast(rects[0].x)) + padding; const y: u32 = @as(u32, @intCast(rects[0].y)) + padding; sprite.position = .initFromInt(u32, x, y); spritesheet.needs_repack = true; } else { log.debug("Failed to pack a single sprite to spritesheet", .{}); } } else { repackSpritesheet(sprite.spritesheet); } } return sprite.position != null; } pub fn getSpriteUVRect(id: Sprite.Id) ?Rect { const self = &g_state; const sprite = self.sprites.get(id) orelse return null; const sprite_mipchain = sprite.mipchain orelse return null; const spritesheet = self.spritesheets.get(sprite.spritesheet) orelse return null; if (!ensureSpritePacked(id)) { return null; } assert(sprite.position != null); const sprite_size = Vec2.initFromInt(u32, sprite_mipchain.width, sprite_mipchain.height); return Rect{ .pos = sprite.position.?.divide(spritesheet.size), .size = sprite_size.divide(spritesheet.size), }; } pub fn drawSpriteQuad(id: Sprite.Id, points: Quad, color: Color) void { const self = &g_state; var quad: VertexQuad = undefined; quad.setColor(color); quad.vertices[0].position = points.positions[0]; quad.vertices[1].position = points.positions[1]; quad.vertices[2].position = points.positions[2]; quad.vertices[3].position = points.positions[3]; var texture: ?Texture.Id = null; if (getSpriteUVRect(id)) |sprite_uv| { quad.setUVRect(sprite_uv); const sprite = self.sprites.getAssumeExists(id); const spritesheet = self.spritesheets.getAssumeExists(sprite.spritesheet); texture = spritesheet.texture; } draw(.{ .texture = texture orelse .nil, .quad = quad }); } pub fn drawSprite(id: Sprite.Id, pos: Vec2, size: Vec2, color: Color) void { const rect = Quad.initRect(pos, size); drawSpriteQuad(id, rect, color); } pub const SpriteSheetOptions = struct { mipmaps: u32 = 1, format: Image.Format = .rgba8 }; pub fn initSpritesheet(opts: SpriteSheetOptions) Spritesheet.Id { const self = &g_state; const id = self.spritesheets.insertUndefined() catch { log.warn("Failed to create spritesheet, limit reached! limit: {}", .{self.spritesheets.slots.capacity}); return .nil; }; assert(opts.mipmaps <= sg.max_mipmaps); assert(opts.mipmaps > 0); const spritesheet = self.spritesheets.getAssumeExists(id); spritesheet.* = .{ .texture = initTexture(.{ .dynamic = true }), .size = .init(0, 0), .format = opts.format, .mipmaps = opts.mipmaps, .needs_repack = false, .needs_texture_rebuild = false, .nodes = undefined, .packer = null }; return id; } pub fn deinitSpritesheet(id: Spritesheet.Id) void { const self = &g_state; if (self.spritesheets.get(id)) |spritesheet| { deinitTexture(spritesheet.texture); self.spritesheets.removeAssumeExists(id); } } pub const Image = struct { format: Image.Format, pixels: [*]u8, width: u32, height: u32, pub const Format = enum { rgba8, r8, pub fn pixelSize(self: Image.Format) usize { return switch(self) { .rgba8 => 4, .r8 => 1, }; } pub fn maxPixelSize() usize { var result: usize = 0; for (@typeInfo(Image.Format).@"enum".field_values) |field_value| { const pixel_size = Image.Format.pixelSize(@enumFromInt(field_value)); result = @max(result, pixel_size); } return result; } }; pub const none = Image{ .format = .rgba8, .pixels = &.{}, .width = 0, .height = 0, }; const InitOptions = struct { format: Image.Format, width: u32, height: u32, }; const Rect = struct { x: u32, y: u32, width: u32, height: u32, fn initTrunc(r: Math.Rect) Image.Rect { return .{ .x = @trunc(r.pos.x), .y = @trunc(r.pos.y), .width = @trunc(r.size.x), .height = @trunc(r.size.y), }; } }; pub fn initAlloc(gpa: Allocator, opts: InitOptions) !Image { const pixel_count = opts.width * opts.height; const pixel_size = opts.format.pixelSize(); return Image{ .format = opts.format, .pixels = (try gpa.alloc(u8, pixel_count * pixel_size)).ptr, .width = opts.width, .height = opts.height }; } fn pixelCount(self: Image) usize { return self.width * self.height; } fn pixelsSlice(self: Image) []u8 { return self.pixels[0..(self.pixelCount() * self.format.pixelSize())]; } pub fn clone(self: Image, arena: Allocator) !Image { return Image{ .pixels = (try arena.dupe(u8, self.pixelsSlice())).ptr, .format = self.format, .width = self.width, .height = self.height }; } pub fn deinit(self: Image, gpa: Allocator) void { gpa.free(self.pixelsSlice()); } pub fn getPixel(self: Image, x: usize, y: usize) []u8 { const pixel_index = y * self.width + x; const pixel_size = self.format.pixelSize(); return self.pixels[(pixel_size*pixel_index)..][0..pixel_size]; } pub fn setRect(self: Image, x: u32, y: u32, width: u32, height: u32, pixel: []const u8) void { for (0..height) |oy| { const iy = y + oy; if (iy >= self.height) { continue; } for (0..width) |ox| { const ix = x + ox; if (ix >= self.width) { continue; } const dest_pixel = self.getPixel(@intCast(ix), @intCast(iy)); @memcpy(dest_pixel, pixel); } } } pub fn setRectZero(self: Image, x: u32, y: u32, width: u32, height: u32) void { const zero_pixel_buff: [Image.Format.maxPixelSize()]u8 = @splat(0); const zero_pixel = zero_pixel_buff[0..self.format.pixelSize()]; self.setRect(x, y, width, height, zero_pixel); } pub fn premultiplyAlpha(self: Image) void { if (self.format != .rgba8) return; for (0..self.height) |y| { for (0..self.width) |x| { const pixel_index = y * self.width + x; const rgba8_pixel = self.pixels[(4*pixel_index)..][0..4]; inline for (0..3) |i| { rgba8_pixel[i] = @intCast(@as(u16, rgba8_pixel[i]) * rgba8_pixel[3] / 255); } } } } pub fn copyFromRect( self: Image, source: Image, cx: u32, cy: u32, source_rect: Image.Rect, ) void { assert(source.format == self.format); for (0..source_rect.height) |oy| { const source_y = source_rect.y+oy; const dest_y = cy+oy; if (dest_y >= self.height) { continue; } if (source_y >= source.height) { continue; } for (0..source_rect.width) |ox| { const source_x = source_rect.x+ox; const dest_x = cx+ox; if (dest_x >= self.width) { continue; } if (source_x >= source.width) { continue; } const source_pixel = source.getPixel(@intCast(source_x), @intCast(source_y)); const dest_pixel = self.getPixel(@intCast(dest_x), @intCast(dest_y)); @memcpy(dest_pixel, source_pixel); } } } pub fn copyFrom( self: Image, source: Image, cx: u32, cy: u32, ) void { self.copyFromRect(source, cx, cy, .{ .x = 0, .y = 0, .width = source.width, .height = source.height}); } pub fn extrudeEdge(self: Image, rect: Image.Rect, size: u32) void { for (0..rect.width) |ox| { const pixel = self.getPixel(rect.x+ox, rect.y); for (0..size) |i| { @memcpy(self.getPixel(rect.x+ox, rect.y-(i+1)), pixel); } } for (0..rect.width) |ox| { const pixel = self.getPixel(rect.x+ox, rect.y+rect.height-1); for (0..size) |i| { @memcpy(self.getPixel(rect.x+ox, rect.y+rect.height+i), pixel); } } for (0..rect.height) |oy| { const pixel = self.getPixel(rect.x, rect.y+oy); for (0..size) |i| { @memcpy(self.getPixel(rect.x-(i+1), rect.y+oy), pixel); } } for (0..rect.height) |oy| { const pixel = self.getPixel(rect.x+rect.width-1, rect.y+oy); for (0..size) |i| { @memcpy(self.getPixel(rect.x+rect.width+i, rect.y+oy), pixel); } } // TODO: Add padding to corners. } }; pub const Mipchain = struct { format: Image.Format, level0: [*]u8, // level0 is stored inline, so that we could cast a `Image` into a `Mipchain` without allocations next_levels: [][*]u8, // Each level after `level0` must be half the size width: u32, height: u32, pub const none = Mipchain{ .format = .rgba8, .level0 = &.{}, .next_levels = &.{}, .width = 0, .height = 0, }; pub const InitOptions = struct { format: Image.Format, levels: [][*]u8, width: u32, height: u32, }; pub fn init(opts: InitOptions) Mipchain { var mipchain: Mipchain = .{ .format = opts.format, .level0 = &.{}, .next_levels = &.{}, .width = opts.width, .height = opts.height, }; if (opts.levels.len > 0) { mipchain.level0 = opts.levels[0]; mipchain.next_levels = opts.levels[1..]; } return mipchain; } pub fn initSingle(image: Image) Mipchain { return Mipchain{ .format = image.format, .level0 = image.pixels, .next_levels = &.{}, .width = image.width, .height = image.height, }; } pub fn clone(self: Mipchain, arena: Allocator) !Mipchain { var mipchain = Mipchain{ .format = self.format, .width = self.width, .height = self.height, .level0 = &.{}, .next_levels = &.{} }; if (self.length() >= 2) { mipchain.next_levels = try arena.alloc([*]u8, self.length()-1); } for (0..mipchain.length()) |index| { var level = self.getLevel(index); const pixels = try arena.dupe(u8, level.pixelsSlice()); mipchain.getLevelPixelsPtr(index).* = pixels.ptr; } return mipchain; } pub fn initAlloc(arena: Allocator, image_opts: Image.InitOptions, levels: usize) !Mipchain { var mipchain = Mipchain{ .width = image_opts.width, .height = image_opts.height, .format = image_opts.format, .level0 = &.{}, .next_levels = &.{} }; if (levels >= 2) { mipchain.next_levels = try arena.alloc([*]u8, levels-1); } for (0..mipchain.length()) |index| { var level = mipchain.getLevel(index); const pixels = try arena.alloc(u8, level.pixelCount() * level.format.pixelSize()); mipchain.getLevelPixelsPtr(index).* = pixels.ptr; } assert(mipchain.length() == levels); return mipchain; } pub fn length(self: Mipchain) usize { if (self.width == 0 or self.height == 0) { return 0; } return 1 + self.next_levels.len; } pub fn getLevelPixels(self: Mipchain, index: usize) [*]u8 { if (index == 0) { return self.level0; } else { return self.next_levels[index-1]; } } // TODO: Don't like that this is redundant with `getLevelPixels` pub fn getLevelPixelsPtr(self: *Mipchain, index: usize) *[*]u8 { if (index == 0) { return &self.level0; } else { return &self.next_levels[index-1]; } } pub fn getLevel(self: Mipchain, index: usize) Image { return Image{ .format = self.format, .pixels = self.getLevelPixels(index), .width = @max(self.width >> @intCast(index), 1), .height = @max(self.height >> @intCast(index), 1), }; } pub fn copyFromRect( self: Mipchain, source: Mipchain, cx: u32, cy: u32, source_rect: Image.Rect, ) void { assert(self.length() == source.length()); for (0..self.length()) |index| { const self_level = self.getLevel(index); const source_level = source.getLevel(index); self_level.copyFromRect( source_level, cx >> @intCast(index), cy >> @intCast(index), .{ .x = source_rect.x >> @intCast(index), .y = source_rect.y >> @intCast(index), .width = source_rect.width >> @intCast(index), .height = source_rect.height >> @intCast(index), } ); } } pub fn premultiplyAlpha(self: Mipchain) void { for (0..self.length()) |index| { self.getLevel(index).premultiplyAlpha(); } } }; pub const Texture = struct { dynamic: bool, updated_at: ?u64, image: sg.Image, view: sg.View, const max_textures = 64; const SlotMap = SlotMapType(std.math.IntFittingRange(0, max_textures-1), u8, Texture); pub const Id = SlotMap.Id; }; pub const Sprite = struct { arena: std.heap.ArenaAllocator, spritesheet: Spritesheet.Id, mipchain: ?Mipchain, position: ?Vec2, padding: u32, const max_sprites = 256; const SlotMap = SlotMapType(std.math.IntFittingRange(0, max_sprites-1), u8, Sprite); pub const Id = SlotMap.Id; }; pub const Spritesheet = struct { texture: Texture.Id, format: Image.Format, size: Vec2, mipmaps: u32, needs_repack: bool, needs_texture_rebuild: bool, packer: ?STBRectPack, nodes: [256]STBRectPack.Node, const max_spritesheets = 64; const SlotMap = SlotMapType(std.math.IntFittingRange(0, max_spritesheets-1), u8, Spritesheet); pub const Id = SlotMap.Id; }; pub const Vertex = extern struct { position: Vec2, texcoord: Vec2, color: Vec4, }; pub const VertexQuad = extern struct { vertices: [4]Vertex, pub fn init(vertices: [4]Vertex) VertexQuad { return VertexQuad{ .vertices = vertices }; } pub fn setColor(self: *VertexQuad, color: Color) void { for (&self.vertices) |*vertex| { vertex.color = Vec4{ .x = color.r, .y = color.g, .z = color.b, .w = color.a, }; } } pub fn setUVRect(self: *VertexQuad, rect: Rect) void { const pos = rect.pos; const size = rect.size; self.vertices[0].texcoord = pos; self.vertices[1].texcoord = pos.add(.init(size.x, 0)); self.vertices[2].texcoord = pos.add(.init(0, size.y)); self.vertices[3].texcoord = pos.add(size); } pub fn setRect(self: *VertexQuad, rect: Rect) void { const pos = rect.pos; const size = rect.size; self.vertices[0].position = pos; self.vertices[1].position = pos.add(.init(size.x, 0)); self.vertices[2].position = pos.add(.init(0, size.y)); self.vertices[3].position = pos.add(size); } }; pub const Quad = struct { positions: [4]Vec2, pub fn init(positions: [4]Vec2) Quad { return Quad{ .positions = positions }; } pub fn initRect(pos: Vec2, size: Vec2) Quad { return Quad{ .positions = .{ pos, pos.add(.init(size.x, 0)), pos.add(.init(0, size.y)), pos.add(size), } }; } pub fn applyRotate(self: *Quad, rad: f32, origin: Vec2) void { for (&self.positions) |*pos| { pos.* = pos.sub(origin).rotate(rad).add(origin); } } pub fn applyOffset(self: *Quad, vec2: Vec2) void { for (&self.positions) |*pos| { pos.* = pos.add(vec2); } } }; pub const TransformFrame = struct { offset: Vec2, scale: Vec2, pub fn apply(self: TransformFrame, position: Vec2) Vec2 { return position.multiply(self.scale).add(self.offset); } };