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}