Skip to main content

concinnity_core/components/
shader.rs

1//! The Shader asset: the authored schema (Shader, ShaderStage, and the
2//! ShaderPrograms container the cook fills), and the `Component` impl. The
3//! compile lives in concinnity-cook (`compile::shader`); which programs a
4//! world shader compiles to is `render::slang_programs::surface`.
5
6use crate::ecs::Component;
7use crate::ecs::PayloadLocator;
8use crate::ecs::asset_id::AssetId;
9use alloc::string::String;
10use alloc::vec::Vec;
11
12use super::compiled_programs::CompiledProgram;
13
14/// One of the two files a [Shader](#shader) declares.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum ShaderStage {
18    /// The `vertex` file, defining `transform`.
19    Vertex,
20    /// The `fragment` file, defining `shade`.
21    Fragment,
22}
23
24/// Replaces how surfaces are shaded, and optionally how vertices are placed,
25/// with functions of your own. Written in Slang, one source for every
26/// backend.
27///
28/// **A Shader is entirely optional.** The engine ships its own lighting and
29/// projection and uses them for every draw a Shader does not claim, so a world
30/// that wants standard lighting declares no Shader at all. The shadow pass and
31/// the depth pre-pass are engine-internal and take no Shader stage; enable or
32/// size shadows with `shadow_map_size` in [GraphicsConfig](#graphicsconfig).
33///
34/// ```rust
35/// # use concinnity_core::components::Shader;
36/// // Custom shading only; the engine still places every vertex.
37/// let water = Shader {
38///     fragment: "assets/shaders/water.slang".into(),
39///     ..Default::default()
40/// };
41/// // Both hooks: a sway displacement, then the surface.
42/// let reeds = Shader {
43///     vertex: Some("assets/shaders/reeds_sway.slang".into()),
44///     fragment: "assets/shaders/reeds.slang".into(),
45///     ..Default::default()
46/// };
47/// assert!(water.vertex.is_none() && reeds.vertex.is_some());
48/// ```
49///
50/// # The two hooks
51///
52/// A Shader file defines a function, not an entry point. The engine owns every
53/// entry point, binding and pipeline on every backend, and calls the world's
54/// functions from inside its own:
55///
56/// ```hlsl
57/// // the `fragment` file, required
58/// float4 shade(VertexOut in, GpuObjectData od);
59///
60/// // the `vertex` file, optional; without one the engine projects the vertex itself
61/// VertexOut transform(float4x4 model, float3 pos, float3 normal, float3 tangent,
62///                     float3 color, float2 uv);
63/// ```
64///
65/// `shade` returns the surface's linear-light colour with alpha. `od` is the
66/// surface's material record whichever path drew it: `tint_roughness`,
67/// `emissive_metallic`, `albedo_index`, `normal_index`, `emissive_map_index`,
68/// `orm_map_index` and `bb_max_alpha_cutoff.w` are the fields a surface
69/// reads. `transform` receives the model matrix and the model-space
70/// attributes, after skinning for a [SkinnedMesh](#skinnedmesh) and per
71/// instance for an [InstancedProp](#instancedprop), and returns the projected
72/// vertex; the engine's own is `project_vertex`, so a displacement is
73/// `return project_vertex(model, pos + offset, normal, tangent, color, uv);`.
74///
75/// Both files are compiled inside the engine's own main-pass source, so they
76/// see the same vocabulary the engine's shading uses and declare no layout,
77/// binding, register, attribute or varying of their own:
78///
79/// - `shade_surface(in, od)`: the engine's PBR lighting, so
80///   `return shade_surface(in, od) * tint;` starts from it.
81/// - `project_vertex(model, pos, normal, tangent, color, uv)`: the engine's
82///   projection.
83/// - `pool_sample(index, uv)`: a texture from the world's pool by the record's
84///   index.
85/// - `decode_normal_map(rg)`: a tangent-space normal from a normal-map texel.
86/// - `shadow_factor_cascaded(world_pos, view_depth, screen_xy)`: the sun's
87///   cascaded shadow term.
88/// - `environment_specular(world_pos, reflected, lod)`: the reflection
89///   environment.
90/// - `irradiance_sample(normal)`: the diffuse environment.
91/// - `VIEW`: the view block, with `vp`, `view_mat`, `elapsed`, `cam_x` /
92///   `cam_y` / `cam_z` and `sky_rot`.
93/// - `LIGHTS`: the light block, with `dir[]`, `pt[]`, `num_dir`, `num_pt` and
94///   `ambient_intensity`.
95/// - `SKY_DIR(d)`: a world direction in the environment map's frame.
96///
97/// `VertexOut` is the engine's varying block: `position` (clip), `world_pos`,
98/// `normal`, `tangent`, `bitangent`, `uv`, `view_depth` and `color`. A `shade`
99/// must not read `in.object_id`; the record is `od`.
100///
101/// # More than one Shader
102///
103/// The first declared Shader is the world's default: everything renders with it
104/// unless a [Material](#material) names another one through its `shader` field.
105/// A world may declare up to 8 Shaders in total.
106///
107/// - **Instanced, skinned, and voxel-chunk draws always use the world default.**
108///   A Material naming a Shader cannot be used by an
109///   [InstancedProp](#instancedprop), a [SkinnedMesh](#skinnedmesh), or a
110///   [VoxelWorld](#voxelworld); give those a Material without one.
111/// - **At most 8 Shaders**, the world default included.
112///
113/// Planar reflections are the one case with no build-time signal: a surface
114/// reflected in a mirror is drawn with the world default Shader regardless of
115/// its Material. Reflection probe cubes capture it the same way.
116///
117/// A Shader referenced only by materials belonging to one [Scene](#scene) is
118/// owned by that scene: its pipeline is built when the scene loads (behind the
119/// loading screen, alongside that scene's textures and meshes) and released when
120/// the scene unloads. A Shader used across scenes, or by the world default,
121/// loads at startup.
122///
123/// # Compilation
124///
125/// `cn build` compiles both files for the backend it cooks for and stores the
126/// result in the world; a player needs no shader compiler. A file that fails
127/// to compile, or omits its hook, fails the build naming the Shader and the
128/// hook. Under `cn debug` a save to either file recompiles it and swaps the
129/// live pipelines.
130#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
131pub struct Shader {
132    /// Asset identity; injected via `inject_name`. Not part of `args`.
133    #[serde(skip)]
134    pub asset_id: AssetId,
135    /// Path to the `.slang` file defining `shade`. Required.
136    pub fragment: String,
137    /// Path to the `.slang` file defining `transform`. Omit to keep the
138    /// engine's own projection.
139    #[serde(default)]
140    pub vertex: Option<String>,
141    /// Injected at load time from BlobAssetDef::payload.
142    #[serde(skip)]
143    pub locator: Option<PayloadLocator>,
144}
145
146impl Shader {
147    /// The declared path for `stage`, if that file is present.
148    pub fn stage(&self, stage: ShaderStage) -> Option<&str> {
149        match stage {
150            ShaderStage::Vertex => self.vertex.as_deref(),
151            ShaderStage::Fragment => Some(&self.fragment),
152        }
153    }
154}
155
156/// The compiled payload a [`Shader`] carries in the blob: the authored files
157/// and every program the cook compiled from them. Written by the cook, decoded
158/// once by the renderer at load.
159///
160/// The sources ride along for the reason an `SdfVolume`'s field does: an
161/// artifact is only loadable while the engine template it was built against
162/// still matches, and the renderer proves that by reassembling and digesting.
163#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
164pub struct ShaderPrograms {
165    /// The Shader's asset name, for diagnostics.
166    pub name: String,
167    /// The `vertex` file's text, when the Shader declares one.
168    pub vertex: Option<String>,
169    /// The `fragment` file's text.
170    pub fragment: String,
171    /// Compiled entries, in the order the cook emitted them.
172    pub programs: Vec<CompiledProgram>,
173}
174
175impl ShaderPrograms {
176    /// Serialize the payload for the blob.
177    pub fn encode(&self) -> Result<Vec<u8>, postcard::Error> {
178        postcard::to_allocvec(self)
179    }
180
181    /// Read a payload back out of the blob.
182    pub fn decode(bytes: &[u8]) -> Result<Self, postcard::Error> {
183        postcard::from_bytes(bytes)
184    }
185
186    /// The artifact holding `entry`, if one was compiled from source matching
187    /// `digest`. A mismatch is a stale artifact and reads as absent.
188    pub fn artifact(&self, entry: &str, digest: u64) -> Option<&[u8]> {
189        super::compiled_programs::artifact(&self.programs, entry, digest)
190    }
191}
192
193impl Component for Shader {
194    const NAME: &'static str = "Shader";
195
196    fn from_baked(bytes: &[u8]) -> Result<Self, crate::result::CnResult> {
197        Ok(crate::blob::decode_exact(bytes)?)
198    }
199
200    fn inject_locator(&mut self, locator: PayloadLocator) {
201        self.locator = Some(locator);
202    }
203
204    fn inject_name(&mut self, id: crate::ecs::asset_id::AssetId) {
205        self.asset_id = id;
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use alloc::string::ToString;
213    use alloc::vec;
214
215    #[test]
216    fn a_shader_parses_from_authored_args() {
217        let s: Shader =
218            serde_json::from_str(r#"{"fragment":"assets/shaders/water.slang"}"#).unwrap();
219        assert_eq!(s.fragment, "assets/shaders/water.slang");
220        assert!(s.vertex.is_none(), "the vertex file is optional");
221        assert_eq!(s.stage(ShaderStage::Vertex), None);
222        assert_eq!(
223            s.stage(ShaderStage::Fragment),
224            Some("assets/shaders/water.slang")
225        );
226        // The identity and payload locator are injected, never authored.
227        assert_eq!(s.asset_id, AssetId::default());
228        assert!(s.locator.is_none());
229
230        let both: Shader =
231            serde_json::from_str(r#"{"vertex":"v.slang","fragment":"f.slang"}"#).unwrap();
232        assert_eq!(both.stage(ShaderStage::Vertex), Some("v.slang"));
233
234        let bytes = postcard::to_allocvec(&both).unwrap();
235        let back: Shader = postcard::from_bytes(&bytes).unwrap();
236        assert_eq!(back.vertex.as_deref(), Some("v.slang"));
237        assert_eq!(back.fragment, "f.slang");
238    }
239
240    // The per-platform `sources` table is gone: a declaration still spelling it
241    // is refused rather than read as a fragment-less Shader.
242    #[test]
243    fn the_old_per_platform_table_is_rejected() {
244        let err = serde_json::from_str::<Shader>(
245            r#"{"vertex":{"sources":{"metal":"a.metal"}},"fragment":{"source":"a.metal"}}"#,
246        );
247        assert!(err.is_err());
248    }
249
250    #[test]
251    fn stages_parse_from_their_authored_spellings() {
252        let stage = |s: &str| serde_json::from_str::<ShaderStage>(s).unwrap();
253        assert_eq!(stage(r#""vertex""#), ShaderStage::Vertex);
254        assert_eq!(stage(r#""fragment""#), ShaderStage::Fragment);
255    }
256
257    #[test]
258    fn programs_round_trip_and_find_artifacts_by_entry_and_digest() {
259        let payload = ShaderPrograms {
260            name: "wall".to_string(),
261            vertex: None,
262            fragment: "float4 shade(VertexOut in, GpuObjectData od) { return 1.0; }".to_string(),
263            programs: vec![CompiledProgram {
264                entries: vec!["fragment_main".to_string()],
265                source_digest: 3,
266                artifact: vec![1, 2, 3],
267            }],
268        };
269        let bytes = payload.encode().expect("encode");
270        let decoded = ShaderPrograms::decode(&bytes).expect("decode");
271        assert_eq!(decoded, payload);
272        assert_eq!(decoded.artifact("fragment_main", 3), Some(&[1u8, 2, 3][..]));
273        assert_eq!(decoded.artifact("fragment_main", 4), None, "stale");
274        assert_eq!(decoded.artifact("vertex_main", 3), None);
275    }
276
277    #[test]
278    fn an_empty_payload_holds_no_programs() {
279        let payload = ShaderPrograms::default();
280        assert!(payload.programs.is_empty());
281        assert_eq!(
282            ShaderPrograms::decode(&payload.encode().unwrap()),
283            Ok(payload)
284        );
285    }
286
287    #[test]
288    fn decoding_garbage_is_an_error_not_a_panic() {
289        assert!(ShaderPrograms::decode(&[0xff, 0xff, 0xff]).is_err());
290    }
291
292    // Shader keeps a hand-written Component impl rather than the generated
293    // one, so its identity and payload injection are its own code.
294    #[test]
295    fn a_shader_takes_its_identity_and_payload_on_load() {
296        let bytes = postcard::to_allocvec(&Shader::default()).expect("a shader encodes");
297        let mut shader = <Shader as Component>::from_baked(&bytes).expect("it loads back");
298        assert_eq!(Shader::NAME, "Shader");
299
300        shader.inject_name(AssetId(4));
301        assert_eq!(shader.asset_id, AssetId(4));
302
303        let locator = PayloadLocator {
304            blob_index: 1,
305            offset: 8,
306            len: 16,
307        };
308        shader.inject_locator(locator.clone());
309        assert_eq!(shader.locator, Some(locator));
310    }
311}