//! Module for loading persistent materials, which are all stored in a single //! storage buffer in VRAM. //! //! This module is intended to be initialized once and to persist until the end //! of the whole program's runtime. Trying to use it in any other way will //! result in weird behavior. const Materials = @This(); const std = @import("std"); const ctx = @import("../AppContext.zig"); const media = @import("media"); const shaders = @import("../shaders.zig"); const vk = @import("vulkan"); const Atom = @import("../engine/Atom.zig").Atom; const Engine = @import("../engine/Engine.zig"); const Textures = @import("Textures.zig"); pub const Id = enum(u16) { // VOLATILE When modifying the list of explicitly defined material IDs (i.e. // any explicit enum value), we need to update `Materials.init` // implementation. /// A material ID that can be used as a "null" material. An object with this /// material ID must not be rendered. If this material is rendered anyway, /// an appropriate "error"-looking material will be used. empty, _, /// 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_materials); 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_materials) 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 material. filename: Atom, }; /// Maps a key value to a material ID. Preallocated with `allocator_persistent`. map: std.AutoHashMapUnmanaged(Key, Id), /// Stores all material data in a single contiguous storage buffer. Use the /// material ID as an index into this buffer. material_buffer: shaders.MaterialBuffer, /// The amount of materials currently loaded. material_count: usize, /// With `@sizeOf(Material) == 52` and `max_materials == 4096`, the material /// storage buffer should take 208 kiB in VRAM. pub const max_materials = 4096; pub fn init() !*Materials { const allocator_persistent = ctx.allocator_persistent; const materials = try allocator_persistent.create(Materials); materials.* = .{ .map = .empty, .material_buffer = undefined, .material_count = 0, }; try materials.map.ensureTotalCapacity(allocator_persistent, max_materials); materials.material_buffer = try .init(.{ .usage = .storage, .target_queue = .graphics, .array_capacity = max_materials, .name = "Materials", }); errdefer materials.material_buffer.deinit(); // VOLATILE Synchronize with explicit values on top of `Id` type. try materials.material_buffer.write(.{ .element_offset = Id.empty.toInt(), .elements = &.{ .{ .base_color = .zero, .emissive = .init(1, 0, 1), .ior = 1.45, .metallic = 0, .normal_scale = 1, .occlusion_texture_strength = 1, .roughness = 1, .base_color_texture = .empty_base_color, .emissive_texture = .empty_emissive, .normal_texture = .empty_normal, .occlusion_roughness_metallic_texture = .empty_occlusion_roughness_metallic, }, }, }); materials.material_count += 1; std.debug.assert(materials.material_count == @typeInfo(Id).@"enum".fields.len); return materials; } pub fn deinit(self: *Materials) void { std.log.scoped(.deinit).debug("Deinitializing {*}", .{self}); self.material_buffer.deinit(); self.* = undefined; } /// Get the ID of a material given its filename (as a string). Returns `null` if /// such material hasn't been loaded. Returns `.empty` when the filename is /// `null`. pub fn get(self: *const Materials, maybe_filename: ?[]const u8) ?Id { if (maybe_filename) |filename| { return self.map.get(.{ // If the atom doesn't exist, then the material cannot possibly exist. .filename = .fromStringIfExists(filename) orelse return null, }); } else { return .empty; } } /// Get the ID of a material given its filename (as an atom). Returns `null` if /// such material hasn't been loaded. Returns `.empty` when the filename is /// `.empty`. pub fn getAtom(self: *const Materials, filename: Atom) ?Id { if (filename != .empty) { return self.map.get(.{ .filename = filename, }); } else { return .empty; } } /// Get the ID of a material given its filename (as a string). Returns either an /// existing material ID or loads a new material along with its textures and /// assigns a new ID, if necessary. Will not return any error if the material /// already exists. Returns `.empty` when the filename is `null`. /// /// When a material or its textures are being loaded, `temp_allocator` is used /// for temporary allocations necessary to perform all operations. No memory /// allocated with `temp_allocator` is retained, so the allocator can be /// deinitialized or reset after this function returns. Note that during loading /// the engine will make its own persistent allocations, so an out of memory /// error is not necessarily related to `temp_allocator`. pub fn getOrLoad(self: *Materials, maybe_filename: ?[]const u8) !Id { if (maybe_filename) |filename| { const key: Key = .{ // If the material already exists, then the atom must exist and the // following line will not return any error. .filename = try .fromString(filename), }; // We don't use `getOrPutAssumeCapacity` method, because we might already be // at full capacity, in which case we should return `error.OutOfMaterials`. if (self.map.get(key)) |id| { return id; } else { const id = Id.fromIndexSafe(self.material_count) catch |err| switch (err) { error.Overflow => return error.OutOfMaterials, }; try self.loadMaterial(filename, id.toInt()); self.map.putAssumeCapacityNoClobber(key, id); self.material_count += 1; return id; } } else { return .empty; } } /// Get the ID of a material given its filename (as an atom). Returns either an /// existing material ID or loads a new material along with its textures and /// assigns a new ID, if necessary. Will not return any error if the material /// already exists. Returns `.empty` when the filename is `.empty`. /// /// When a material or its textures are being loaded, `temp_allocator` is used /// for temporary allocations necessary to perform all operations. No memory /// allocated with `temp_allocator` is retained, so the allocator can be /// deinitialized or reset after this function returns. Note that during loading /// the engine will make its own persistent allocations, so an out of memory /// error is not necessarily related to `temp_allocator`. pub fn getOrLoadAtom(self: *Materials, filename: Atom) !Id { if (filename != .empty) { const key: Key = .{ .filename = filename, }; // We don't use `getOrPutAssumeCapacity` method, because we might already be // at full capacity, in which case we should return `error.OutOfMaterials`. if (self.map.get(key)) |id| { return id; } else { const id = Id.fromIndexSafe(self.material_count) catch |err| switch (err) { error.Overflow => return error.OutOfMaterials, }; try self.loadMaterial(filename.toString(), id.toInt()); self.map.putAssumeCapacityNoClobber(key, id); self.material_count += 1; return id; } } else { return .empty; } } /// Scan the materials directory and load all materials that haven't been loaded /// already. Will not traverse subdirectories. Any errors will be skipped and /// logged. /// /// When a material or its textures are being loaded, `temp_allocator` is used /// for temporary allocations necessary to perform all operations. No memory /// allocated with `temp_allocator` is retained, so the allocator can be /// deinitialized or reset after this function returns. Note that during loading /// the engine will make its own persistent allocations, so an out of memory /// error is not necessarily related to `temp_allocator`. pub fn loadAll(self: *Materials) void { const io = ctx.io; const cwd = std.Io.Dir.cwd(); var dir = cwd.openDir(io, "assets/materials", .{ .iterate = true }) catch |err| { std.log.err("Error while opening metarials directory: {s}", .{@errorName(err)}); return; }; defer dir.close(io); var it = dir.iterate(); while (it.next() catch |err| { std.log.err("Error while iterating over materials directory: {s}", .{@errorName(err)}); return; }) |entry| { if (entry.kind != .file) { std.log.warn("Skipping material entry {s}, which is not a file", .{entry.name}); continue; } _ = self.getOrLoad(entry.name) catch |err| { std.log.err("Error while loading material entry {s}: {s}", .{ entry.name, @errorName(err) }); }; } } fn loadMaterial(self: *Materials, filename: []const u8, index: u32) !void { const allocator_frame = ctx.allocator_frame; const io = ctx.io; const textures = ctx.textures; const MaterialJson = struct { baseColor: [3]f32 = .{ 1, 1, 1 }, baseColorTexture: ?[]const u8 = null, emissive: [3]f32 = .{ 0, 0, 0 }, emissiveTexture: ?[]const u8 = null, ior: f32 = 1.45, metallic: f32 = 1, normalScale: f32 = 1, normalTexture: ?[]const u8 = null, occlusionRoughnessMetallicTexture: ?[]const u8 = null, occlusionTextureStrength: f32 = 1, roughness: f32 = 1, }; std.log.debug("Loading material \"{s}\"...", .{filename}); const cwd = std.Io.Dir.cwd(); var dir = try cwd.openDir(io, "assets/materials", .{}); defer dir.close(io); // NOTE Buffer size approximated based on expected JSON structure. var buffer: [512]u8 = undefined; const file = try dir.openFile(io, filename, .{}); defer file.close(io); var file_reader = file.reader(io, &buffer); var json_reader = std.json.Reader.init(allocator_frame, &file_reader.interface); const material_json = try std.json.parseFromTokenSourceLeaky(MaterialJson, allocator_frame, &json_reader, .{ .duplicate_field_behavior = .@"error", .ignore_unknown_fields = false, .allocate = .alloc_if_needed, }); try self.material_buffer.write(.{ .element_offset = index, .elements = &.{ .{ .base_color = .initArray(material_json.baseColor), .emissive = .initArray(material_json.emissive), .ior = material_json.ior, .metallic = material_json.metallic, .normal_scale = material_json.normalScale, .occlusion_texture_strength = material_json.occlusionTextureStrength, .roughness = material_json.roughness, .base_color_texture = try textures.getOrLoad(material_json.baseColorTexture, .base_color), .emissive_texture = try textures.getOrLoad(material_json.emissiveTexture, .emissive), .normal_texture = try textures.getOrLoad(material_json.normalTexture, .normal), .occlusion_roughness_metallic_texture = try textures.getOrLoad(material_json.occlusionRoughnessMetallicTexture, .occlusion_roughness_metallic), }, }, }); }