Skip to main content

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-world (`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-world `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}