diff --git a/Cargo.lock b/Cargo.lock index 7586c5d11..ee2312561 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1444,6 +1444,7 @@ dependencies = [ "bytemuck", "naga", "serde", + "sha2", "toml", "zip", ] diff --git a/crates/horizon-shader-api/Cargo.toml b/crates/horizon-shader-api/Cargo.toml index 249019042..caab5c736 100644 --- a/crates/horizon-shader-api/Cargo.toml +++ b/crates/horizon-shader-api/Cargo.toml @@ -8,12 +8,12 @@ description = "Horizon's own shader packs (WGSL, docs/api/shaders.md): horizon-s [dependencies] bytemuck = { version = "1.24", features = ["derive"] } +# Parses and validates a pack's WGSL, and translates it as wgpu does for Vulkan, Metal and DirectX 12. +naga = { version = "30.0.1", features = ["wgsl-in", "spv-out", "msl-out", "hlsl-out"] } serde = { version = "1", features = ["derive"] } +sha2 = "0.10" toml = "1.1.6" zip = { version = "2", default-features = false, features = ["deflate"] } -[dev-dependencies] -naga = { version = "30.0.1", features = ["wgsl-in"] } - [lints] workspace = true diff --git a/crates/horizon-shader-api/src/check.rs b/crates/horizon-shader-api/src/check.rs new file mode 100644 index 000000000..935dccc13 --- /dev/null +++ b/crates/horizon-shader-api/src/check.rs @@ -0,0 +1,495 @@ +//! The check a pack passes before it runs: in the game when it loads (a pack +//! that fails keeps the last good one drawing), in the launcher before it +//! installs, in the registry's CI, and in `horizon-shader check`. +//! +//! Each stage is built as the renderer builds it, parsed and validated by +//! naga, checked against its signature and the API's rules (no bindings, no +//! entry points, no `horizon_` names), then translated to SPIR-V, Metal and +//! HLSL with its options at their defaults, minimums and maximums. +use crate::manifest::{OptionKind, PackOption}; +use crate::package::Pack; +use crate::stages::{Composed, StageKind, compose}; +use naga::valid::{Capabilities, ModuleInfo, ValidationFlags, Validator}; +use std::collections::HashSet; +use std::fmt; + +/// naga expressions a stage may hold, over all its functions. +pub const MAX_EXPRESSIONS: usize = 20_000; +/// Texture reads (samples, gathers and loads) a stage may write. +pub const MAX_TEXTURE_READS: usize = 64; + +/// What is wrong, and where: a file of the pack, with the line and column +/// when known (0 when not). +#[derive(Clone, Debug, PartialEq)] +pub struct Problem { + pub file: String, + pub line: usize, + pub column: usize, + pub message: String, +} + +impl fmt::Display for Problem { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if self.line == 0 { + write!(f, "{}: {}", self.file, self.message) + } else { + write!( + f, + "{}:{}:{}: {}", + self.file, self.line, self.column, self.message + ) + } + } +} + +fn problem(file: &str, message: impl Into) -> Problem { + Problem { + file: file.to_owned(), + line: 0, + column: 0, + message: message.into(), + } +} + +/// One stage that passed, ready for the renderer. +#[derive(Debug)] +pub struct CheckedStage { + pub kind: StageKind, + pub file: String, + /// The manifest's name for it, else its file. + pub label: String, + pub module: naga::Module, + pub info: ModuleInfo, + pub expressions: usize, + pub texture_reads: usize, +} + +#[derive(Debug)] +pub struct Report { + pub stages: Vec, +} + +/// Checks every stage of `pack`, and that each option is an `override` of +/// its type in some stage. Returns every problem found, not just the first. +pub fn check(pack: &Pack) -> Result> { + let m = &pack.manifest; + let stages = m + .sky + .iter() + .map(|s| (StageKind::Sky, s)) + .chain(m.material.iter().map(|s| (StageKind::Material, s))) + .chain(m.post.iter().map(|s| (StageKind::Post, s))); + let (mut checked, mut problems) = (Vec::new(), Vec::new()); + for (kind, stage) in stages { + match check_stage(pack, kind, &stage.file) { + Ok(mut c) => { + c.label = stage.name.clone().unwrap_or_else(|| stage.file.clone()); + checked.push(c); + } + Err(p) => problems.extend(p), + } + } + if problems.is_empty() { + for option in &m.options { + let used = checked.iter().any(|c| { + c.module + .overrides + .iter() + .any(|(_, o)| o.name.as_deref() == Some(&option.id)) + }); + if !used { + problems.push(problem( + crate::MANIFEST, + format!( + "option {:?}: no stage declares `override {}: {}`", + option.id, + option.id, + option.kind.wgsl_type() + ), + )); + } + } + } + if problems.is_empty() { + Ok(Report { stages: checked }) + } else { + Err(problems) + } +} + +/// Builds and checks one stage of `pack` from its file. +pub fn check_stage(pack: &Pack, kind: StageKind, file: &str) -> Result> { + // First without the entry points, so a missing or mistyped stage function + // is reported as such rather than as a call that doesn't resolve. + let mut own = compose(pack, kind, file); + let entries = own.parts.pop().expect("entries are the last part"); + own.text.truncate(entries.1.start); + let module = parse(&own)?; + let mut problems = signature(&module, kind, file); + problems.extend(api_rules(&module, kind, &own)); + problems.extend(options(&module, &pack.manifest.options, file)); + if !problems.is_empty() { + return Err(problems); + } + let composed = compose(pack, kind, file); + let module = parse(&composed)?; + let info = Validator::new(ValidationFlags::all(), Capabilities::default()) + .validate(&module) + .map_err(|e| { + let at = e.spans().next().and_then(|(span, _)| span.to_range()); + let mut message = e.as_inner().to_string(); + let mut source = std::error::Error::source(e.as_inner()); + while let Some(s) = source { + message += &format!(": {s}"); + source = s.source(); + } + vec![located(&composed, at.map(|r| r.start), message)] + })?; + let (expressions, texture_reads) = cost(&module); + let mut problems = Vec::new(); + if expressions > MAX_EXPRESSIONS { + problems.push(problem( + file, + format!("{expressions} expressions; a stage may hold {MAX_EXPRESSIONS}"), + )); + } + if texture_reads > MAX_TEXTURE_READS { + problems.push(problem( + file, + format!("{texture_reads} texture reads; a stage may write {MAX_TEXTURE_READS}"), + )); + } + problems.extend(translate(&module, &info, &pack.manifest.options, file)); + if !problems.is_empty() { + return Err(problems); + } + Ok(CheckedStage { + kind, + file: file.to_owned(), + label: file.to_owned(), + module, + info, + expressions, + texture_reads, + }) +} + +fn located(composed: &Composed, offset: Option, message: String) -> Problem { + match offset { + Some(at) => { + let (file, line, column) = composed.locate(at); + Problem { + file: file.to_owned(), + line, + column, + message, + } + } + None => problem(&composed.parts[composed.parts.len() - 2].0, message), + } +} + +fn parse(composed: &Composed) -> Result> { + naga::front::wgsl::parse_str(&composed.text).map_err(|e| { + let label = e.labels().next(); + let mut message = e.message().to_owned(); + if let Some((_, text)) = label.filter(|(_, t)| !t.is_empty()) { + message += &format!(" ({text})"); + } + let at = label.and_then(|(span, _)| span.to_range()).map(|r| r.start); + vec![located(composed, at, message)] + }) +} + +fn type_name(module: &naga::Module, ty: naga::Handle) -> String { + let t = &module.types[ty]; + if let Some(name) = &t.name { + return name.clone(); + } + match t.inner { + naga::TypeInner::Scalar(s) => scalar_name(s).to_owned(), + naga::TypeInner::Vector { size, scalar } => { + format!("vec{}<{}>", size as u8, scalar_name(scalar)) + } + _ => format!("{:?}", t.inner), + } +} + +fn scalar_name(s: naga::Scalar) -> &'static str { + match (s.kind, s.width) { + (naga::ScalarKind::Bool, _) => "bool", + (naga::ScalarKind::Float, 4) => "f32", + (naga::ScalarKind::Sint, 4) => "i32", + (naga::ScalarKind::Uint, 4) => "u32", + _ => "another scalar", + } +} + +fn signature(module: &naga::Module, kind: StageKind, file: &str) -> Vec { + let found = module + .functions + .iter() + .find(|(_, f)| f.name.as_deref() == Some(kind.function())); + let Some((_, f)) = found else { + return vec![problem(file, format!("defines no `{}`", kind.signature()))]; + }; + let args: Vec = f + .arguments + .iter() + .map(|a| type_name(module, a.ty)) + .collect(); + let result = f.result.as_ref().map(|r| type_name(module, r.ty)); + if args == [kind.input().name] && result.as_deref() == Some(kind.output()) { + return Vec::new(); + } + vec![problem( + file, + format!( + "`fn {}({}) -> {}` should be `{}`", + kind.function(), + args.join(", "), + result.as_deref().unwrap_or("()"), + kind.signature() + ), + )] +} + +/// The names of everything a module defines at its top level. +fn names(module: &naga::Module) -> HashSet { + let types = module.types.iter().filter_map(|(_, t)| t.name.clone()); + let functions = module.functions.iter().filter_map(|(_, f)| f.name.clone()); + let globals = module + .global_variables + .iter() + .filter_map(|(_, g)| g.name.clone()); + let constants = module.constants.iter().filter_map(|(_, c)| c.name.clone()); + let overrides = module.overrides.iter().filter_map(|(_, o)| o.name.clone()); + types + .chain(functions) + .chain(globals) + .chain(constants) + .chain(overrides) + .collect() +} + +/// A pack's files declare no bindings, no entry points and no `horizon_` +/// names: what the prelude doesn't define is the pack's. +fn api_rules(module: &naga::Module, kind: StageKind, own: &Composed) -> Vec { + let prelude = naga::front::wgsl::parse_str(&kind.prelude()).expect("the prelude parses"); + let ours = names(&prelude); + let file = &own.parts[own.parts.len() - 1].0; + let mut problems = Vec::new(); + for e in &module.entry_points { + problems.push(problem( + file, + format!( + "declares the entry point `{}`: a stage is a function, Horizon adds the entry points", + e.name + ), + )); + } + for (_, g) in module.global_variables.iter() { + let name = g.name.clone().unwrap_or_default(); + if g.binding.is_some() && !ours.contains(&name) { + problems.push(problem( + file, + format!( + "declares the binding `{name}`: a stage reads only the resources of its prelude" + ), + )); + } + } + let mut theirs: Vec = names(module) + .into_iter() + .filter(|n| !ours.contains(n) && n.starts_with("horizon_")) + .collect(); + theirs.sort(); + for name in theirs { + problems.push(problem( + file, + format!("`{name}`: names starting with horizon_ are Horizon's"), + )); + } + problems +} + +/// Each `override` is an option of its type, or has a default of its own. +fn options(module: &naga::Module, options: &[PackOption], file: &str) -> Vec { + let mut problems = Vec::new(); + for (_, o) in module.overrides.iter() { + let name = o.name.as_deref().unwrap_or("?"); + let ty = type_name(module, o.ty); + match options.iter().find(|opt| opt.id == name) { + Some(opt) if ty != opt.kind.wgsl_type() => problems.push(problem( + file, + format!( + "`override {name}: {ty}`: option {name:?} is a {}, so its override is `{}`", + kind_name(&opt.kind), + opt.kind.wgsl_type() + ), + )), + None if o.init.is_none() => problems.push(problem( + file, + format!("`override {name}` has no default and no [[options]] entry"), + )), + _ => {} + } + } + problems +} + +fn kind_name(kind: &OptionKind) -> &'static str { + match kind { + OptionKind::Bool { .. } => "bool", + OptionKind::Float { .. } => "float", + OptionKind::Int { .. } => "int", + OptionKind::Choice { .. } => "choice", + } +} + +fn cost(module: &naga::Module) -> (usize, usize) { + let functions = module + .functions + .iter() + .map(|(_, f)| f) + .chain(module.entry_points.iter().map(|e| &e.function)); + let (mut expressions, mut reads) = (0, 0); + for f in functions { + expressions += f.expressions.len(); + reads += f + .expressions + .iter() + .filter(|(_, e)| { + matches!( + e, + naga::Expression::ImageSample { .. } | naga::Expression::ImageLoad { .. } + ) + }) + .count(); + } + (expressions, reads) +} + +/// The option values a pack is translated with: its defaults, then every +/// option at its minimum, then at its maximum. +fn option_sets(options: &[PackOption]) -> [(&'static str, Vec<(String, f64)>); 3] { + let at = |pick: fn(&OptionKind) -> f64| { + options + .iter() + .map(|o| (o.id.clone(), pick(&o.kind))) + .collect() + }; + [ + ("defaults", at(OptionKind::default_constant)), + ( + "minimums", + at(|k| match *k { + OptionKind::Bool { .. } | OptionKind::Choice { .. } => 0.0, + OptionKind::Float { min, .. } => f64::from(min), + OptionKind::Int { min, .. } => f64::from(min), + }), + ), + ( + "maximums", + at(|k| match k { + OptionKind::Bool { .. } => 1.0, + OptionKind::Choice { values, .. } => (values.len() - 1) as f64, + OptionKind::Float { max, .. } => f64::from(*max), + OptionKind::Int { max, .. } => f64::from(*max), + }), + ), + ] +} + +/// Translates every entry point for Vulkan (SPIR-V), Metal and DirectX 12 +/// (HLSL) with each option set, as wgpu does when it builds the pipelines. +fn translate( + module: &naga::Module, + info: &ModuleInfo, + options: &[PackOption], + file: &str, +) -> Vec { + let present: HashSet<&str> = module + .overrides + .iter() + .filter_map(|(_, o)| o.name.as_deref()) + .collect(); + let mut problems = Vec::new(); + for (set, values) in option_sets(options) { + let mut constants = naga::back::PipelineConstants::default(); + for (id, value) in values + .into_iter() + .filter(|(id, _)| present.contains(id.as_str())) + { + constants.insert(id, value); + } + for e in &module.entry_points { + let fail = |backend: &str, err: String| { + problem( + file, + format!("with the options at their {set}, {backend} refuses it: {err}"), + ) + }; + let processed = naga::back::pipeline_constants::process_overrides( + module, + info, + Some((e.stage, &e.name)), + &constants, + ); + let (m, i) = match processed { + Ok(p) => p, + Err(err) => { + problems.push(fail("the override step", err.to_string())); + return problems; + } + }; + let spv = naga::back::spv::write_vec( + &m, + &i, + &naga::back::spv::Options::default(), + Some(&naga::back::spv::PipelineOptions { + shader_stage: e.stage, + entry_point: e.name.clone(), + }), + ); + let msl = naga::back::msl::write_string( + &m, + &i, + &naga::back::msl::Options { + lang_version: (2, 2), + ..Default::default() + }, + &naga::back::msl::PipelineOptions { + entry_point: Some((e.stage, e.name.clone())), + ..Default::default() + }, + ); + let mut hlsl = String::new(); + let hlsl_result = naga::back::hlsl::Writer::new( + &mut hlsl, + &naga::back::hlsl::Options::default(), + &naga::back::hlsl::PipelineOptions { + entry_point: Some((e.stage, e.name.clone())), + }, + ) + .write(&m, &i, None); + let errors = [ + ("Vulkan (SPIR-V)", spv.err().map(|e| e.to_string())), + ("Metal", msl.err().map(|e| e.to_string())), + ( + "DirectX 12 (HLSL)", + hlsl_result.err().map(|e| e.to_string()), + ), + ]; + for (backend, err) in errors { + if let Some(err) = err { + problems.push(fail(backend, err)); + } + } + if !problems.is_empty() { + return problems; + } + } + } + problems +} diff --git a/crates/horizon-shader-api/src/lib.rs b/crates/horizon-shader-api/src/lib.rs index 1d85e7a5e..ff676f64f 100644 --- a/crates/horizon-shader-api/src/lib.rs +++ b/crates/horizon-shader-api/src/lib.rs @@ -3,12 +3,14 @@ //! a folder or a `.hshader`. This crate reads and checks packs; the game, the //! launcher and the registry all use it, so a pack is judged the same way //! everywhere. +pub mod check; pub mod inputs; pub mod manifest; pub mod package; pub mod reference; pub mod stages; +pub use check::{Problem, Report, check}; pub use inputs::Frame; pub use manifest::{API, MANIFEST, Manifest, OptionKind, PackOption, SCHEMA, Stage}; pub use package::{EXTENSION, Pack}; diff --git a/crates/horizon-shader-api/src/package.rs b/crates/horizon-shader-api/src/package.rs index d95e8d321..e2e644a04 100644 --- a/crates/horizon-shader-api/src/package.rs +++ b/crates/horizon-shader-api/src/package.rs @@ -148,6 +148,21 @@ impl Pack { } Ok(zip.finish().map_err(err)?.into_inner()) } + + /// The SHA-256 of [`Pack::package`], in lowercase hex: the pack's + /// identity in the registry. + pub fn sha256(&self) -> Result { + Ok(sha256_hex(&self.package()?)) + } +} + +/// SHA-256 in lowercase hex. +pub fn sha256_hex(bytes: &[u8]) -> String { + use sha2::Digest; + sha2::Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() } fn read_dir(dir: &Path, prefix: &str, files: &mut BTreeMap>) -> Result<(), String> { diff --git a/crates/horizon-shader-api/src/reference.rs b/crates/horizon-shader-api/src/reference.rs index 9847ccf90..df74b7633 100644 --- a/crates/horizon-shader-api/src/reference.rs +++ b/crates/horizon-shader-api/src/reference.rs @@ -1,5 +1,6 @@ //! docs/api/shaders-reference.md, generated from the stages and inputs this //! crate defines (`tests/reference.rs` fails when the file drifts). +use crate::check::{MAX_EXPRESSIONS, MAX_TEXTURE_READS}; use crate::inputs::{Frame, SURFACE, Struct}; use crate::manifest::{API, MAX_GPU_MS, MAX_OPTIONS, MAX_POST_PASSES}; use crate::package::{MAX_TOTAL, MAX_WGSL}; @@ -80,7 +81,15 @@ pub fn markdown() -> String { | `choice` | `values`, `default` (one of them) | `override : u32`, the value's index |\n\n\ ## Limits\n\n\ - {MAX_POST_PASSES} post passes, {MAX_OPTIONS} options, `budget.gpu_ms` up to {MAX_GPU_MS}.\n\ - - {} KiB per WGSL file, {} MiB per pack.\n", + - {} KiB per WGSL file, {} MiB per pack.\n\ + - Per stage: {MAX_EXPRESSIONS} naga expressions over all its functions, {MAX_TEXTURE_READS} texture reads (samples,\n \ + gathers and loads, the prelude's included).\n\n\ + ## The check\n\n\ + A pack runs only after it passes `horizon_shader_api::check` (the game when it loads a pack, the launcher before it\n\ + installs one, the registry's CI): each stage is parsed and validated by naga, checked against its signature and\n\ + the rules above, and translated for Vulkan (SPIR-V), Metal and DirectX 12 (HLSL) with its options at their\n\ + defaults, minimums and maximums. Every option must be an `override` of its type in some stage; an `override`\n\ + that is no option needs a default.\n", MAX_WGSL >> 10, MAX_TOTAL >> 20 ); diff --git a/crates/horizon-shader-api/tests/check.rs b/crates/horizon-shader-api/tests/check.rs new file mode 100644 index 000000000..4c6168a43 --- /dev/null +++ b/crates/horizon-shader-api/tests/check.rs @@ -0,0 +1,146 @@ +//! `check`: the sample pack passes, and each kind of mistake is reported +//! with the pack's own file and line. +use horizon_shader_api::{MANIFEST, Pack, Problem, check}; +use std::path::Path; + +fn sample() -> Pack { + Pack::read(Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/samples/schema-1" + ))) + .unwrap() +} + +/// The sample with `file` replaced by `text`, checked. +fn problems(file: &str, text: &str) -> Vec { + let mut files = sample().files; + files.insert(file.into(), text.as_bytes().to_vec()); + check(&Pack::from_files(files).unwrap()).expect_err("should fail") +} + +fn one(file: &str, text: &str, needle: &str) -> Problem { + let p = problems(file, text); + assert!( + p.iter().any(|p| p.message.contains(needle)), + "{needle:?} not in {p:#?}" + ); + p.into_iter().find(|p| p.message.contains(needle)).unwrap() +} + +const GRADE: &str = "post/grade.wgsl"; + +#[test] +fn the_sample_passes() { + let pack = sample(); + let report = check(&pack).unwrap(); + let labels: Vec<_> = report.stages.iter().map(|s| s.label.as_str()).collect(); + assert_eq!( + labels, + ["sky.wgsl", "Terrain", "Colour grade", "post/vignette.wgsl"] + ); + assert!(report.stages.iter().all(|s| s.expressions > 0)); + let post = &report.stages[2]; + assert!(post.texture_reads >= 2, "the entry point samples and loads"); + let sha = pack.sha256().unwrap(); + assert_eq!(sha.len(), 64); + assert_eq!(sha, sample().sha256().unwrap()); +} + +#[test] +fn a_syntax_error_points_at_its_line() { + let p = one( + GRADE, + "override saturation: f32 = 1.1;\noverride tonemap: u32 = 1u;\nfn post(input: PostInput) -> vec4 {\n return input.color +;\n}\n", + "expected", + ); + assert_eq!((p.file.as_str(), p.line), (GRADE, 4), "{p}"); + assert!(p.to_string().starts_with("post/grade.wgsl:4:"), "{p}"); +} + +#[test] +fn the_stage_function_must_have_its_signature() { + let header = "override saturation: f32 = 1.1;\noverride tonemap: u32 = 1u;\n"; + one( + GRADE, + &format!("{header}fn post(input: PostInput) -> vec3 {{ return input.color.rgb; }}\n"), + "`fn post(PostInput) -> vec3` should be `fn post(input: PostInput) -> vec4`", + ); + one( + GRADE, + &format!("{header}fn grade(input: PostInput) -> vec4 {{ return input.color; }}\n"), + "defines no `fn post(input: PostInput) -> vec4`", + ); +} + +#[test] +fn a_pack_declares_no_bindings_entry_points_or_horizon_names() { + let body = "override saturation: f32 = 1.1;\noverride tonemap: u32 = 1u;\nfn post(input: PostInput) -> vec4 { return input.color; }\n"; + one( + GRADE, + &format!("@group(3) @binding(9) var extra: texture_2d;\n{body}"), + "declares the binding `extra`", + ); + one( + GRADE, + &format!( + "{body}@fragment fn main() -> @location(0) vec4 {{ return vec4(1.0); }}\n" + ), + "declares the entry point `main`", + ); + one( + GRADE, + &format!("{body}fn horizon_mine() {{}}\n"), + "`horizon_mine`: names starting with horizon_", + ); +} + +#[test] +fn overrides_match_the_options() { + let post = "fn post(input: PostInput) -> vec4 { return input.color; }\n"; + one( + GRADE, + &format!("override saturation: i32 = 1;\noverride tonemap: u32 = 1u;\n{post}"), + "option \"saturation\" is a float, so its override is `f32`", + ); + one( + GRADE, + &format!( + "override saturation: f32 = 1.1;\noverride tonemap: u32 = 1u;\noverride extra: f32;\n{post}" + ), + "`override extra` has no default and no [[options]] entry", + ); + let p = one( + GRADE, + &format!("override saturation: f32 = 1.1;\n{post}"), + "option \"tonemap\": no stage declares `override tonemap: u32`", + ); + assert_eq!(p.file, MANIFEST); +} + +#[test] +fn validation_errors_point_into_the_pack() { + // A compute-only barrier in a fragment stage passes the parser, not the validator. + let p = one( + GRADE, + "override saturation: f32 = 1.1;\noverride tonemap: u32 = 1u;\n\ + fn post(input: PostInput) -> vec4 {\n \ + workgroupBarrier();\n \ + return input.color;\n}\n", + "forbidden at this stage", + ); + assert_eq!(p.file, GRADE, "{p}"); +} + +#[test] +fn texture_reads_have_a_budget() { + let reads: String = (0..70) + .map(|i| format!(" c += textureLoad(previous, vec2({i}, 0), 0);\n")) + .collect(); + one( + GRADE, + &format!( + "override saturation: f32 = 1.1;\noverride tonemap: u32 = 1u;\nfn post(input: PostInput) -> vec4 {{\n var c = input.color;\n{reads} return c;\n}}\n" + ), + "texture reads; a stage may write 64", + ); +} diff --git a/docs/api/shaders-reference.md b/docs/api/shaders-reference.md index ed574978d..f9682a1a4 100644 --- a/docs/api/shaders-reference.md +++ b/docs/api/shaders-reference.md @@ -150,3 +150,13 @@ Each `[[options]]` entry sets the WGSL `override` of the same name when the pipe - 8 post passes, 64 options, `budget.gpu_ms` up to 16. - 256 KiB per WGSL file, 8 MiB per pack. +- Per stage: 20000 naga expressions over all its functions, 64 texture reads (samples, + gathers and loads, the prelude's included). + +## The check + +A pack runs only after it passes `horizon_shader_api::check` (the game when it loads a pack, the launcher before it +installs one, the registry's CI): each stage is parsed and validated by naga, checked against its signature and +the rules above, and translated for Vulkan (SPIR-V), Metal and DirectX 12 (HLSL) with its options at their +defaults, minimums and maximums. Every option must be an `override` of its type in some stage; an `override` +that is no option needs a default.