sokoban-v2/src/platform/graphics.zig
2026-10-02 01:25:47 +03:00

1756 lines
51 KiB
Zig

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);
}
};