| // Copyright 2026 the Vello Authors | |
| // SPDX-License-Identifier: Apache-2.0 OR MIT | |
| //! Build-time shader identifier and whitespace minification. | |
| #![allow( | |
| dead_code, | |
| reason = "WGSL minification is used by build.rs; GLSL whitespace minification is used by the library." | |
| )] | |
| use std::collections::BTreeMap; | |
| use naga::{ | |
| Function, Module, TypeInner, | |
| back::wgsl, | |
| front::wgsl as wgsl_front, | |
| valid::{Capabilities, ValidationFlags, Validator}, | |
| }; | |
| /// Minified WGSL together with names needed to preserve the reflection API. | |
| pub(crate) struct MinifiedWgsl { | |
| /// Minified, validated WGSL ready to embed. | |
| pub(crate) source: String, | |
| /// Maps minified WGSL global names back to their authored names. | |
| pub(crate) original_global_names: BTreeMap<String, String>, | |
| } | |
| /// Renames every non-entry-point declaration in linked WGSL and removes redundant whitespace. | |
| /// | |
| /// For example, `fn helper(value: u32)` may become `fn A(B:u32)`, while `vs_main` is preserved. | |
| pub(crate) fn minify_wgsl(source: &str) -> MinifiedWgsl { | |
| let mut module = | |
| wgsl_front::parse_str(source).expect("linked WGSL should parse before minifying"); | |
| let original_global_names = rename_module(&mut module); | |
| let info = validate(&module); | |
| let source = wgsl::write_string(&module, &info, wgsl::WriterFlags::empty()) | |
| .expect("renamed WGSL should serialize"); | |
| MinifiedWgsl { | |
| source: minify_whitespace(&source, false), | |
| original_global_names, | |
| } | |
| } | |
| /// Removes comments and whitespace that are unnecessary between shader tokens. | |
| /// | |
| /// For example, `let x = 1u;` becomes `let x=1u;`; GLSL preprocessor directives remain on | |
| /// separate lines when `preserve_preprocessor_lines` is enabled. | |
| pub(crate) fn minify_whitespace(source: &str, preserve_preprocessor_lines: bool) -> String { | |
| if !preserve_preprocessor_lines { | |
| return minify_code(source); | |
| } | |
| let mut output = String::with_capacity(source.len()); | |
| let mut code = String::new(); | |
| for line in source.lines() { | |
| let trimmed = line.trim(); | |
| if trimmed.starts_with('#') { | |
| append_minified_code(&mut output, &code); | |
| code.clear(); | |
| if !output.is_empty() && !output.ends_with('\n') { | |
| output.push('\n'); | |
| } | |
| output.push_str(trimmed); | |
| output.push('\n'); | |
| } else { | |
| code.push_str(line); | |
| code.push('\n'); | |
| } | |
| } | |
| append_minified_code(&mut output, &code); | |
| output | |
| } | |
| /// Appends minified code without merging tokens across the append boundary. | |
| /// | |
| /// For example, appending `value;` after `return` inserts the required space. | |
| fn append_minified_code(output: &mut String, source: &str) { | |
| let minified = minify_code(source); | |
| if minified.is_empty() { | |
| return; | |
| } | |
| if !output.is_empty() | |
| && !output.ends_with('\n') | |
| && needs_separator(last_char(output), first_char(&minified)) | |
| { | |
| output.push(' '); | |
| } | |
| output.push_str(&minified); | |
| } | |
| /// Assigns short names throughout a module and returns the global-name reverse map. | |
| /// | |
| /// For example, `Config` and global `config` may become `A` and `B`, with `B -> config` recorded. | |
| fn rename_module(module: &mut Module) -> BTreeMap<String, String> { | |
| let mut names = NameAllocator::default(); | |
| let type_handles = module | |
| .types | |
| .iter() | |
| .map(|(handle, _)| handle) | |
| .collect::<Vec<_>>(); | |
| for handle in type_handles { | |
| let original = module.types[handle].clone(); | |
| let mut ty = original.clone(); | |
| rename_optional(&mut ty.name, &mut names); | |
| if let TypeInner::Struct { members, .. } = &mut ty.inner { | |
| for member in members { | |
| rename_optional(&mut member.name, &mut names); | |
| } | |
| } | |
| if ty != original { | |
| module.types.replace(handle, ty); | |
| } | |
| } | |
| for (_, constant) in module.constants.iter_mut() { | |
| rename_optional(&mut constant.name, &mut names); | |
| } | |
| for (_, override_) in module.overrides.iter_mut() { | |
| rename_optional(&mut override_.name, &mut names); | |
| } | |
| let mut original_global_names = BTreeMap::new(); | |
| for (_, global) in module.global_variables.iter_mut() { | |
| if let Some(original) = global.name.take() { | |
| let minified = names.next(); | |
| original_global_names.insert(minified.clone(), original); | |
| global.name = Some(minified); | |
| } | |
| } | |
| for (_, function) in module.functions.iter_mut() { | |
| rename_function(function, &mut names); | |
| } | |
| for entry_point in &mut module.entry_points { | |
| // Entry-point names are part of the renderer's pipeline contract. | |
| rename_function(&mut entry_point.function, &mut names); | |
| } | |
| original_global_names | |
| } | |
| /// Renames a function declaration, its arguments, locals, and named expressions. | |
| /// | |
| /// For example, `fn shade(color)` may become `fn A(B)`, including short names for its local lets. | |
| fn rename_function(function: &mut Function, names: &mut NameAllocator) { | |
| rename_optional(&mut function.name, names); | |
| for argument in &mut function.arguments { | |
| rename_optional(&mut argument.name, names); | |
| } | |
| for (_, local) in function.local_variables.iter_mut() { | |
| rename_optional(&mut local.name, names); | |
| } | |
| for name in function.named_expressions.values_mut() { | |
| *name = names.next(); | |
| } | |
| } | |
| /// Replaces an optional Naga IR declaration name with the next short name, leaving unnamed | |
| /// declarations (`None`) unchanged. | |
| /// | |
| /// For example, `Some("width")` may become `Some("A")`. | |
| fn rename_optional(name: &mut Option<String>, names: &mut NameAllocator) { | |
| if name.is_some() { | |
| *name = Some(names.next()); | |
| } | |
| } | |
| /// Validates the renamed module and produces the metadata required by Naga's writer. | |
| /// | |
| /// This does not change the module; invalid bindings, types, or entry points fail the build. | |
| fn validate(module: &Module) -> naga::valid::ModuleInfo { | |
| Validator::new(ValidationFlags::all(), Capabilities::default()) | |
| .subgroup_stages(naga::valid::ShaderStages::all()) | |
| .subgroup_operations(naga::valid::SubgroupOperationSet::all()) | |
| .validate(module) | |
| .expect("renamed WGSL should validate") | |
| } | |
| /// Deterministically allocates compact uppercase identifiers. | |
| #[derive(Default)] | |
| struct NameAllocator { | |
| next: usize, | |
| } | |
| impl NameAllocator { | |
| /// Returns the next spreadsheet-style name: `A` through `Z`, then `AA`, `AB`, and so on. | |
| fn next(&mut self) -> String { | |
| let mut value = self.next; | |
| self.next += 1; | |
| let mut bytes = Vec::with_capacity(3); | |
| loop { | |
| bytes.push(b'A' + u8::try_from(value % 26).expect("base-26 digit should fit in u8")); | |
| value /= 26; | |
| if value == 0 { | |
| break; | |
| } | |
| value -= 1; | |
| } | |
| bytes.reverse(); | |
| String::from_utf8(bytes).expect("generated identifiers should be ASCII") | |
| } | |
| } | |
| /// Removes comments and optional whitespace while preserving shader token boundaries. | |
| /// | |
| /// For example, `a = b + c; // sum` becomes `a=b+c;`. | |
| fn minify_code(source: &str) -> String { | |
| let chars = source.chars().collect::<Vec<_>>(); | |
| let mut output = String::with_capacity(source.len()); | |
| let mut index = 0; | |
| let mut pending_separator = false; | |
| while index < chars.len() { | |
| let current = chars[index]; | |
| let next = chars.get(index + 1).copied(); | |
| if current.is_whitespace() { | |
| pending_separator = true; | |
| index += 1; | |
| continue; | |
| } | |
| if current == '/' && next == Some('/') { | |
| pending_separator = true; | |
| index += 2; | |
| while index < chars.len() && chars[index] != '\n' { | |
| index += 1; | |
| } | |
| continue; | |
| } | |
| if current == '/' && next == Some('*') { | |
| pending_separator = true; | |
| index += 2; | |
| while index + 1 < chars.len() && !(chars[index] == '*' && chars[index + 1] == '/') { | |
| index += 1; | |
| } | |
| index = (index + 2).min(chars.len()); | |
| continue; | |
| } | |
| if pending_separator && !output.is_empty() && needs_separator(last_char(&output), current) { | |
| output.push(' '); | |
| } | |
| pending_separator = false; | |
| if current == '"' || current == '\'' { | |
| index = copy_quoted(&chars, index, &mut output); | |
| } else { | |
| output.push(current); | |
| index += 1; | |
| } | |
| } | |
| output | |
| } | |
| /// Copies one quoted value verbatim, including escaped characters. | |
| /// | |
| /// For example, `"a // b"` remains unchanged rather than treating `//` as a comment. | |
| fn copy_quoted(chars: &[char], start: usize, output: &mut String) -> usize { | |
| let quote = chars[start]; | |
| let mut index = start; | |
| while index < chars.len() { | |
| let current = chars[index]; | |
| output.push(current); | |
| index += 1; | |
| if current == '\\' && index < chars.len() { | |
| output.push(chars[index]); | |
| index += 1; | |
| } else if current == quote && index > start + 1 { | |
| break; | |
| } | |
| } | |
| index | |
| } | |
| /// Returns the first character of a known non-empty source section. | |
| /// | |
| /// For example, `first_char("abc")` returns `a`. | |
| fn first_char(source: &str) -> char { | |
| source.chars().next().expect("source should not be empty") | |
| } | |
| /// Returns the last character of a known non-empty source section. | |
| /// | |
| /// For example, `last_char("abc")` returns `c`. | |
| fn last_char(source: &str) -> char { | |
| source | |
| .chars() | |
| .next_back() | |
| .expect("source should not be empty") | |
| } | |
| /// Reports whether removing whitespace would merge two adjacent shader tokens. | |
| /// | |
| /// For example, `return` followed by `value` needs a separator, as do `+` and `+`. | |
| fn needs_separator(left: char, right: char) -> bool { | |
| let identifier = |character: char| character.is_ascii_alphanumeric() || character == '_'; | |
| (identifier(left) && identifier(right)) | |
| || matches!( | |
| (left, right), | |
| ('+', '+') | |
| | ('-', '-') | |
| | ('-', '>') | |
| | ('/', '/') | |
| | ('/', '*') | |
| | ('<', '<') | |
| | ('<', '=') | |
| | ('>', '>') | |
| | ('>', '=') | |
| | ('=', '=') | |
| | ('!', '=') | |
| | ('&', '&') | |
| | ('|', '|') | |
| | (':', ':') | |
| ) | |
| || (left.is_ascii_digit() && right == '.') | |
| || (left == '.' && right.is_ascii_digit()) | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| #[test] | |
| fn allocates_short_deterministic_names() { | |
| let mut names = NameAllocator::default(); | |
| assert_eq!(names.next(), "A"); | |
| for _ in 1..26 { | |
| names.next(); | |
| } | |
| assert_eq!(names.next(), "AA"); | |
| assert_eq!(names.next(), "AB"); | |
| } | |
| #[test] | |
| fn minifies_root_and_imported_declarations() { | |
| let source = r#" | |
| struct Configuration { | |
| width: u32, | |
| } | |
| @group(0) @binding(0) | |
| var<uniform> configuration: Configuration; | |
| fn verbose_helper(value: u32) -> u32 { | |
| let incremented = value + 1u; | |
| return incremented; | |
| } | |
| @vertex | |
| fn vs_main(@builtin(vertex_index) vertex_index: u32) -> @builtin(position) vec4<f32> { | |
| let result = verbose_helper(vertex_index) + configuration.width; | |
| return vec4<f32>(f32(result)); | |
| } | |
| "#; | |
| let first = minify_wgsl(source); | |
| let second = minify_wgsl(source); | |
| assert_eq!(first.source, second.source); | |
| assert!(first.source.contains("fn vs_main(")); | |
| assert!(!first.source.contains("Configuration")); | |
| assert!(!first.source.contains("verbose_helper")); | |
| assert!(!first.source.contains("incremented")); | |
| assert!( | |
| first | |
| .original_global_names | |
| .values() | |
| .any(|name| name == "configuration") | |
| ); | |
| wgsl_front::parse_str(&first.source).expect("minified WGSL should parse"); | |
| } | |
| #[test] | |
| fn keeps_required_token_separators() { | |
| assert_eq!( | |
| minify_whitespace( | |
| "let value = left + +right; // comment\nreturn value;", | |
| false | |
| ), | |
| "let value=left+ +right;return value;" | |
| ); | |
| assert_eq!( | |
| minify_whitespace("#version 300 es\n\nprecision highp float;\n", true), | |
| "#version 300 es\nprecision highp float;" | |
| ); | |
| } | |
| } |