Skip to main content

celestialsim/
custom_surface.rs

1//! Custom **GPU** surface layers: splice a user's terrain GLSL into the library
2//! template and drive the per-slot surface buffers on the render device.
3//!
4//! This is the code-authored terrain extension point for consumers who want
5//! procedural terrain that runs entirely on the GPU (no CPU bake pool). The user
6//! writes a small `.glsl` file defining `terrain_height` / `terrain_color` (and
7//! optionally `terrain_normal`); [`assemble_source`] wraps it in
8//! [`TEMPLATE`](self) — which owns the per-texel gnomonic direction mapping, the
9//! finite-difference auto-normal, and the writes into the same
10//! `surface_color/height/normal` buffers the built-in realize/bake shaders read
11//! when `surface_enabled == 1`. Godot's `RenderingDevice` compiles the assembled
12//! GLSL at runtime, so **no `slangc` and nothing new ships** — it reuses Godot's
13//! own shader compiler. The compiled pipeline runs as the
14//! `celestial/chunk-surface-custom` node (`crate::chunk_nodes::surface_custom`),
15//! between upload and realize.
16//!
17//! Contrast with [`crate::surface::CpuSurfaceProvider`], which bakes the same
18//! buffers on CPU worker threads (for data-driven / streaming terrain).
19
20/// The library GLSL template. The user's source replaces the
21/// `// __CELS_USER_CODE__` marker line.
22pub const TEMPLATE: &str = include_str!("../shaders/custom_surface.glsl");
23
24/// The marker line in [`TEMPLATE`] where user code is spliced in.
25const USER_CODE_MARKER: &str = "// __CELS_USER_CODE__";
26
27/// The marker line in [`TEMPLATE`] where per-param `#define`s are spliced
28/// (before the user code, so the user's functions can reference them).
29const USER_DEFINES_MARKER: &str = "// __CELS_USER_DEFINES__";
30
31/// Max generic user params (`@export var name: float`) surfaced to a GPU
32/// builder's `.glsl`. Matches the `float cels_user[16]` tail in the template's
33/// binding-4 `Params` block and the pad in [`pack_params`].
34pub const MAX_USER_PARAMS: usize = 16;
35
36/// Errors assembling a custom-surface shader from user GLSL.
37#[derive(Debug, PartialEq, Eq)]
38pub enum AssembleError {
39    /// The user source does not define a required function.
40    MissingFn(&'static str),
41    /// The template lost its user-code marker (a library bug, not user error).
42    TemplateMarkerMissing,
43}
44
45impl std::fmt::Display for AssembleError {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            AssembleError::MissingFn(name) => write!(
49                f,
50                "custom-surface GLSL must define `{name}` (see docs/custom_terrain_gpu.md)"
51            ),
52            AssembleError::TemplateMarkerMissing => {
53                write!(f, "custom-surface template is missing its user-code marker")
54            }
55        }
56    }
57}
58
59/// Splice `user_glsl` into [`TEMPLATE`], producing a complete compute shader.
60///
61/// Fails fast (before touching the GPU) if the user omitted a required function
62/// so the error names the missing symbol instead of surfacing as an opaque GLSL
63/// compile error. `terrain_normal` is optional and not checked.
64pub fn assemble_source(user_glsl: &str) -> Result<String, AssembleError> {
65    assemble_source_with_params(user_glsl, &[])
66}
67
68/// Like [`assemble_source`], but also splices a `#define` block — one line per
69/// `param_names` entry — at the defines marker so the user's `.glsl` can
70/// reference each `@export var name: float` on the builder as the UPPERCASE
71/// `NAME` (backed by `P.cels_user[<index>]`). Names past [`MAX_USER_PARAMS`] are
72/// ignored (they have no backing slot).
73pub fn assemble_source_with_params(
74    user_glsl: &str,
75    param_names: &[String],
76) -> Result<String, AssembleError> {
77    for required in ["terrain_height", "terrain_color"] {
78        if !user_glsl.contains(required) {
79            return Err(AssembleError::MissingFn(match required {
80                "terrain_height" => "terrain_height",
81                _ => "terrain_color",
82            }));
83        }
84    }
85    // Each marker MUST appear exactly once: `str::replace` is global, so a stray
86    // mention elsewhere (e.g. in a header comment) would also be replaced and
87    // inject content — with newlines — mid-comment, breaking the shader.
88    if TEMPLATE.matches(USER_CODE_MARKER).count() != 1
89        || TEMPLATE.matches(USER_DEFINES_MARKER).count() != 1
90    {
91        return Err(AssembleError::TemplateMarkerMissing);
92    }
93    let mut defines = String::new();
94    for (i, name) in param_names.iter().take(MAX_USER_PARAMS).enumerate() {
95        defines.push_str(&format!("#define {} (P.cels_user[{}])\n", name.to_uppercase(), i));
96    }
97    // Splice defines first, then user code (order independent — distinct markers).
98    let with_defines = TEMPLATE.replace(USER_DEFINES_MARKER, defines.trim_end());
99    Ok(with_defines.replace(USER_CODE_MARKER, user_glsl))
100}
101
102/// Pack the custom-surface params buffer (std430) read by the template's
103/// binding-4 `Params` block: the four fixed fields
104/// `{chunk_count, tile_res, water_height, height_scale}` followed by
105/// [`MAX_USER_PARAMS`] user floats (`cels_user[16]`). Extra `user` values are
106/// ignored; missing ones are zero-filled. Total = `16 + 4*MAX_USER_PARAMS` bytes.
107pub fn pack_params(
108    chunk_count: u32,
109    tile_res: u32,
110    water_height: f32,
111    height_scale: f32,
112    user: &[f32],
113) -> Vec<u8> {
114    let mut out = vec![0u8; 16 + 4 * MAX_USER_PARAMS];
115    out[0..4].copy_from_slice(&chunk_count.to_le_bytes());
116    out[4..8].copy_from_slice(&tile_res.to_le_bytes());
117    out[8..12].copy_from_slice(&water_height.to_le_bytes());
118    out[12..16].copy_from_slice(&height_scale.to_le_bytes());
119    for (i, v) in user.iter().take(MAX_USER_PARAMS).enumerate() {
120        let off = 16 + i * 4;
121        out[off..off + 4].copy_from_slice(&v.to_le_bytes());
122    }
123    out
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    const OK_USER: &str = r#"
131        float terrain_height(vec3 dir) { return 0.05 * sin(dir.x * 8.0); }
132        vec3 terrain_color(vec3 dir, float h) { return vec3(h, 0.5, 0.2); }
133    "#;
134
135    #[test]
136    fn assembles_user_code_into_template() {
137        let src = assemble_source(OK_USER).expect("valid user code assembles");
138        // The user body is present and the marker is gone.
139        assert!(src.contains("0.05 * sin(dir.x * 8.0)"));
140        assert!(!src.contains(USER_CODE_MARKER));
141        // Template scaffolding survives (entry point + one of the buffers).
142        assert!(src.contains("void main()"));
143        assert!(src.contains("surface_height["));
144    }
145
146    #[test]
147    fn missing_height_fn_is_rejected_before_gpu() {
148        let bad = "vec3 terrain_color(vec3 dir, float h) { return vec3(0.0); }";
149        assert_eq!(assemble_source(bad), Err(AssembleError::MissingFn("terrain_height")));
150    }
151
152    #[test]
153    fn missing_color_fn_is_rejected_before_gpu() {
154        let bad = "float terrain_height(vec3 dir) { return 0.0; }";
155        assert_eq!(assemble_source(bad), Err(AssembleError::MissingFn("terrain_color")));
156    }
157
158    #[test]
159    fn template_has_exactly_one_marker() {
160        // Exactly one: zero => no splice point; two+ => `replace` injects user
161        // code (with newlines) into a comment mention and corrupts the shader
162        // (the real bug this guards). A multi-line user body must land ONLY at
163        // the true marker.
164        assert_eq!(TEMPLATE.matches(USER_CODE_MARKER).count(), 1);
165    }
166
167    #[test]
168    fn multiline_user_code_assembles_without_corrupting_template() {
169        // The header must not carry a second marker mention: a real multi-line
170        // file (comments + several statements) must splice cleanly.
171        let multiline = "// a comment\nfloat terrain_height(vec3 dir) {\n  return 0.1;\n}\nvec3 terrain_color(vec3 dir, float h) {\n  return vec3(h);\n}\n";
172        let src = assemble_source(multiline).expect("multiline assembles");
173        // The template header text after the marker survives intact (proof the
174        // splice happened at the true marker, not inside the header comment).
175        assert!(src.contains("void main()"));
176        assert!(src.contains("cels_pack_rgba8"));
177    }
178
179    #[test]
180    fn pack_params_layout_fixed_head_plus_user_tail() {
181        let b = pack_params(3, 256, 0.45, 0.18, &[1.5, -2.0]);
182        assert_eq!(b.len(), 16 + 4 * MAX_USER_PARAMS);
183        assert_eq!(u32::from_le_bytes([b[0], b[1], b[2], b[3]]), 3);
184        assert_eq!(u32::from_le_bytes([b[4], b[5], b[6], b[7]]), 256);
185        assert_eq!(f32::from_le_bytes([b[8], b[9], b[10], b[11]]), 0.45);
186        assert_eq!(f32::from_le_bytes([b[12], b[13], b[14], b[15]]), 0.18);
187        // First two user floats land in the tail; the rest are zero-filled.
188        assert_eq!(f32::from_le_bytes([b[16], b[17], b[18], b[19]]), 1.5);
189        assert_eq!(f32::from_le_bytes([b[20], b[21], b[22], b[23]]), -2.0);
190        assert_eq!(f32::from_le_bytes([b[24], b[25], b[26], b[27]]), 0.0);
191    }
192
193    #[test]
194    fn pack_params_ignores_user_overflow() {
195        let many: Vec<f32> = (0..MAX_USER_PARAMS + 4).map(|i| i as f32).collect();
196        let b = pack_params(0, 0, 0.0, 0.0, &many);
197        assert_eq!(b.len(), 16 + 4 * MAX_USER_PARAMS);
198        // Last in-range slot is index MAX_USER_PARAMS-1.
199        let off = 16 + (MAX_USER_PARAMS - 1) * 4;
200        assert_eq!(
201            f32::from_le_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]]),
202            (MAX_USER_PARAMS - 1) as f32
203        );
204    }
205
206    #[test]
207    fn param_name_produces_define_at_expected_index() {
208        let src = assemble_source_with_params(OK_USER, &["ridge_sharpness".into(), "snow_line".into()])
209            .expect("assembles with params");
210        assert!(src.contains("#define RIDGE_SHARPNESS (P.cels_user[0])"));
211        assert!(src.contains("#define SNOW_LINE (P.cels_user[1])"));
212        // Markers are gone and the user body survives.
213        assert!(!src.contains(USER_DEFINES_MARKER));
214        assert!(!src.contains(USER_CODE_MARKER));
215        assert!(src.contains("void main()"));
216    }
217
218    #[test]
219    fn template_has_exactly_one_defines_marker() {
220        assert_eq!(TEMPLATE.matches(USER_DEFINES_MARKER).count(), 1);
221    }
222}