| 1 | // Copyright (c) 2024 Lars Pontoppidan. All rights reserved. |
| 2 | // Use of this source code is governed by an MIT license |
| 3 | // that can be found in the LICENSE file. |
| 4 | // |
| 5 | // vshader aids in generating special shader code C headers via sokol-shdc's 'annotated GLSL' format to any |
| 6 | // supported target formats that sokol_gfx supports internally. |
| 7 | // |
| 8 | // vshader bootstraps itself by downloading its own dependencies to a system cache directory on first run. |
| 9 | // |
| 10 | // Please see https://github.com/floooh/sokol-tools/blob/master/docs/sokol-shdc.md#feature-overview |
| 11 | // for a more in-depth overview of the specific tool in use. |
| 12 | // |
| 13 | // The shader language used is, as described on the overview page linked above, an 'annotated GLSL' |
| 14 | // and 'modern GLSL' (v450) shader language format. |
| 15 | import os |
| 16 | import time |
| 17 | import io.util |
| 18 | import flag |
| 19 | import net.http |
| 20 | |
| 21 | const shdc_full_hash = '0d91b038780614a867f2c8eecd7d935d76bcaae3' |
| 22 | const tool_version = '0.0.4' |
| 23 | const tool_description = "Compile shaders in sokol's annotated GLSL format to C headers for use with sokol based apps" |
| 24 | const tool_name = os.file_name(os.executable()) |
| 25 | const cache_dir = os.join_path(os.cache_dir(), 'v', tool_name) |
| 26 | const runtime_os = os.user_os() |
| 27 | const supported_hosts = ['linux', 'macos', 'windows'] |
| 28 | const supported_slangs = [ |
| 29 | 'glsl430', // default desktop OpenGL backend (SOKOL_GLCORE) |
| 30 | 'glsl410', // default macOS desktop OpenGL |
| 31 | 'glsl300es', // OpenGLES3 and WebGL2 (SOKOL_GLES3) |
| 32 | 'hlsl4', // Direct3D11 with HLSL4 (SOKOL_D3D11) |
| 33 | 'hlsl5', // Direct3D11 with HLSL5 (SOKOL_D3D11) |
| 34 | 'metal_macos', // Metal on macOS (SOKOL_METAL) |
| 35 | 'metal_ios', // Metal on iOS devices (SOKOL_METAL) |
| 36 | 'metal_sim', // Metal on iOS simulator (SOKOL_METAL) |
| 37 | 'wgsl', // WebGPU (SOKOL_WGPU) |
| 38 | 'reflection', |
| 39 | ] |
| 40 | const default_slangs = [ |
| 41 | 'glsl410', |
| 42 | 'glsl300es', |
| 43 | // 'hlsl4', and hlsl5 can't be used at the same time |
| 44 | 'hlsl5', |
| 45 | 'metal_macos', |
| 46 | 'metal_ios', |
| 47 | 'metal_sim', |
| 48 | 'wgsl', |
| 49 | ] |
| 50 | |
| 51 | const shdc_version = shdc_full_hash[0..8] |
| 52 | const shdc_urls = { |
| 53 | 'windows': 'https://github.com/floooh/sokol-tools-bin/raw/${shdc_full_hash}/bin/win32/sokol-shdc.exe' |
| 54 | 'macos': 'https://github.com/floooh/sokol-tools-bin/raw/${shdc_full_hash}/bin/osx/sokol-shdc' |
| 55 | 'linux': 'https://github.com/floooh/sokol-tools-bin/raw/${shdc_full_hash}/bin/linux/sokol-shdc' |
| 56 | 'osx_a64': 'https://github.com/floooh/sokol-tools-bin/raw/${shdc_full_hash}/bin/osx_arm64/sokol-shdc' |
| 57 | } |
| 58 | const shdc_version_file = os.join_path(cache_dir, 'sokol-shdc.version') |
| 59 | const shdc_exe = os.join_path(cache_dir, 'sokol-shdc.exe') |
| 60 | |
| 61 | struct Options { |
| 62 | show_help bool |
| 63 | verbose bool |
| 64 | force_update bool |
| 65 | slangs []string |
| 66 | } |
| 67 | |
| 68 | struct CompileOptions { |
| 69 | verbose bool |
| 70 | slangs []string |
| 71 | invoke_path string |
| 72 | } |
| 73 | |
| 74 | fn main() { |
| 75 | if os.args.len == 1 { |
| 76 | println('Usage: ${tool_name} PATH \n${tool_description}\n${tool_name} -h for more help...') |
| 77 | exit(1) |
| 78 | } |
| 79 | mut fp := flag.new_flag_parser(os.args[1..]) |
| 80 | fp.application(tool_name) |
| 81 | fp.version(tool_version) |
| 82 | fp.description(tool_description) |
| 83 | fp.arguments_description('PATH [PATH]...') |
| 84 | fp.skip_executable() |
| 85 | // Collect tool options |
| 86 | opt := Options{ |
| 87 | show_help: fp.bool('help', `h`, false, 'Show this help text.') |
| 88 | force_update: fp.bool('force-update', `u`, false, 'Force update of the sokol-shdc tool.') |
| 89 | verbose: fp.bool('verbose', `v`, false, 'Be verbose about the tools progress.') |
| 90 | slangs: fp.string_multi('slang', `l`, |
| 91 | 'Shader dialects to generate code for. Default is all.\n Available dialects: ${supported_slangs}') |
| 92 | } |
| 93 | if opt.show_help { |
| 94 | println(fp.usage()) |
| 95 | exit(0) |
| 96 | } |
| 97 | |
| 98 | ensure_external_tools(opt) or { panic(err) } |
| 99 | |
| 100 | input_paths := fp.finalize() or { panic(err) } |
| 101 | |
| 102 | for path in input_paths { |
| 103 | if os.exists(path) { |
| 104 | compile_shaders(opt, path) or { panic(err) } |
| 105 | } |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | // shader_program_name returns the name of the program from `shader_file`. |
| 110 | // shader_program_name returns a blank string if no @program entry could be found. |
| 111 | fn shader_program_name(shader_file string) string { |
| 112 | shader_program := os.read_lines(shader_file) or { return '' } |
| 113 | for line in shader_program { |
| 114 | if line.contains('@program ') { |
| 115 | return line.all_after('@program ').all_before(' ') |
| 116 | } |
| 117 | } |
| 118 | return '' |
| 119 | } |
| 120 | |
| 121 | // validate_shader_file returns an error if `shader_file` isn't valid. |
| 122 | fn validate_shader_file(shader_file string) ! { |
| 123 | shader_program := os.read_lines(shader_file) or { |
| 124 | return error('shader program at "${shader_file}" could not be opened for reading') |
| 125 | } |
| 126 | mut has_program_directive := false |
| 127 | for line in shader_program { |
| 128 | if line.contains('@program ') { |
| 129 | has_program_directive = true |
| 130 | break |
| 131 | } |
| 132 | } |
| 133 | if !has_program_directive { |
| 134 | return error('shader program at "${shader_file}" is missing a "@program" directive.') |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // compile_shaders compiles all `*.glsl` files found in `input_path` |
| 139 | // to their C header file representatives. |
| 140 | fn compile_shaders(opt Options, input_path string) ! { |
| 141 | mut path := os.real_path(input_path) |
| 142 | path = path.trim_right('/') |
| 143 | if os.is_file(path) { |
| 144 | path = os.dir(path) |
| 145 | } |
| 146 | |
| 147 | mut shader_files := []string{} |
| 148 | collect(path, mut shader_files) |
| 149 | |
| 150 | if shader_files.len == 0 { |
| 151 | if opt.verbose { |
| 152 | eprintln('${tool_name} found no shader files to compile for "${path}"') |
| 153 | } |
| 154 | return |
| 155 | } |
| 156 | |
| 157 | for shader_file in shader_files { |
| 158 | // It could be the user has WIP shader files lying around not used, |
| 159 | // so we just report that there's something wrong |
| 160 | validate_shader_file(shader_file) or { |
| 161 | eprintln(err) |
| 162 | continue |
| 163 | } |
| 164 | co := CompileOptions{ |
| 165 | verbose: opt.verbose |
| 166 | slangs: opt.slangs |
| 167 | invoke_path: path |
| 168 | } |
| 169 | // Currently sokol-shdc allows for multiple --input flags |
| 170 | // - but it's only the last entry that's actually compiled/used |
| 171 | // Given this fact - we can only compile one '.glsl' file to one C '.h' header |
| 172 | compile_shader(co, shader_file)! |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | // compile_shader compiles `shader_file` to a C header file. |
| 177 | fn compile_shader(opt CompileOptions, shader_file string) ! { |
| 178 | path := opt.invoke_path |
| 179 | // The output convention, for now, is to use the name of the .glsl file |
| 180 | mut out_file := os.file_name(shader_file).all_before_last('.') + '.h' |
| 181 | out_file = os.join_path(path, out_file) |
| 182 | |
| 183 | mut slangs := opt.slangs.clone() |
| 184 | if opt.slangs.len == 0 { |
| 185 | slangs = default_slangs.clone() |
| 186 | } |
| 187 | |
| 188 | header_name := os.file_name(out_file) |
| 189 | if opt.verbose { |
| 190 | eprintln('${tool_name} generating shader code for ${slangs} in header "${header_name}" in "${path}" from ${shader_file}') |
| 191 | } |
| 192 | |
| 193 | cmd := |
| 194 | '${os.quoted_path(shdc_exe)} --input ${os.quoted_path(shader_file)} --output ${os.quoted_path(out_file)} --slang ' + |
| 195 | os.quoted_path(slangs.join(':')) |
| 196 | if opt.verbose { |
| 197 | eprintln('${tool_name} executing:\n${cmd}') |
| 198 | } |
| 199 | res := os.execute(cmd) |
| 200 | if res.exit_code != 0 { |
| 201 | eprintln('${tool_name} failed generating shader includes:\n ${res.output}\n ${cmd}') |
| 202 | exit(1) |
| 203 | } |
| 204 | if opt.verbose { |
| 205 | program_name := shader_program_name(shader_file) |
| 206 | eprintln('${tool_name} usage example in V:\n\nimport sokol.gfx\n\n#include "${header_name}"\n\nfn C.${program_name}_shader_desc(gfx.Backend) &gfx.ShaderDesc\n') |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | // collect recursively collects `.glsl` file entries from `path` in `list`. |
| 211 | fn collect(path string, mut list []string) { |
| 212 | if !os.is_dir(path) { |
| 213 | return |
| 214 | } |
| 215 | mut files := os.ls(path) or { return } |
| 216 | for file in files { |
| 217 | p := os.join_path(path, file) |
| 218 | if os.is_dir(p) && !os.is_link(p) { |
| 219 | collect(p, mut list) |
| 220 | } else if os.exists(p) { |
| 221 | if os.file_ext(p) == '.glsl' { |
| 222 | list << os.real_path(p) |
| 223 | } |
| 224 | } |
| 225 | } |
| 226 | return |
| 227 | } |
| 228 | |
| 229 | // ensure_external_tools returns nothing if the external |
| 230 | // tools can be setup or is already in place. |
| 231 | fn ensure_external_tools(opt Options) ! { |
| 232 | if !os.exists(cache_dir) { |
| 233 | os.mkdir_all(cache_dir)! |
| 234 | } |
| 235 | if opt.force_update { |
| 236 | download_shdc(opt)! |
| 237 | return |
| 238 | } |
| 239 | |
| 240 | is_shdc_available := os.is_file(shdc_exe) |
| 241 | is_shdc_executable := os.is_executable(shdc_exe) |
| 242 | if opt.verbose { |
| 243 | eprintln('reading version from ${shdc_version_file} ...') |
| 244 | version := os.read_file(shdc_version_file) or { 'unknown' } |
| 245 | eprintln('${tool_name} using sokol-shdc version ${version} at "${shdc_exe}"') |
| 246 | eprintln('executable: ${is_shdc_executable}') |
| 247 | eprintln(' available: ${is_shdc_available}') |
| 248 | if is_shdc_available { |
| 249 | eprintln(' file path: ${shdc_exe}') |
| 250 | eprintln(' file size: ${os.file_size(shdc_exe)}') |
| 251 | eprintln(' file time: ${time.unix_microsecond(os.file_last_mod_unix(shdc_exe), 0)}') |
| 252 | } |
| 253 | } |
| 254 | if is_shdc_available && is_shdc_executable { |
| 255 | return |
| 256 | } |
| 257 | |
| 258 | download_shdc(opt)! |
| 259 | } |
| 260 | |
| 261 | // download_shdc downloads the `sokol-shdc` tool to an OS specific cache directory. |
| 262 | fn download_shdc(opt Options) ! { |
| 263 | // We want to use the same, runtime, OS type as this tool is invoked on. |
| 264 | mut download_url := shdc_urls[runtime_os] or { '' } |
| 265 | $if arm64 && macos { |
| 266 | download_url = shdc_urls['osx_a64'] |
| 267 | } |
| 268 | if download_url == '' { |
| 269 | return error('${tool_name} failed to download an external dependency "sokol-shdc" for ${runtime_os}.\nThe supported host platforms for shader compilation is ${supported_hosts}') |
| 270 | } |
| 271 | if opt.verbose { |
| 272 | eprintln('> reading version from ${shdc_version_file} ...') |
| 273 | } |
| 274 | update_to_shdc_version := os.read_file(shdc_version_file) or { shdc_version } |
| 275 | if opt.verbose { |
| 276 | eprintln('> update_to_shdc_version: ${update_to_shdc_version} | shdc_version: ${shdc_version}') |
| 277 | } |
| 278 | if opt.verbose { |
| 279 | if shdc_version != update_to_shdc_version && os.exists(shdc_exe) { |
| 280 | eprintln('${tool_name} updating sokol-shdc to version ${shdc_version} ...') |
| 281 | } else { |
| 282 | eprintln('${tool_name} installing sokol-shdc version ${update_to_shdc_version} ...') |
| 283 | } |
| 284 | } |
| 285 | if os.exists(shdc_exe) { |
| 286 | os.rm(shdc_exe)! |
| 287 | } |
| 288 | |
| 289 | mut dtmp_file, dtmp_path := util.temp_file(util.TempFileOptions{ path: os.dir(shdc_exe) })! |
| 290 | dtmp_file.close() |
| 291 | if opt.verbose { |
| 292 | eprintln('${tool_name} downloading sokol-shdc from ${download_url}') |
| 293 | } |
| 294 | http.download_file(download_url, dtmp_path) or { |
| 295 | os.rm(dtmp_path)! |
| 296 | return error('${tool_name} failed to download sokol-shdc needed for shader compiling: ${err}') |
| 297 | } |
| 298 | // Make it executable |
| 299 | os.chmod(dtmp_path, 0o775)! |
| 300 | // Move downloaded file in place |
| 301 | os.mv(dtmp_path, shdc_exe)! |
| 302 | // Update internal version file |
| 303 | os.write_file(shdc_version_file, shdc_version)! |
| 304 | } |
| 305 | |