Skip to main content

optic_render/asset/
shdr.rs

1use optic_core::consts::{OPTIC_CACHE_VERSION, OPTIC_MAGIC, SHADER_COMPUTE, SHADER_PIPELINE};
2use optic_core::{OpticError, OpticErrorKind, OpticResult};
3
4use crate::handles::shader::{link_compute_program, link_program, Shader};
5
6/// Whether a shader source is a vertex+fragment pipeline or a compute shader.
7pub enum ShaderType {
8    /// Vertex + fragment shader pair.
9    Pipeline,
10    /// Compute shader.
11    Compute,
12}
13
14impl ShaderType {
15    /// Returns `true` for the compute variant.
16    pub fn is_compute(&self) -> bool {
17        matches!(self, ShaderType::Compute)
18    }
19}
20
21/// Internal GLSL parsing result.
22enum GLSL {
23    ParsedCompute(String),
24    ParsedPipeline { v_src: String, f_src: String },
25    FailedPipeline { v_missing: bool, _f_missing: bool },
26}
27
28impl GLSL {
29    /// Parses a combined GLSL source into vertex/fragment or compute.
30    ///
31    /// Pipeline shaders use comment markers to delimit sections:
32    /// - `//V`, `//VERT`, `//vertex`, etc. start the vertex section
33    /// - `//F`, `//FRAG`, `//fragment`, etc. start the fragment section
34    ///
35    /// Compute shaders use the entire source as one stage.
36    fn parse(src: &str, typ: &ShaderType) -> Self {
37        if typ.is_compute() {
38            return GLSL::ParsedCompute(src.to_string());
39        }
40
41        let mut v_src = String::new();
42        let mut f_src = String::new();
43        let mut v_found = false;
44        let mut f_found = false;
45        let mut cur = &mut v_src;
46
47        for line in src.lines() {
48            let line = line.trim();
49            match line {
50                "//v" | "//V" | "//vert" | "//VERT" | "//vertex" | "//VERTEX"
51                | "// v" | "// V" | "// vert" | "// VERT" | "// vertex" | "// VERTEX" => {
52                    cur = &mut v_src;
53                    v_found = true;
54                }
55                "//f" | "//F" | "//frag" | "//FRAG" | "//fragment" | "//FRAGMENT"
56                | "// f" | "// F" | "// frag" | "// FRAG" | "// fragment" | "// FRAGMENT" => {
57                    cur = &mut f_src;
58                    f_found = true;
59                }
60                _ => {
61                    cur.push_str(line);
62                    cur.push('\n');
63                }
64            }
65        }
66
67        let v_missing = v_src.is_empty() || !v_found;
68        let f_missing = f_src.is_empty() || !f_found;
69
70        if v_missing || f_missing {
71            GLSL::FailedPipeline { v_missing, _f_missing: f_missing }
72        } else {
73            GLSL::ParsedPipeline { v_src, f_src }
74        }
75    }
76}
77
78/// A shader loaded from disk (or cache), ready to compile.
79///
80/// # Loading
81///
82/// ```ignore
83/// use optic_render::asset::{ShaderFile, ShaderType};
84///
85/// let sf = ShaderFile::from_disk("shaders/example.glsl", ShaderType::Pipeline)?;
86/// let shader = sf.compile()?; // returns a Shader handle
87/// ```
88///
89/// # Shader format
90///
91/// Pipeline shaders use comment markers to separate vertex and fragment stages
92/// within a single `.glsl` file:
93///
94/// ```glsl
95/// // V
96/// void main() {
97///     gl_Position = vec4(0.0);
98/// }
99/// // F
100/// void main() {
101///     outColor = vec4(1.0);
102/// }
103/// ```
104pub struct ShaderFile {
105    pub v_src: String,
106    pub f_src: String,
107    pub is_compute: bool,
108}
109
110impl ShaderFile {
111    /// Creates a `ShaderFile` by parsing a combined GLSL source string.
112    pub fn from_src(src: &str, typ: ShaderType) -> OpticResult<Self> {
113        match GLSL::parse(src, &typ) {
114            GLSL::ParsedCompute(src) => Ok(Self {
115                v_src: src.clone(),
116                f_src: String::new(),
117                is_compute: true,
118            }),
119            GLSL::ParsedPipeline { v_src, f_src } => Ok(Self {
120                v_src,
121                f_src,
122                is_compute: false,
123            }),
124            GLSL::FailedPipeline { v_missing, _f_missing: _ } => {
125                if v_missing {
126                    Err(OpticError::new(OpticErrorKind::Shader, "vertex shader section missing"))
127                } else {
128                    Err(OpticError::new(OpticErrorKind::Shader, "fragment shader section missing"))
129                }
130            }
131        }
132    }
133
134    /// Creates a pipeline shader from separate vertex and fragment source strings.
135    pub fn from_vert_frag(v_src: &str, f_src: &str) -> Self {
136        Self {
137            v_src: v_src.to_string(),
138            f_src: f_src.to_string(),
139            is_compute: false,
140        }
141    }
142
143    /// Compiles this shader and returns a [`Shader`](crate::handles::Shader) handle.
144    pub fn compile(&self) -> OpticResult<Shader> {
145        if self.is_compute {
146            let id = link_compute_program(&self.v_src)?;
147            Ok(Shader::new(id, true))
148        } else {
149            let id = link_program(&self.v_src, &self.f_src)?;
150            Ok(Shader::new(id, false))
151        }
152    }
153}
154
155// --- from_disk: debug loads source + overwrites cache; release loads cache only ---
156#[cfg(debug_assertions)]
157impl ShaderFile {
158    /// Loads a shader from disk, caching it for release builds.
159    pub fn from_disk(path: &str, typ: ShaderType) -> OpticResult<Self> {
160        let src = optic_file::read_string(path)?;
161        let shader = Self::from_src(&src, typ)?;
162        let cache = optic_file::cached_path(path, "oshdr");
163        shader.save_cached(&cache)?;
164        Ok(shader)
165    }
166}
167
168#[cfg(not(debug_assertions))]
169impl ShaderFile {
170    /// Loads a shader from the binary cache (release only).
171    pub fn from_disk(path: &str, _typ: ShaderType) -> OpticResult<Self> {
172        let cache = optic_file::cached_path(path, "oshdr");
173        Self::from_cached(&cache)
174    }
175}
176
177// --- binary cache read/write (internal) ---
178impl ShaderFile {
179    /// Saves this shader to a binary cache file.
180    pub fn save_cached(&self, path: &str) -> OpticResult<()> {
181        let typ_byte = if self.is_compute { SHADER_COMPUTE } else { SHADER_PIPELINE };
182        let v_bytes = self.v_src.as_bytes();
183        let f_bytes = self.f_src.as_bytes();
184        let mut data = Vec::with_capacity(13 + v_bytes.len() + f_bytes.len());
185        data.extend_from_slice(&OPTIC_MAGIC);
186        data.extend_from_slice(&OPTIC_CACHE_VERSION.to_le_bytes());
187        data.push(typ_byte);
188        data.extend_from_slice(&(v_bytes.len() as u32).to_le_bytes());
189        data.extend_from_slice(v_bytes);
190        data.extend_from_slice(&(f_bytes.len() as u32).to_le_bytes());
191        data.extend_from_slice(f_bytes);
192        optic_file::write_bytes(path, &data)
193    }
194
195    /// Loads a shader from a binary cache file.
196    #[cfg_attr(debug_assertions, allow(dead_code))]
197    fn from_cached(path: &str) -> OpticResult<Self> {
198        let data = optic_file::read_bytes(path)?;
199        if data.len() < 15 {
200            return Err(OpticError::new(OpticErrorKind::Asset, &format!("cached shader too short: {path}")));
201        }
202        if data[0..8] != OPTIC_MAGIC {
203            return Err(OpticError::new(OpticErrorKind::Asset, &format!("not a valid Optic cache file (bad magic): {path}")));
204        }
205        let version = u16::from_le_bytes([data[8], data[9]]);
206        if version != OPTIC_CACHE_VERSION {
207            return Err(OpticError::new(OpticErrorKind::Asset, &format!(
208                "cache file version {version} is not supported (expected {OPTIC_CACHE_VERSION}): {path}"
209            )));
210        }
211        let is_compute = data[10] == SHADER_COMPUTE;
212
213        let mut off = 11usize;
214        let v_len = u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]) as usize;
215        off += 4;
216        if off + v_len > data.len() {
217            return Err(OpticError::new(OpticErrorKind::Asset, &format!("truncated cached shader (vertex section): {path}")));
218        }
219        let v_src = String::from_utf8(data[off..off + v_len].to_vec())
220            .map_err(|_| OpticError::new(OpticErrorKind::Asset, &format!("invalid UTF-8 in cached shader: {path}")))?;
221        off += v_len;
222
223        if off + 4 > data.len() {
224            return Err(OpticError::new(OpticErrorKind::Asset, &format!("truncated cached shader (fragment length): {path}")));
225        }
226        let f_len = u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]) as usize;
227        off += 4;
228        if off + f_len > data.len() {
229            return Err(OpticError::new(OpticErrorKind::Asset, &format!("truncated cached shader (fragment section): {path}")));
230        }
231        let f_src = if f_len > 0 {
232            String::from_utf8(data[off..off + f_len].to_vec())
233                .map_err(|_| OpticError::new(OpticErrorKind::Asset, &format!("invalid UTF-8 in cached shader: {path}")))?
234        } else {
235            String::new()
236        };
237
238        Ok(Self { v_src, f_src, is_compute })
239    }
240}
241
242impl ShaderFile {
243    /// Loads the default 3D pipeline shader from `optic/assets/shdr/fallback3d.glsl`.
244    pub fn default_3d() -> OpticResult<Self> {
245        Self::from_disk("optic/assets/shdr/fallback3d.glsl", ShaderType::Pipeline)
246    }
247
248    /// Loads the default 2D pipeline shader from `optic/assets/shdr/fallback2d.glsl`.
249    pub fn default_2d() -> OpticResult<Self> {
250        Self::from_disk("optic/assets/shdr/fallback2d.glsl", ShaderType::Pipeline)
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn parse_compute_shader() {
260        let src = "#version 430\nvoid main() {}";
261        let asset = ShaderFile::from_src(src, ShaderType::Compute).unwrap();
262        assert!(asset.is_compute);
263        assert_eq!(asset.v_src, src);
264        assert!(asset.f_src.is_empty());
265    }
266
267    #[test]
268    fn parse_pipeline_shader() {
269        let src = "// v\nvoid vertex_main() {}\n// f\nvoid fragment_main() {}";
270        let asset = ShaderFile::from_src(src, ShaderType::Pipeline).unwrap();
271        assert!(!asset.is_compute);
272        assert!(asset.v_src.contains("vertex_main"));
273        assert!(asset.f_src.contains("fragment_main"));
274    }
275
276    #[test]
277    fn parse_pipeline_missing_vertex() {
278        let src = "// f\nvoid fragment_main() {}";
279        let result = ShaderFile::from_src(src, ShaderType::Pipeline);
280        assert!(result.is_err());
281    }
282
283    #[test]
284    fn parse_pipeline_missing_fragment() {
285        let src = "// v\nvoid vertex_main() {}";
286        let result = ShaderFile::from_src(src, ShaderType::Pipeline);
287        assert!(result.is_err());
288    }
289
290    #[test]
291    fn parse_pipeline_empty_source() {
292        let result = ShaderFile::from_src("", ShaderType::Pipeline);
293        assert!(result.is_err());
294    }
295
296    #[test]
297    fn parse_pipeline_various_markers() {
298        let cases = vec![
299            ("//VERT\nv\n//FRAG\nf", "v", "f"),
300            ("//vertex\nv\n//fragment\nf", "v", "f"),
301            ("// V\nv\n// F\nf", "v", "f"),
302        ];
303        for (src, v_exp, f_exp) in cases {
304            let asset = ShaderFile::from_src(src, ShaderType::Pipeline).unwrap();
305            assert!(asset.v_src.trim().contains(v_exp));
306            assert!(asset.f_src.trim().contains(f_exp));
307        }
308    }
309
310    #[test]
311    fn shader_cached_roundtrip_pipeline() {
312        let src = "// VERTEX\nvoid main() {}\n// FRAGMENT\nvoid main() {}";
313        let asset = ShaderFile::from_src(src, ShaderType::Pipeline).unwrap();
314        let path = "/tmp/optic_test_shdr_pipe.oshdr";
315        asset.save_cached(path).unwrap();
316        let loaded = ShaderFile::from_cached(path).unwrap();
317        assert!(!loaded.is_compute);
318        assert_eq!(loaded.v_src, asset.v_src);
319        assert_eq!(loaded.f_src, asset.f_src);
320        let _ = std::fs::remove_file(path);
321    }
322
323    #[test]
324    fn shader_cached_roundtrip_compute() {
325        let src = "#version 430\nvoid main() {}";
326        let asset = ShaderFile::from_src(src, ShaderType::Compute).unwrap();
327        let path = "/tmp/optic_test_shdr_comp.oshdr";
328        asset.save_cached(path).unwrap();
329        let loaded = ShaderFile::from_cached(path).unwrap();
330        assert!(loaded.is_compute);
331        assert_eq!(loaded.v_src, src);
332        let _ = std::fs::remove_file(path);
333    }
334
335    #[test]
336    fn shader_type_is_compute() {
337        assert!(ShaderType::Compute.is_compute());
338        assert!(!ShaderType::Pipeline.is_compute());
339    }
340
341    #[test]
342    fn from_vert_frag() {
343        let asset = ShaderFile::from_vert_frag("v_src", "f_src");
344        assert!(!asset.is_compute);
345        assert_eq!(asset.v_src, "v_src");
346        assert_eq!(asset.f_src, "f_src");
347    }
348}