alice-sdf 6.0.0

ALICE-SDF: 3D/Spatial Data Specialist — Store laws, not polygons. GLSL/WGSL/HLSL transpile, SVO, GPU marching cubes, texture-fit, procedural CSG. Optional bridges to alice-physics / alice-codec / libasp / alice-cache behind features.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
//! Nanite Cluster Export for UE5
//!
//! Exports NaniteMesh data to `.nanite` binary format for UE5 Nanite import.
//! Also supports JSON cluster manifest for custom UE5 import plugins.
//!
//! # Formats
//! - `.nanite` — Compact binary with cluster hierarchy
//! - `.nanite.json` — JSON manifest with cluster metadata
//!
//! Author: Moroya Sakamoto

use crate::io::IoError;
use crate::mesh::nanite::NaniteMesh;
use std::io::Write;
use std::path::Path;

/// Magic bytes for the .nanite binary format
pub const NANITE_MAGIC: &[u8; 4] = b"NANT";

/// Current binary format version
///
/// A reader must check the version and reject (or convert) other versions
/// instead of reading them as the current one:
///
/// - version 3: after the material id, every cluster carries 36 more bytes:
///   the LOD sphere of its group (center x / y / z, radius), the error of the
///   parent group (`+inf` for a root group) and the parent group's LOD sphere
///   (center x / y / z, radius; equal to the cluster's LOD sphere for a root
///   group). Those are what the cut needs per cluster: draw a cluster when its
///   error over the distance to its LOD sphere is within the threshold (or it
///   has no children) and its parent's is not. Versions 1 and 2 carried
///   neither sphere, so a reader could not evaluate the cut without
///   regenerating the groups
/// - version 2: a cluster's `geometric_error` is the measured surface error of
///   its group, never smaller than the errors of its child groups; the parent
///   ids are the clusters of the enclosing region at the next coarser level
///   with geometry, the child ids the clusters of the regions it encloses; the
///   group count counts one group per region and level. The cut that draws a
///   cluster when its group's projected error is within the threshold and its
///   parent group's is not covers the surface exactly once
/// - version 1: `geometric_error` was a size heuristic that could be smaller
///   for a parent than for its child, the parent / child ids linked clusters of
///   adjacent levels whose bounding spheres overlapped, and there was one group
///   per level (versions 1 and 2 have the same byte layout)
pub const NANITE_VERSION: u32 = 3;

/// Configuration for Nanite export
#[derive(Debug, Clone)]
pub struct NaniteExportConfig {
    /// Export UV coordinates per vertex
    pub export_uvs: bool,
    /// Export normals per vertex
    pub export_normals: bool,
    /// Quantize positions to 16-bit (halves file size)
    pub quantize_positions: bool,
}

impl Default for NaniteExportConfig {
    fn default() -> Self {
        Self {
            export_uvs: true,
            export_normals: true,
            quantize_positions: false,
        }
    }
}

/// Export NaniteMesh to binary .nanite format
pub fn export_nanite(nanite: &NaniteMesh, path: impl AsRef<Path>) -> Result<(), IoError> {
    export_nanite_with_config(nanite, path, &NaniteExportConfig::default())
}

/// Export NaniteMesh to binary .nanite format with configuration
pub fn export_nanite_with_config(
    nanite: &NaniteMesh,
    path: impl AsRef<Path>,
    config: &NaniteExportConfig,
) -> Result<(), IoError> {
    let file = std::fs::File::create(path)?;
    let mut w = std::io::BufWriter::new(file);

    // Count totals
    let total_vertices: u32 = nanite
        .clusters
        .iter()
        .map(|c| c.vertices.len() as u32)
        .sum();
    let total_triangles: u32 = nanite
        .clusters
        .iter()
        .map(|c| c.triangles.len() as u32)
        .sum();

    // Header (32 bytes)
    w.write_all(NANITE_MAGIC)?;
    w.write_all(&NANITE_VERSION.to_le_bytes())?;
    w.write_all(&(nanite.clusters.len() as u32).to_le_bytes())?;
    w.write_all(&(nanite.groups.len() as u32).to_le_bytes())?;
    w.write_all(&(nanite.lod_levels.len() as u32).to_le_bytes())?;
    w.write_all(&total_vertices.to_le_bytes())?;
    w.write_all(&total_triangles.to_le_bytes())?;
    // Flags: bit0=uvs, bit1=normals, bit2=quantized
    let flags: u32 = (config.export_uvs as u32)
        | ((config.export_normals as u32) << 1)
        | ((config.quantize_positions as u32) << 2);
    w.write_all(&flags.to_le_bytes())?;

    // LOD levels
    for lod in &nanite.lod_levels {
        w.write_all(&lod.level.to_le_bytes())?;
        w.write_all(&lod.resolution.to_le_bytes())?;
        w.write_all(&lod.max_error.to_le_bytes())?;
        w.write_all(&lod.triangle_count.to_le_bytes())?;
    }

    // Clusters
    for cluster in &nanite.clusters {
        w.write_all(&cluster.id.to_le_bytes())?;
        w.write_all(&cluster.lod_level.to_le_bytes())?;
        w.write_all(&(cluster.vertices.len() as u32).to_le_bytes())?;
        w.write_all(&(cluster.triangles.len() as u32).to_le_bytes())?;

        // Bounds
        w.write_all(&cluster.bounds.center.x.to_le_bytes())?;
        w.write_all(&cluster.bounds.center.y.to_le_bytes())?;
        w.write_all(&cluster.bounds.center.z.to_le_bytes())?;
        w.write_all(&cluster.bounds.radius.to_le_bytes())?;
        w.write_all(&cluster.bounds.aabb_min.x.to_le_bytes())?;
        w.write_all(&cluster.bounds.aabb_min.y.to_le_bytes())?;
        w.write_all(&cluster.bounds.aabb_min.z.to_le_bytes())?;
        w.write_all(&cluster.bounds.aabb_max.x.to_le_bytes())?;
        w.write_all(&cluster.bounds.aabb_max.y.to_le_bytes())?;
        w.write_all(&cluster.bounds.aabb_max.z.to_le_bytes())?;
        w.write_all(&cluster.geometric_error.to_le_bytes())?;
        w.write_all(&cluster.material_id.to_le_bytes())?;

        // LOD spheres of the group and of the parent group (version 3)
        for f in [
            cluster.lod_bounds.center.x,
            cluster.lod_bounds.center.y,
            cluster.lod_bounds.center.z,
            cluster.lod_bounds.radius,
            cluster.parent_error,
            cluster.parent_lod_bounds.center.x,
            cluster.parent_lod_bounds.center.y,
            cluster.parent_lod_bounds.center.z,
            cluster.parent_lod_bounds.radius,
        ] {
            w.write_all(&f.to_le_bytes())?;
        }

        // Parent/child DAG
        w.write_all(&(cluster.parent_ids.len() as u32).to_le_bytes())?;
        w.write_all(&(cluster.child_ids.len() as u32).to_le_bytes())?;
        for &pid in &cluster.parent_ids {
            w.write_all(&pid.to_le_bytes())?;
        }
        for &cid in &cluster.child_ids {
            w.write_all(&cid.to_le_bytes())?;
        }

        // Vertices
        for v in &cluster.vertices {
            w.write_all(&v.position.x.to_le_bytes())?;
            w.write_all(&v.position.y.to_le_bytes())?;
            w.write_all(&v.position.z.to_le_bytes())?;
            if config.export_normals {
                w.write_all(&v.normal.x.to_le_bytes())?;
                w.write_all(&v.normal.y.to_le_bytes())?;
                w.write_all(&v.normal.z.to_le_bytes())?;
            }
            if config.export_uvs {
                w.write_all(&v.uv.x.to_le_bytes())?;
                w.write_all(&v.uv.y.to_le_bytes())?;
            }
        }

        // Triangles
        for t in &cluster.triangles {
            w.write_all(&t.a.to_le_bytes())?;
            w.write_all(&t.b.to_le_bytes())?;
            w.write_all(&t.c.to_le_bytes())?;
        }
    }

    w.flush()?;
    Ok(())
}

/// Export NaniteMesh metadata to JSON manifest
pub fn export_nanite_json(nanite: &NaniteMesh, path: impl AsRef<Path>) -> Result<(), IoError> {
    let file = std::fs::File::create(path)?;
    let mut w = std::io::BufWriter::new(file);

    writeln!(w, "{{")?;
    writeln!(w, "  \"generator\": \"ALICE-SDF\",")?;
    writeln!(w, "  \"format_version\": {},", NANITE_VERSION)?;
    writeln!(w, "  \"cluster_count\": {},", nanite.clusters.len())?;
    writeln!(w, "  \"group_count\": {},", nanite.groups.len())?;

    // LOD levels
    writeln!(w, "  \"lod_levels\": [")?;
    for (i, lod) in nanite.lod_levels.iter().enumerate() {
        let comma = if i + 1 < nanite.lod_levels.len() {
            ","
        } else {
            ""
        };
        writeln!(w, "    {{\"level\": {}, \"resolution\": {}, \"max_error\": {:.6}, \"triangle_count\": {}}}{comma}",
            lod.level, lod.resolution, lod.max_error, lod.triangle_count)?;
    }
    writeln!(w, "  ],")?;

    // Clusters summary
    writeln!(w, "  \"clusters\": [")?;
    for (i, cluster) in nanite.clusters.iter().enumerate() {
        let comma = if i + 1 < nanite.clusters.len() {
            ","
        } else {
            ""
        };
        writeln!(w, "    {{")?;
        writeln!(w, "      \"id\": {},", cluster.id)?;
        writeln!(w, "      \"lod_level\": {},", cluster.lod_level)?;
        writeln!(w, "      \"vertex_count\": {},", cluster.vertices.len())?;
        writeln!(w, "      \"triangle_count\": {},", cluster.triangles.len())?;
        writeln!(
            w,
            "      \"geometric_error\": {:.6},",
            cluster.geometric_error
        )?;
        writeln!(w, "      \"material_id\": {},", cluster.material_id)?;
        writeln!(w, "      \"bounds\": {{")?;
        writeln!(
            w,
            "        \"center\": [{:.6}, {:.6}, {:.6}],",
            cluster.bounds.center.x, cluster.bounds.center.y, cluster.bounds.center.z
        )?;
        writeln!(w, "        \"radius\": {:.6},", cluster.bounds.radius)?;
        writeln!(
            w,
            "        \"aabb_min\": [{:.6}, {:.6}, {:.6}],",
            cluster.bounds.aabb_min.x, cluster.bounds.aabb_min.y, cluster.bounds.aabb_min.z
        )?;
        writeln!(
            w,
            "        \"aabb_max\": [{:.6}, {:.6}, {:.6}]",
            cluster.bounds.aabb_max.x, cluster.bounds.aabb_max.y, cluster.bounds.aabb_max.z
        )?;
        writeln!(w, "      }},")?;
        let sphere = |b: &crate::mesh::nanite::ClusterBounds| {
            format!(
                "{{\"center\": [{:.6}, {:.6}, {:.6}], \"radius\": {:.6}}}",
                b.center.x, b.center.y, b.center.z, b.radius
            )
        };
        writeln!(w, "      \"lod_bounds\": {},", sphere(&cluster.lod_bounds))?;
        // JSON has no infinity: a root group's parent error is null
        if cluster.parent_error == f32::INFINITY {
            writeln!(w, "      \"parent_error\": null,")?;
        } else {
            writeln!(w, "      \"parent_error\": {:.6},", cluster.parent_error)?;
        }
        writeln!(
            w,
            "      \"parent_lod_bounds\": {},",
            sphere(&cluster.parent_lod_bounds)
        )?;
        writeln!(w, "      \"parent_ids\": {:?},", cluster.parent_ids)?;
        writeln!(w, "      \"child_ids\": {:?}", cluster.child_ids)?;
        writeln!(w, "    }}{comma}")?;
    }
    writeln!(w, "  ]")?;

    writeln!(w, "}}")?;
    w.flush()?;
    Ok(())
}

/// Export a Nanite-compatible HLSL material function (.usf)
///
/// Generates a procedural material function that can be used with UE5's
/// custom material nodes. The SDF tree is transpiled to HLSL and wrapped
/// in a material-ready function.
///
/// # Feature Requirements
/// Requires the `hlsl` feature to be enabled. Build with:
/// ```bash
/// cargo build --features hlsl
/// ```
#[cfg(feature = "hlsl")]
pub fn export_nanite_hlsl_material(
    sdf: &crate::types::SdfNode,
    path: impl AsRef<Path>,
) -> Result<(), IoError> {
    use crate::compiled::hlsl::{HlslShader, HlslTranspileMode};

    let file = std::fs::File::create(path)?;
    let mut w = std::io::BufWriter::new(file);

    writeln!(w, "// ALICE-SDF Nanite Material Function")?;
    writeln!(w, "// Auto-generated procedural material for UE5 Nanite")?;
    writeln!(
        w,
        "// Usage: Add as Custom Expression in UE5 Material Editor"
    )?;
    writeln!(w, "// Input: float3 WorldPosition")?;
    writeln!(w, "// Output: float4 (xyz = normal, w = signed_distance)")?;
    writeln!(w)?;

    // Transpile SDF tree to HLSL function
    let shader = HlslShader::transpile(sdf, HlslTranspileMode::Hardcoded);
    w.write_all(shader.source.as_bytes())?;
    writeln!(w)?;

    // Add normal computation helper
    writeln!(w, "// Normal computation via central differences")?;
    writeln!(w, "float3 AliceSdfNormal(float3 p)")?;
    writeln!(w, "{{")?;
    writeln!(w, "    const float eps = 0.001;")?;
    writeln!(w, "    return normalize(float3(")?;
    writeln!(
        w,
        "        sdf_eval(p + float3(eps, 0, 0)) - sdf_eval(p - float3(eps, 0, 0)),"
    )?;
    writeln!(
        w,
        "        sdf_eval(p + float3(0, eps, 0)) - sdf_eval(p - float3(0, eps, 0)),"
    )?;
    writeln!(
        w,
        "        sdf_eval(p + float3(0, 0, eps)) - sdf_eval(p - float3(0, 0, eps))"
    )?;
    writeln!(w, "    ));")?;
    writeln!(w, "}}")?;
    writeln!(w)?;

    // Material entry point
    writeln!(w, "// Material entry point for UE5")?;
    writeln!(w, "// Returns: float4(normal.xyz, signed_distance)")?;
    writeln!(w, "float4 AliceSdfMaterial(float3 WorldPosition)")?;
    writeln!(w, "{{")?;
    writeln!(w, "    float d = sdf_eval(WorldPosition);")?;
    writeln!(w, "    float3 n = AliceSdfNormal(WorldPosition);")?;
    writeln!(w, "    return float4(n, d);")?;
    writeln!(w, "}}")?;

    w.flush()?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::mesh::nanite::{generate_nanite_mesh, NaniteConfig};
    use crate::types::SdfNode;

    fn make_test_nanite() -> NaniteMesh {
        let sphere = SdfNode::sphere(1.0);
        generate_nanite_mesh(
            &sphere,
            glam::Vec3::splat(-2.0),
            glam::Vec3::splat(2.0),
            &NaniteConfig::default(),
        )
    }

    #[test]
    fn test_nanite_export() {
        let nanite = make_test_nanite();
        let path = std::env::temp_dir().join("alice_test.nanite");

        export_nanite(&nanite, &path).unwrap();

        let data = std::fs::read(&path).unwrap();
        assert!(data.len() >= 32);
        assert_eq!(&data[0..4], b"NANT");

        let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
        assert_eq!(version, NANITE_VERSION);

        std::fs::remove_file(&path).ok();
    }

    #[test]
    fn test_nanite_json_export() {
        let nanite = make_test_nanite();
        let path = std::env::temp_dir().join("alice_test.nanite.json");

        export_nanite_json(&nanite, &path).unwrap();

        let content = std::fs::read_to_string(&path).unwrap();
        assert!(content.contains("\"generator\": \"ALICE-SDF\""));
        assert!(content.contains("\"cluster_count\""));
        assert!(content.contains("\"lod_levels\""));

        std::fs::remove_file(&path).ok();
    }

    #[test]
    #[cfg(feature = "hlsl")]
    fn test_nanite_hlsl_material_export() {
        let sphere = SdfNode::sphere(1.0);
        let path = std::env::temp_dir().join("alice_test_nanite_material.usf");

        export_nanite_hlsl_material(&sphere, &path).unwrap();

        let content = std::fs::read_to_string(&path).unwrap();
        assert!(content.contains("ALICE-SDF"));
        assert!(content.contains("AliceSdfMaterial"));
        assert!(content.contains("AliceSdfNormal"));

        std::fs::remove_file(&path).ok();
    }
}