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//! `StageSourceExt::current_platform_source` extension the engine init and
4//! hot-reload paths use. The JSON-args source selection and validation live in
5//! concinnity-cook (`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
298/// Resolve the source filename for the current build platform from a stage's
299/// declared `source` / `sources`. Mirrors the build-time selection
300/// (concinnity-cook `authoring::source_args`) so the hot-reload subsystem picks the
301/// same per-platform source the build read at compile time. Returns `None` when
302/// no current-platform source is declared (e.g. a stage that only declares `glsl`
303/// running on the Metal backend, which loads the embedded GLSL fallback at init
304/// and has no on-disk file to hot-reload). Exposed as an extension trait because
305/// the schema type is declared above.
306pub trait StageSourceExt {
307 /// The source path declared for the running platform, or `None` when the
308 /// stage declares none.
309 fn current_platform_source(&self) -> Option<String>;
310}
311
312impl StageSourceExt for StageSource {
313 fn current_platform_source(&self) -> Option<String> {
314 let platform = crate::platform::Platform::current();
315 if let Some(sources) = &self.sources
316 && let Some(src) = sources.get(platform.key())
317 {
318 return Some(src.clone());
319 }
320 if self.source.is_empty() {
321 return None;
322 }
323 let ext = super::path_extension(&self.source).unwrap_or("");
324 if platform.accepts_ext(ext) {
325 Some(self.source.clone())
326 } else {
327 None
328 }
329 }
330}
331
332impl Component for Shader {
333 const NAME: &'static str = "Shader";
334
335 fn from_baked(bytes: &[u8]) -> Result<Self, crate::result::CnResult> {
336 Ok(crate::blob::decode_exact(bytes)?)
337 }
338
339 fn inject_locator(&mut self, locator: PayloadLocator) {
340 self.locator = Some(locator);
341 }
342
343 fn inject_name(&mut self, id: crate::ecs::asset_id::AssetId) {
344 self.asset_id = id;
345 }
346}
347
348/// Returns the platform key used to look up entries in the `sources` map.
349pub fn platform_key() -> &'static str {
350 crate::platform::Platform::current().key()
351}
352
353#[cfg(test)]
354mod runtime_tests {
355 use super::*;
356 use alloc::string::ToString;
357
358 #[test]
359 fn compile_kind_maps_each_stage() {
360 assert_eq!(ShaderKind::Vertex.compile_kind(), "vertex");
361 assert_eq!(ShaderKind::VertexInstanced.compile_kind(), "vertex");
362 assert_eq!(ShaderKind::Fragment.compile_kind(), "fragment");
363 assert_eq!(ShaderKind::default(), ShaderKind::Vertex);
364 }
365
366 #[test]
367 fn current_platform_source_resolves_for_any_backend() {
368 // Declaring every platform source resolves on whichever backend the
369 // test build targets.
370 let stage = StageSource {
371 sources: Some(
372 [
373 ("metal".to_string(), "v.metal".to_string()),
374 ("hlsl".to_string(), "v.hlsl".to_string()),
375 ("glsl".to_string(), "v.glsl".to_string()),
376 ]
377 .into_iter()
378 .collect(),
379 ),
380 ..Default::default()
381 };
382 assert!(stage.current_platform_source().is_some());
383 }
384
385 #[test]
386 fn single_source_resolves_only_for_matching_extensions() {
387 let stage = StageSource {
388 source: "v.metal".to_string(),
389 sources: None,
390 };
391 let platform = crate::platform::Platform::current();
392 assert_eq!(
393 stage.current_platform_source().is_some(),
394 platform.accepts_ext("metal")
395 );
396 }
397
398 // The map is consulted first; a stage declaring only a bare `source` falls
399 // back to it, but only when the file's extension is one this platform can
400 // actually load. A stage declaring nothing resolves to nothing rather than
401 // handing back an empty path.
402 #[test]
403 fn a_bare_source_resolves_only_when_the_platform_accepts_its_extension() {
404 let bare = |source: &str| StageSource {
405 source: source.to_string(),
406 sources: None,
407 };
408
409 assert_eq!(bare("").current_platform_source(), None, "nothing declared");
410
411 // A generic extension is not platform-specific, so it is accepted
412 // whichever backend is running.
413 assert_eq!(
414 bare("v.slang").current_platform_source(),
415 Some("v.slang".to_string())
416 );
417
418 // A source for a different backend's language resolves to nothing.
419 let other = ["metal", "hlsl", "glsl"]
420 .into_iter()
421 .find(|ext| *ext != platform_key())
422 .expect("some other platform exists");
423 assert_eq!(
424 bare(&alloc::format!("v.{other}")).current_platform_source(),
425 None,
426 "a {other} source is not loadable here"
427 );
428 }
429
430 #[test]
431 fn the_platform_key_is_the_running_backends_own() {
432 assert!(["metal", "hlsl", "glsl"].contains(&platform_key()));
433 assert_eq!(platform_key(), crate::platform::Platform::current().key());
434 }
435
436 // Shader keeps a hand-written Component impl rather than the generated
437 // one, so its identity and payload injection are its own code.
438 #[test]
439 fn a_shader_takes_its_identity_and_payload_on_load() {
440 use crate::ecs::Component;
441 use crate::ecs::asset_id::AssetId;
442
443 let bytes = postcard::to_allocvec(&Shader::default()).expect("a shader encodes");
444 let mut shader = <Shader as Component>::from_baked(&bytes).expect("it loads back");
445 assert_eq!(Shader::NAME, "Shader");
446
447 shader.inject_name(AssetId(4));
448 assert_eq!(shader.asset_id, AssetId(4));
449
450 let locator = PayloadLocator {
451 blob_index: 1,
452 offset: 8,
453 len: 16,
454 };
455 shader.inject_locator(locator.clone());
456 assert_eq!(shader.locator, Some(locator));
457 }
458}