concinnity_core/components/shader.rs
1//! The Shader asset: the authored schema (Shader, StageSource, ShaderKind, and
2//! the ShaderPayload container), the `Component` impl, and the
3//! `StageSource::source_for` selection the engine init and hot-reload paths
4//! use. The JSON-args source selection and validation live in concinnity-cook
5//! (`authoring::source_args`, `check::shader`).
6
7use crate::ecs::Component;
8use crate::ecs::PayloadLocator;
9use crate::ecs::asset_id::AssetId;
10use alloc::collections::BTreeMap;
11use alloc::string::String;
12use alloc::vec::Vec;
13
14/// A stage slot within a [Shader](#shader).
15///
16/// `VertexInstanced` is the GPU-instanced sibling of `Vertex`, reading per-
17/// instance model matrices instead of a per-draw transform. Required for any
18/// world containing [InstancedProp](#instancedprop) components; otherwise
19/// unused.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
21#[serde(rename_all = "lowercase")]
22#[derive(Default)]
23pub enum ShaderKind {
24 /// Vertex stage for a per-draw transform.
25 #[default]
26 Vertex,
27 /// Fragment stage.
28 Fragment,
29 /// Vertex stage reading per-instance model matrices.
30 #[serde(rename = "vertex_instanced", alias = "vertexinstanced")]
31 VertexInstanced,
32}
33
34impl ShaderKind {
35 /// The compile kind string expected by ShaderCompileArgs.
36 pub fn compile_kind(&self) -> &'static str {
37 match self {
38 ShaderKind::Vertex | ShaderKind::VertexInstanced => "vertex",
39 ShaderKind::Fragment => "fragment",
40 }
41 }
42}
43
44/// Source declaration for one stage of a [Shader](#shader).
45///
46/// Provide either `source` (single platform) or `sources` (multi-platform).
47/// When both are present, `sources` takes priority for the current platform.
48///
49/// **Platform keys:** `"metal"` (macOS), `"hlsl"` (Windows), `"glsl"` (Linux/Vulkan).
50#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
51pub struct StageSource {
52 /// Single-platform source path; used when `sources` is absent or lacks the current platform key.
53 #[serde(default)]
54 pub source: String,
55 /// Per-platform source paths keyed by `"metal"`, `"hlsl"`, or `"glsl"`. Takes priority over `source`.
56 #[serde(default)]
57 pub sources: Option<BTreeMap<String, String>>,
58}
59
60impl StageSource {}
61
62/// Declares a custom shader program: the vertex and fragment stages, plus the
63/// optional GPU-instanced vertex stage.
64///
65/// **A Shader is entirely optional.** The engine ships its own main-pass
66/// program and uses it for every draw a Shader does not claim, so a world that
67/// wants standard lighting declares no Shader at all. Declare one only to
68/// replace that program with your own. The shadow pass is engine-internal (no
69/// Shader stage of its own); enable or size it with `shadow_map_size` in
70/// [GraphicsConfig](#graphicsconfig).
71///
72/// The engine-internal shadow map covers a ±20 m world-space region centred at
73/// the origin with 80 m depth. For larger scenes, increase `shadow_map_size` in
74/// [GraphicsConfig](#graphicsconfig) to maintain resolution.
75///
76/// A stage that resolves no source for the running backend falls back to the
77/// engine's own program for that stage, so a Shader may cover only the
78/// platforms it has sources for.
79///
80/// # More than one Shader
81///
82/// The first declared Shader is the world's default: everything renders with it
83/// unless a [Material](#material) names another one through its `shader` field.
84/// A world may declare up to 8 Shaders in total.
85///
86/// Three rules come with the second Shader, all enforced at build time:
87///
88/// - **Every fragment stage must define `fragment_main_bindless`.** Multi-Shader
89/// worlds render through the GPU-driven bindless path, which is the only path
90/// that can switch programs per draw. A single-Shader world has no such
91/// requirement and may define just `fragment_main`. This applies to `.metal`
92/// sources, which carry one program per entry point; an `.hlsl` or GLSL stage
93/// compiles a single `main`, so there is no entry point to pick -- what it must
94/// match instead is the bindless binding layout (see below).
95/// - **Instanced, skinned, and voxel-chunk draws always use the world default.**
96/// A Material naming a Shader cannot be used by an
97/// [InstancedProp](#instancedprop), a [SkinnedMesh](#skinnedmesh), or a
98/// [VoxelWorld](#voxelworld); give those a Material without one.
99/// - **At most 8 Shaders**, the world default included.
100///
101/// Planar reflections are the one case with no build-time signal: a surface
102/// reflected in a mirror is drawn with the world default Shader regardless of
103/// its Material. Reflection probe cubes capture it the same way.
104///
105/// A non-default Shader's stages must be written against the engine's **bindless**
106/// binding layout, not the per-draw one: the material, transform, and texture
107/// indices come from the per-frame object buffer rather than per-draw constants.
108///
109/// A Shader referenced only by materials belonging to one [Scene](#scene) is
110/// owned by that scene: its pipeline is built when the scene loads (behind the
111/// loading screen, alongside that scene's textures and meshes) and released when
112/// the scene unloads. A Shader used across scenes, or by the world default,
113/// loads at startup.
114///
115/// **Custom shader vertex layout**: the engine always supplies vertices with 5
116/// attributes at a fixed 56-byte stride. Any custom `.metal` shader **must** declare
117/// `struct Vertex` exactly as shown below: wrong attribute indices cause tangent
118/// data to be read as vertex colour, producing red/green/blue geometry:
119///
120/// ```metal
121/// struct Vertex {
122/// float3 pos [[attribute(0)]]; // offset 0
123/// float3 normal [[attribute(1)]]; // offset 12
124/// float3 tangent [[attribute(2)]]; // offset 24
125/// float3 color [[attribute(3)]]; // offset 36
126/// float2 uv [[attribute(4)]]; // offset 48
127/// };
128/// ```
129///
130/// Buffer and texture bindings that must match:
131///
132/// ```metal
133/// struct DirectionalLightData {
134/// packed_float3 direction;
135/// float intensity;
136/// packed_float3 color;
137/// float _pad;
138/// };
139///
140/// struct PointLightData {
141/// packed_float3 position;
142/// float range;
143/// packed_float3 color;
144/// float intensity;
145/// };
146///
147/// struct ShadowUniforms {
148/// float4x4 light_vp;
149/// };
150/// ```
151#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
152pub struct Shader {
153 /// Asset identity; injected via `inject_name`. Not part of `args`.
154 #[serde(skip)]
155 pub asset_id: AssetId,
156 /// The vertex stage. Required.
157 pub vertex: StageSource,
158 /// The fragment stage. Required.
159 pub fragment: StageSource,
160 /// The GPU-instanced vertex stage. Required only for worlds with
161 /// [InstancedProp](#instancedprop) components.
162 #[serde(default)]
163 pub vertex_instanced: Option<StageSource>,
164 /// Injected at load time from BlobAssetDef::payload.
165 #[serde(skip)]
166 pub locator: Option<PayloadLocator>,
167}
168
169impl Shader {
170 /// The declared source for `kind`, if that stage is present.
171 pub fn stage(&self, kind: ShaderKind) -> Option<&StageSource> {
172 match kind {
173 ShaderKind::Vertex => Some(&self.vertex),
174 ShaderKind::Fragment => Some(&self.fragment),
175 ShaderKind::VertexInstanced => self.vertex_instanced.as_ref(),
176 }
177 }
178}
179
180/// The compiled payload a [`Shader`] carries in the blob: every compiled stage,
181/// tagged by kind. Written by the cook, decoded once by the renderer at load.
182#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
183pub struct ShaderPayload {
184 /// The compiled bytes of each stage, tagged by kind.
185 pub stages: Vec<(ShaderKind, Vec<u8>)>,
186}
187
188impl ShaderPayload {
189 /// Serialize the payload for the blob.
190 pub fn encode(&self) -> Result<Vec<u8>, postcard::Error> {
191 postcard::to_allocvec(self)
192 }
193
194 /// Read a payload back out of the blob.
195 pub fn decode(bytes: &[u8]) -> Result<Self, postcard::Error> {
196 postcard::from_bytes(bytes)
197 }
198
199 /// The compiled bytes for `kind`, if that stage was compiled.
200 pub fn stage(&self, kind: ShaderKind) -> Option<&[u8]> {
201 self.stages
202 .iter()
203 .find(|(k, _)| *k == kind)
204 .map(|(_, b)| b.as_slice())
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use alloc::vec;
212
213 #[test]
214 fn payload_round_trips_and_indexes_by_kind() {
215 let payload = ShaderPayload {
216 stages: vec![
217 (ShaderKind::Vertex, vec![1, 2, 3]),
218 (ShaderKind::Fragment, vec![4, 5]),
219 ],
220 };
221 let bytes = payload.encode().expect("encode");
222 let decoded = ShaderPayload::decode(&bytes).expect("decode");
223 assert_eq!(decoded, payload);
224 assert_eq!(decoded.stage(ShaderKind::Vertex), Some(&[1u8, 2, 3][..]));
225 assert_eq!(decoded.stage(ShaderKind::Fragment), Some(&[4u8, 5][..]));
226 assert_eq!(decoded.stage(ShaderKind::VertexInstanced), None);
227 }
228
229 #[test]
230 fn stage_lookup_covers_every_kind() {
231 let s = Shader::default();
232 assert!(s.stage(ShaderKind::Vertex).is_some());
233 assert!(s.stage(ShaderKind::Fragment).is_some());
234 assert!(s.stage(ShaderKind::VertexInstanced).is_none());
235 }
236
237 #[test]
238 fn the_instanced_vertex_stage_compiles_as_a_vertex_stage() {
239 assert_eq!(ShaderKind::Vertex.compile_kind(), "vertex");
240 assert_eq!(ShaderKind::VertexInstanced.compile_kind(), "vertex");
241 assert_eq!(ShaderKind::Fragment.compile_kind(), "fragment");
242 assert_eq!(ShaderKind::default(), ShaderKind::Vertex);
243 }
244
245 #[test]
246 fn stage_kinds_parse_from_their_authored_spellings() {
247 let kind = |s: &str| serde_json::from_str::<ShaderKind>(s).unwrap();
248 assert_eq!(kind(r#""vertex""#), ShaderKind::Vertex);
249 assert_eq!(kind(r#""fragment""#), ShaderKind::Fragment);
250 assert_eq!(kind(r#""vertex_instanced""#), ShaderKind::VertexInstanced);
251 // The unseparated spelling is accepted as an alias.
252 assert_eq!(kind(r#""vertexinstanced""#), ShaderKind::VertexInstanced);
253 assert_eq!(
254 serde_json::to_string(&ShaderKind::VertexInstanced).unwrap(),
255 r#""vertex_instanced""#
256 );
257 }
258
259 #[test]
260 fn a_shader_parses_from_authored_args() {
261 let s: Shader = serde_json::from_str(
262 r#"{"vertex":{"sources":{"metal":"my.metal"}},"fragment":{"source":"my.metal"}}"#,
263 )
264 .unwrap();
265 assert_eq!(s.fragment.source, "my.metal");
266 assert_eq!(
267 s.vertex.sources.as_ref().expect("per-platform")["metal"],
268 "my.metal"
269 );
270 // A world with no instanced props declares no instanced vertex stage.
271 assert!(s.vertex_instanced.is_none());
272 // The identity and payload locator are injected, never authored.
273 assert_eq!(s.asset_id, AssetId::default());
274 assert!(s.locator.is_none());
275
276 let bytes = postcard::to_allocvec(&s).unwrap();
277 let back: Shader = postcard::from_bytes(&bytes).unwrap();
278 assert_eq!(back.fragment.source, "my.metal");
279 }
280
281 #[test]
282 fn an_empty_payload_has_no_stages() {
283 let payload = ShaderPayload::default();
284 assert!(payload.stages.is_empty());
285 assert_eq!(payload.stage(ShaderKind::Vertex), None);
286 assert_eq!(
287 ShaderPayload::decode(&payload.encode().unwrap()),
288 Ok(payload)
289 );
290 }
291
292 #[test]
293 fn decoding_garbage_is_an_error_not_a_panic() {
294 assert!(ShaderPayload::decode(&[0xff, 0xff, 0xff]).is_err());
295 }
296}
297
298impl StageSource {
299 /// Resolve the source filename `platform` selects from this stage's
300 /// declared `source` / `sources`. Mirrors the build-time selection
301 /// (concinnity-cook `authoring::source_args`) so the hot-reload subsystem
302 /// picks the same source the build read. Returns `None` when the stage
303 /// declares nothing for that platform (e.g. a `glsl`-only stage asked for
304 /// Metal, which loads the embedded GLSL fallback at init and has no
305 /// on-disk file to hot-reload).
306 pub fn source_for(&self, platform: crate::platform::Platform) -> Option<String> {
307 if let Some(sources) = &self.sources
308 && let Some(src) = sources.get(platform.key())
309 {
310 return Some(src.clone());
311 }
312 if self.source.is_empty() {
313 return None;
314 }
315 let ext = super::path_extension(&self.source).unwrap_or("");
316 if platform.accepts_ext(ext) {
317 Some(self.source.clone())
318 } else {
319 None
320 }
321 }
322}
323
324impl Component for Shader {
325 const NAME: &'static str = "Shader";
326
327 fn from_baked(bytes: &[u8]) -> Result<Self, crate::result::CnResult> {
328 Ok(crate::blob::decode_exact(bytes)?)
329 }
330
331 fn inject_locator(&mut self, locator: PayloadLocator) {
332 self.locator = Some(locator);
333 }
334
335 fn inject_name(&mut self, id: crate::ecs::asset_id::AssetId) {
336 self.asset_id = id;
337 }
338}
339
340#[cfg(test)]
341mod runtime_tests {
342 use super::*;
343 use crate::platform::Platform;
344 use alloc::string::ToString;
345
346 #[test]
347 fn compile_kind_maps_each_stage() {
348 assert_eq!(ShaderKind::Vertex.compile_kind(), "vertex");
349 assert_eq!(ShaderKind::VertexInstanced.compile_kind(), "vertex");
350 assert_eq!(ShaderKind::Fragment.compile_kind(), "fragment");
351 assert_eq!(ShaderKind::default(), ShaderKind::Vertex);
352 }
353
354 #[test]
355 fn a_sources_map_resolves_for_every_platform() {
356 let stage = StageSource {
357 sources: Some(
358 [
359 ("metal".to_string(), "v.metal".to_string()),
360 ("hlsl".to_string(), "v.hlsl".to_string()),
361 ("glsl".to_string(), "v.glsl".to_string()),
362 ]
363 .into_iter()
364 .collect(),
365 ),
366 ..Default::default()
367 };
368 for (platform, expected) in [
369 (Platform::Metal, "v.metal"),
370 (Platform::Hlsl, "v.hlsl"),
371 (Platform::Glsl, "v.glsl"),
372 ] {
373 assert_eq!(
374 stage.source_for(platform),
375 Some(expected.to_string()),
376 "{platform:?} takes its own map entry"
377 );
378 }
379 }
380
381 #[test]
382 fn single_source_resolves_only_for_matching_extensions() {
383 let stage = StageSource {
384 source: "v.metal".to_string(),
385 sources: None,
386 };
387 assert_eq!(
388 stage.source_for(Platform::Metal),
389 Some("v.metal".to_string())
390 );
391 assert_eq!(stage.source_for(Platform::Hlsl), None);
392 assert_eq!(stage.source_for(Platform::Glsl), None);
393 }
394
395 // The map is consulted first; a stage declaring only a bare `source` falls
396 // back to it, but only when the file's extension is one that platform can
397 // actually load. A stage declaring nothing resolves to nothing rather than
398 // handing back an empty path.
399 #[test]
400 fn a_bare_source_resolves_only_when_the_platform_accepts_its_extension() {
401 let bare = |source: &str| StageSource {
402 source: source.to_string(),
403 sources: None,
404 };
405
406 assert_eq!(
407 bare("").source_for(Platform::Metal),
408 None,
409 "nothing declared"
410 );
411
412 // A generic extension is not platform-specific, so every platform
413 // accepts it.
414 for platform in [Platform::Metal, Platform::Hlsl, Platform::Glsl] {
415 assert_eq!(
416 bare("v.slang").source_for(platform),
417 Some("v.slang".to_string())
418 );
419 }
420
421 // A source for a different backend's language resolves to nothing.
422 assert_eq!(bare("v.glsl").source_for(Platform::Hlsl), None);
423 }
424
425 // The map wins over a bare `source`, even one this platform would accept.
426 #[test]
427 fn a_sources_map_entry_wins_over_a_bare_source() {
428 let stage = StageSource {
429 source: "bare.slang".to_string(),
430 sources: Some(
431 [("hlsl".to_string(), "mapped.hlsl".to_string())]
432 .into_iter()
433 .collect(),
434 ),
435 };
436 assert_eq!(
437 stage.source_for(Platform::Hlsl),
438 Some("mapped.hlsl".to_string())
439 );
440 assert_eq!(
441 stage.source_for(Platform::Metal),
442 Some("bare.slang".to_string()),
443 "a platform the map misses falls back to the bare source"
444 );
445 }
446
447 // Shader keeps a hand-written Component impl rather than the generated
448 // one, so its identity and payload injection are its own code.
449 #[test]
450 fn a_shader_takes_its_identity_and_payload_on_load() {
451 use crate::ecs::Component;
452 use crate::ecs::asset_id::AssetId;
453
454 let bytes = postcard::to_allocvec(&Shader::default()).expect("a shader encodes");
455 let mut shader = <Shader as Component>::from_baked(&bytes).expect("it loads back");
456 assert_eq!(Shader::NAME, "Shader");
457
458 shader.inject_name(AssetId(4));
459 assert_eq!(shader.asset_id, AssetId(4));
460
461 let locator = PayloadLocator {
462 blob_index: 1,
463 offset: 8,
464 len: 16,
465 };
466 shader.inject_locator(locator.clone());
467 assert_eq!(shader.locator, Some(locator));
468 }
469}