//! Module for loading persistent textures. const Textures = @This(); const std = @import("std"); const media = @import("media"); const Atom = @import("../engine/Atom.zig").Atom; const Engine = @import("../engine/Engine.zig"); const Texture = @import("../engine/Texture.zig"); pub const Id = enum(u16) { // VOLATILE Synchronize explicit values with `init` implementation. /// 1×1 texture with usage `.base_color`, whose texel is read as `vec4(1)`. empty_base_color = 0, /// 1×1 texture with usage `.emissive`, whose texel is read as `vec4(1)`. empty_emissive = 1, /// 1×1 texture with usage `.normal`, whose texel is read as /// `vec4(0, 0, 1, 1)`. empty_normal = 2, /// 1×1 texture with usage `,occlusion_roughness_metallic`, whose texel is /// read as `vec4(1)`. empty_occlusion_roughness_metallic = 3, _, /// Cast an integer into an ID. This can produce an invalid ID. pub fn fromInt(value: u16) Id { return @enumFromInt(value); } /// Cast an index into an ID. This can produce an invalid ID. The caller /// asserts that the index is not greater than the max ID value. pub fn fromIndex(index: usize) Id { std.debug.assert(index < max_textures); return @enumFromInt(@as(u16, @intCast(index))); } /// Cast an index into an ID. This can produce an invalid ID. Returns an /// error if the index is greater than the max ID value. pub fn fromIndexSafe(index: usize) error{Overflow}!Id { if (index >= max_textures) return error.Overflow; return @enumFromInt(@as(u16, @intCast(index))); } /// Cast an ID into an integer. pub fn toInt(self: Id) u16 { return @intFromEnum(self); } }; pub const Key = struct { /// Atom representing the filename of the texture. filename: Atom, /// Desired usage of the texture. usage: Texture.Usage, }; /// Maps a key value to a texture ID. map: std.AutoHashMapUnmanaged(Key, Id), /// Stores all `Texture` structs and maps a texture ID to a `Texture` struct. array: std.ArrayList(Texture), /// 4096 textures of usage `.base_color` and 16×16 dimensions should take 4 MiB /// in VRAM. pub const max_textures = 4096; pub fn init(engine: *Engine, allocator: std.mem.Allocator) !Textures { var map: std.AutoHashMapUnmanaged(Key, Id) = .empty; errdefer map.deinit(allocator); try map.ensureTotalCapacity(allocator, max_textures); var array: std.ArrayList(Texture) = try .initCapacity(allocator, max_textures); errdefer { for (array.items) |*texture| { texture.deinit(engine); } array.deinit(allocator); } // VOLATILE Synchronize with explicit values on top of `Id` type. const empty_base_color_texture = try Texture.init(engine, .{ .width = 1, .height = 1, .usage = .base_color, .target_queue = .graphics, .name = "@Empty", }); array.appendAssumeCapacity(empty_base_color_texture); const empty_emissive_texture = try Texture.init(engine, .{ .width = 1, .height = 1, .usage = .emissive, .target_queue = .graphics, .name = "@Empty", }); array.appendAssumeCapacity(empty_emissive_texture); const empty_normal_texture = try Texture.init(engine, .{ .width = 1, .height = 1, .usage = .normal, .target_queue = .graphics, .name = "@Empty", }); array.appendAssumeCapacity(empty_normal_texture); const empty_occlusuion_roughness_metallic_texture = try Texture.init(engine, .{ .width = 1, .height = 1, .usage = .occlusion_roughness_metallic, .target_queue = .graphics, .name = "@Empty", }); array.appendAssumeCapacity(empty_occlusuion_roughness_metallic_texture); try empty_base_color_texture.writeSamples(u8, engine, &.{ 255, 255, 255, 255 }); try empty_emissive_texture.writeSamples(f16, engine, &.{ 1.0, 1.0, 1.0, 1.0 }); try empty_normal_texture.writeSamples(i8, engine, &.{ 0, 0, 127, 127 }); try empty_occlusuion_roughness_metallic_texture.writeSamples(u8, engine, &.{ 255, 255, 255, 255 }); return .{ .map = map, .array = array, }; } pub fn deinit(self: *Textures, engine: *Engine, allocator: std.mem.Allocator) void { std.log.scoped(.deinit).debug("Deinitializing {*} with {*} and Allocator{{{*},{*}}}", .{ self, engine, allocator.ptr, allocator.vtable }); for (self.array.items) |*texture| { texture.deinit(engine); } self.array.deinit(allocator); self.map.deinit(allocator); self.* = undefined; } /// Get the ID of a texture given its filename (as a string) and usage. Returns /// `null` if such texture hasn't been loaded. When the filename is `null`, /// returns an empty texture ID appropriate for given usage. pub fn get(self: *const Textures, maybe_filename: ?[]const u8, usage: Texture.Usage) ?Id { if (maybe_filename) |filename| { return self.map.get(.{ // If the atom doesn't exist, then the texture cannot possibly exist. .filename = .fromStringIfExists(filename) orelse return null, .usage = usage, }); } else { return emptyTextureForUsage(usage); } } /// Get the ID of a texture given its filename (as an atom) and usage. Returns /// `null` if such texture hasn't been loaded. When the filename is `.empty`, /// returns an empty texture ID appropriate for given usage. pub fn getAtom(self: *const Textures, filename: Atom, usage: Texture.Usage) ?Id { if (filename != .empty) { return self.map.get(.{ .filename = filename, .usage = usage, }); } else { return emptyTextureForUsage(usage); } } /// Get the ID of a texture given its filename (as a string) and usage. Returns /// either an existing texture ID or loads a new texture and assigns a new ID, /// if necessary. Will not return any error if the texture already exists. When /// the filename is `null`, returns an empty texture ID appropriate for given /// usage. pub fn getOrLoad( self: *Textures, engine: *Engine, stbi: *media.stbi, maybe_filename: ?[]const u8, usage: Texture.Usage, io: std.Io, ) !Id { if (maybe_filename) |filename| { const key: Key = .{ // If the texture already exists, then the atom must exist and the // following line will not return any error. .filename = try .fromString(filename, io), .usage = usage, }; // We don't use `getOrPutAssumeCapacity` method, because we might already be // at full capacity, in which case we should return `error.OutOfTextures`. if (self.map.get(key)) |id| { return id; } else { const id = Id.fromIndexSafe(self.array.items.len) catch |err| switch (err) { error.Overflow => return error.OutOfTextures, }; const texture = try loadTexture(engine, stbi, filename, usage, io); self.map.putAssumeCapacityNoClobber(key, id); self.array.appendAssumeCapacity(texture); return id; } } else { return emptyTextureForUsage(usage); } } /// Get the ID of a texture given its filename (as an atom) and usage. Returns /// either an existing texture ID or loads a new texture and assigns a new ID, /// if necessary. Will not return any error if the texture already exists. When /// the filename is `.empty`, returns an empty texture ID appropriate for given /// usage. pub fn getOrLoadAtom( self: *Textures, engine: *Engine, stbi: *media.stbi, filename: Atom, usage: Texture.Usage, io: std.Io, ) !Id { if (filename != .empty) { const key: Key = .{ .filename = filename, .usage = usage, }; // We don't use `getOrPutAssumeCapacity` method, because we might already be // at full capacity, in which case we should return `error.OutOfTextures`. if (self.map.get(key)) |id| { return id; } else { const id = Id.fromIndexSafe(self.array.items.len) catch |err| switch (err) { error.Overflow => return error.OutOfTextures, }; const texture = try loadTexture(engine, stbi, filename.toString(), usage, io); self.map.putAssumeCapacityNoClobber(key, id); self.array.appendAssumeCapacity(texture); return id; } } else { return emptyTextureForUsage(usage); } } fn loadTexture( engine: *Engine, stbi: *media.stbi, filename: []const u8, usage: Texture.Usage, io: std.Io, ) !Texture { std.log.debug("Loading texture \"{s}\" as {s}...", .{ filename, @tagName(usage) }); const cwd = std.Io.Dir.cwd(); var dir = try cwd.openDir(io, "assets/textures", .{}); defer dir.close(io); var file = try dir.openFile(io, filename, .{}); defer file.close(io); // The textures are expected to be small; a standard block base color as a // PNG takes well below 1 kiB. var buf: [4096]u8 = undefined; var reader = file.reader(io, &buf); const img = try stbi.loadDynamicIo(&reader.interface); defer stbi.freeDynamic(img); const data = img.data[0 .. img.width * img.height]; if (usage == .normal) { for (data) |*pixel| { pixel.r = pixel.r +% 128; pixel.g = pixel.g +% 128; pixel.b = pixel.b +% 128; pixel.a = pixel.a +% 128; } } var texture = try Texture.init(engine, .{ .width = img.width, .height = img.height, .usage = usage, .target_queue = .graphics, .name = filename, }); errdefer texture.deinit(engine); try texture.writeRaw(engine, @ptrCast(data)); return texture; } fn emptyTextureForUsage(usage: Texture.Usage) !Id { return switch (usage) { .base_color => .empty_base_color, .normal => .empty_normal, .occlusion_roughness_metallic => .empty_occlusion_roughness_metallic, .emissive => .empty_emissive, .depth => error.InvalidUsage, }; }