diff --git a/conanfile.py b/conanfile.py index 13f90b3..f508872 100644 --- a/conanfile.py +++ b/conanfile.py @@ -38,6 +38,7 @@ class SDL3CppConan(ConanFile): self.requires("vulkan-memory-allocator/3.3.0") self.requires("spirv-tools/1.4.313.0") self.requires("spirv-headers/1.4.313.0") + self.requires("shaderc/2025.3") self.requires("cpptrace/1.0.4") self.requires("ogg/1.3.5") self.requires("theora/1.1.1") diff --git a/scripts/shader_toolkit.lua b/scripts/shader_toolkit.lua new file mode 100644 index 0000000..088be33 --- /dev/null +++ b/scripts/shader_toolkit.lua @@ -0,0 +1,359 @@ +local shader_toolkit = {} + +local path_sep = package.config:sub(1, 1) +local is_windows = path_sep == "\\" + +local function quote_arg(value) + return string.format("%q", value) +end + +local function execute_command(command) + local ok, exit_type, code = os.execute(command) + if ok == true then + return true + end + if type(ok) == "number" then + return ok == 0 + end + return exit_type == "exit" and code == 0 +end + +local function ensure_directory(path) + if not path or path == "" or path == "." then + return true + end + local command + if is_windows then + command = string.format("mkdir %s >NUL 2>&1", quote_arg(path)) + else + command = string.format("mkdir -p %s >/dev/null 2>&1", quote_arg(path)) + end + return execute_command(command) +end + +local function get_directory(path) + return path:match("^(.*)[/\\\\]") or "" +end + +local function path_join(...) + local parts = {...} + local result = "" + for index, part in ipairs(parts) do + if part and part ~= "" then + if result ~= "" and result:sub(-1) ~= path_sep then + result = result .. path_sep + end + if index > 1 and part:sub(1, 1) == path_sep then + part = part:sub(2) + end + result = result .. part + end + end + return result +end + +local function is_absolute_path(path) + if not path or path == "" then + return false + end + if path:sub(1, 1) == "/" then + return true + end + return is_windows and path:match("^%a:[/\\\\]") ~= nil +end + +local function file_exists(path) + local file = io.open(path, "rb") + if file then + file:close() + return true + end + return false +end + +local function normalize_output_name(name) + if not name or name == "" then + return nil + end + if name:match("%.vert$") then + return name:gsub("%.vert$", "") + end + if name:match("%.frag$") then + return name:gsub("%.frag$", "") + end + return name +end + +local function get_module_directory() + if not debug or not debug.getinfo then + return nil + end + local info = debug.getinfo(1, "S") + local source = info and info.source or "" + if source:sub(1, 1) == "@" then + source = source:sub(2) + end + return get_directory(source) +end + +local function resolve_output_dir(output_dir) + if output_dir and output_dir ~= "" then + return output_dir + end + local shader_dir = "shaders" + local project_root = nil + if type(config) == "table" then + shader_dir = config.shaders_directory or shader_dir + project_root = config.project_root + end + if is_absolute_path(shader_dir) then + return shader_dir + end + + local base_dir = get_module_directory() or "." + if project_root and project_root ~= "" then + if is_absolute_path(project_root) then + base_dir = project_root + else + base_dir = path_join(base_dir, project_root) + end + else + base_dir = path_join(base_dir, "..") + end + return path_join(base_dir, shader_dir) +end + +local function find_in_path(command) + local check_command + if is_windows then + check_command = "where " .. command .. " 2>NUL" + else + check_command = "command -v " .. command .. " 2>/dev/null" + end + local handle = io.popen(check_command) + if not handle then + return false + end + local output = handle:read("*a") or "" + handle:close() + return output ~= "" +end + +local function detect_compiler() + local candidates = {"glslangValidator", "glslc"} + for _, candidate in ipairs(candidates) do + if find_in_path(candidate) then + return candidate + end + end + return nil +end + +local function compile_shader(compiler, input_path, output_path) + local quoted_compiler = quote_arg(compiler) + local quoted_input = quote_arg(input_path) + local quoted_output = quote_arg(output_path) + local command + if compiler:find("glslangValidator") then + command = string.format("%s -V %s -o %s", quoted_compiler, quoted_input, quoted_output) + else + command = string.format("%s %s -o %s", quoted_compiler, quoted_input, quoted_output) + end + if not execute_command(command) then + error("Shader compilation failed: " .. command) + end +end + +local function write_text_file(path, contents) + local directory = get_directory(path) + if directory ~= "" and not ensure_directory(directory) then + error("Failed to create directory: " .. directory) + end + local file = io.open(path, "w") + if not file then + error("Failed to open file for writing: " .. path) + end + file:write(contents) + file:close() +end + +local function normalize_color(color) + if type(color) ~= "table" then + return {1.0, 1.0, 1.0, 1.0} + end + local r = tonumber(color[1]) or 1.0 + local g = tonumber(color[2]) or 1.0 + local b = tonumber(color[3]) or 1.0 + local a = tonumber(color[4]) or 1.0 + return {r, g, b, a} +end + +local push_constants_block = [[ +layout(push_constant) uniform PushConstants { + mat4 model; + mat4 viewProj; + mat4 view; + mat4 proj; + mat4 lightViewProj; + vec3 cameraPos; + float time; + float ambientStrength; + float fogDensity; + float fogStart; + float fogEnd; + vec3 fogColor; + float gamma; + float exposure; + int enableShadows; + int enableFog; +} pushConstants; +]] + +local vertex_color_source = [[ +#version 450 + +layout(location = 0) in vec3 inPos; +layout(location = 1) in vec3 inColor; + +layout(location = 0) out vec3 fragColor; + +]] .. push_constants_block .. [[ + +void main() { + fragColor = inColor; + gl_Position = pushConstants.viewProj * pushConstants.model * vec4(inPos, 1.0); +} +]] + +local vertex_color_fragment_source = [[ +#version 450 + +layout(location = 0) in vec3 fragColor; +layout(location = 0) out vec4 outColor; + +void main() { + outColor = vec4(fragColor, 1.0); +} +]] + +local templates = { + vertex_color = function() + return { + vertex = vertex_color_source, + fragment = vertex_color_fragment_source, + } + end, + solid_color = function(options) + local color = normalize_color(options and options.color) + local fragment = string.format([[ +#version 450 + +layout(location = 0) out vec4 outColor; + +void main() { + outColor = vec4(%.3f, %.3f, %.3f, %.3f); +} +]], color[1], color[2], color[3], color[4]) + return { + vertex = vertex_color_source, + fragment = fragment, + } + end, +} + +shader_toolkit.templates = templates + +function shader_toolkit.register_template(name, generator) + if type(name) ~= "string" or name == "" then + error("Template name must be a non-empty string") + end + if type(generator) ~= "function" then + error("Template generator must be a function") + end + templates[name] = generator +end + +function shader_toolkit.list_templates() + local names = {} + for name in pairs(templates) do + names[#names + 1] = name + end + table.sort(names) + return names +end + +function shader_toolkit.generate_variant(options) + if type(options) ~= "table" then + error("Shader variant options must be a table") + end + local template_name = options.template + if type(template_name) ~= "string" or template_name == "" then + error("Shader variant template must be a non-empty string") + end + local template = templates[template_name] + if not template then + error("Unknown shader template: " .. template_name) + end + local output_name = normalize_output_name(options.output_name or options.key or template_name) + if not output_name then + error("Shader variant requires output_name or key") + end + local output_dir = resolve_output_dir(options.output_dir) + if not ensure_directory(output_dir) then + error("Failed to create shader output directory: " .. output_dir) + end + + local base_name = path_join(output_dir, output_name) + local vertex_source = base_name .. ".vert" + local fragment_source = base_name .. ".frag" + local vertex_spv = vertex_source .. ".spv" + local fragment_spv = fragment_source .. ".spv" + + local sources = template(options) + if type(sources) ~= "table" or not sources.vertex or not sources.fragment then + error("Shader template did not return vertex and fragment source") + end + + write_text_file(vertex_source, sources.vertex) + write_text_file(fragment_source, sources.fragment) + + local compile = options.compile ~= false + if compile then + local compiler = options.compiler or detect_compiler() + if not compiler then + error("No shader compiler found. Install glslangValidator or glslc, or pass options.compiler") + end + local skip_if_present = options.skip_if_present == true + if not (skip_if_present and file_exists(vertex_spv)) then + compile_shader(compiler, vertex_source, vertex_spv) + end + if not (skip_if_present and file_exists(fragment_spv)) then + compile_shader(compiler, fragment_source, fragment_spv) + end + end + + if compile then + return {vertex = vertex_spv, fragment = fragment_spv} + end + return {vertex = vertex_source, fragment = fragment_source} +end + +function shader_toolkit.generate_variants(variant_list) + if type(variant_list) ~= "table" then + error("Shader variants must be a list of tables") + end + local result = {} + for index, options in ipairs(variant_list) do + if type(options) ~= "table" then + error("Shader variant at index " .. index .. " must be a table") + end + local key = options.key + if type(key) ~= "string" or key == "" then + error("Shader variant at index " .. index .. " must include a non-empty key") + end + result[key] = shader_toolkit.generate_variant(options) + end + return result +end + +return shader_toolkit