bevy_symbios_texture 0.6.0

Algorithmic texture generator for Bevy.
Documentation
//! Roof shingle / tile texture generator using overlapping gradient fields.
//!
//! The algorithm:
//! 1. Divide V into rows; stagger alternate rows horizontally.
//! 2. Build a sawtooth height ramp within each row (0 at the exposed lower edge,
//!    1 at the top where the shingle slides under the row above).
//! 3. Apply the shape function: `Square` (0.0) is a flat ramp; `Scalloped` (1.0)
//!    carves a half-circle from the bottom of each shingle.
//! 4. Add moss/algae noise near the lower exposed edge, weighted by `moss_level`.
//! 5. Blend a thin grout line at the row boundary for the sharp shadow step that
//!    makes the overlap visible.

use noise::{Fbm, MultiFractal, Perlin};

use crate::{
    generator::{TextureError, TextureGenerator, TextureMap, Workspace, validate_dimensions},
    noise::{ToroidalNoise, normalize, sample_grid_into},
    surface::{SurfaceCell, SurfaceSample, generate_surface, lerp},
};

/// Configures the appearance of a [`ShingleGenerator`].
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct ShingleConfig {
    /// PRNG seed for the deterministic noise pattern; different seeds give
    /// statistically-different textures from otherwise-identical configs.
    pub seed: u32,
    /// Number of shingle rows across the tile.
    pub scale: f64,
    /// Shape profile blended between square (`0.0`) and scalloped (`1.0`).
    pub shape_profile: f64,
    /// Fraction of each shingle hidden under the row above \[0, 0.8\].
    /// `0.5` = half-lap (standard); `0.0` = no overlap (flat tiles).
    pub overlap: f64,
    /// Horizontal stagger of alternate rows as a fraction of shingle width
    /// \[0, 1\].  `0.5` = running bond.
    pub stagger: f64,
    /// Moss / algae growth intensity on the lower exposed edge \[0, 1\].
    pub moss_level: f64,
    /// Primary tile colour in linear RGB \[0, 1\].
    pub color_tile: [f32; 3],
    /// Shadow / grout colour between rows in linear RGB \[0, 1\].
    pub color_grout: [f32; 3],
    /// Normal-map strength.  Higher values exaggerate the overlap step.
    pub normal_strength: f32,
}

impl Default for ShingleConfig {
    fn default() -> Self {
        Self {
            seed: 42,
            scale: 5.0,
            shape_profile: 0.5,
            overlap: 0.45,
            stagger: 0.5,
            moss_level: 0.18,
            color_tile: [0.40, 0.25, 0.18],
            color_grout: [0.18, 0.14, 0.12],
            normal_strength: 5.0,
        }
    }
}

/// Procedural roof-shingle / tile texture generator.
///
/// Drives [`TextureGenerator::generate`] using a [`ShingleConfig`].  Construct
/// via [`ShingleGenerator::new`] and call `generate` directly, or spawn a
/// [`crate::async_gen::PendingTexture::shingle`] task for non-blocking generation.
///
/// Noise objects are built in the constructor so that calling `generate`
/// multiple times (e.g. producing size variants of the same material)
/// does not repeat the initialisation cost.
pub struct ShingleGenerator {
    config: ShingleConfig,
    surf_noise: ToroidalNoise<Fbm<Perlin>>,
    moss_noise: ToroidalNoise<Fbm<Perlin>>,
}

impl ShingleGenerator {
    /// Create a new generator with the given configuration.
    ///
    /// Builds the noise objects up front so that repeated
    /// calls to [`generate`](TextureGenerator::generate) skip initialisation.
    pub fn new(config: ShingleConfig) -> Self {
        let fbm_surf: Fbm<Perlin> = Fbm::new(config.seed).set_octaves(4);
        let surf_noise = ToroidalNoise::new(fbm_surf, config.scale * 3.0);

        let fbm_moss: Fbm<Perlin> = Fbm::new(config.seed.wrapping_add(100)).set_octaves(3);
        let moss_noise = ToroidalNoise::new(fbm_moss, config.scale * 1.5);

        Self {
            config,
            surf_noise,
            moss_noise,
        }
    }
}

/// Per-generation sampler: surface + moss grids and the derived layout
/// constants (rounded scale, exposed fraction).
struct ShingleCell<'a> {
    config: &'a ShingleConfig,
    surf_grid: &'a [f64],
    moss_grid: &'a [f64],
    /// `scale` rounded to the nearest integer so the grid tiles exactly.
    scale: f64,
    /// Exposed (visible) fraction of each shingle from the bottom.
    exposed: f64,
    width: usize,
}

/// Thin grout / shadow band at the bottom of each exposed portion.
const GROUT_FRAC: f64 = 0.06;

impl SurfaceCell for ShingleCell<'_> {
    fn sample(&self, x: u32, y: u32, u: f64, v: f64) -> SurfaceSample {
        let c = self.config;

        let v_scaled = v * self.scale;
        let row_id = v_scaled.floor() as i64;
        let v_frac = v_scaled.fract(); // 0 = bottom of cell, 1 = top

        // Stagger: shift U by row_id × stagger so alternate rows offset.
        let u_stagger = (u + row_id as f64 * c.stagger).rem_euclid(1.0);
        let col_id = (u_stagger * self.scale).floor() as i64;
        let u_frac = (u_stagger * self.scale).fract(); // 0..1 within shingle cell

        // Per-shingle colour variance hash.
        let cv = cell_hash(col_id, row_id, c.seed);

        // ── Height ramp ──────────────────────────────────────────────
        // The sawtooth ramp: 0 at the exposed bottom edge, ramps up to 1
        // at the top.  Only the [0, exposed] portion is visible; above
        // that is hidden under the shingle from the row above.
        let ramp = (v_frac / self.exposed).clamp(0.0, 1.0);

        // ── Shape function ───────────────────────────────────────────
        // `shape_profile=0`: pure ramp (square / flat shingle).
        // `shape_profile=1`: scalloped — carve a half-circle from the
        //   bottom corners so the exposed edge is convex in the middle.
        let dx = (u_frac - 0.5).abs() * 2.0; // 0 at centre, 1 at edge
        let scallop_drop = c.shape_profile
            * (1.0 - (1.0 - dx * dx).sqrt()) // half-circle profile
            * (1.0 - ramp).powi(2); // only affects bottom edge
        let ramp_shaped = (ramp - scallop_drop).clamp(0.0, 1.0);

        // ── Grout / shadow line ──────────────────────────────────────
        let in_grout = v_frac < GROUT_FRAC && ramp_shaped < 0.15;

        let idx = y as usize * self.width + x as usize;
        let surf = normalize(self.surf_grid[idx]);
        let moss_raw = normalize(self.moss_grid[idx]);

        // Moss grows on the lower exposed portion of each shingle.
        let moss_weight =
            c.moss_level * moss_raw * (1.0 - ramp_shaped).powi(3) * (1.0 - in_grout as i32 as f64);

        if in_grout {
            // Grout / shadow line between rows.  The pre-driver code wrote
            // the roughness byte as `(0.92 * 255.0) as u8` — truncation to
            // 234 — so express it as 234/255 for the driver's rounding to
            // reproduce the same byte.
            SurfaceSample::matte(0.0, c.color_grout, 234.0 / 255.0)
        } else {
            // Shingle surface with micro-detail and moss.
            let h_val = (ramp_shaped * (0.9 + surf * 0.1) - moss_weight * 0.05).clamp(0.0, 1.0);

            // Tile colour: jitter per cell, darken with moss.
            let jitter = (cv - 0.5) * 0.12;
            let moss_green = [0.15f32, 0.28, 0.10];
            let base_r = (c.color_tile[0] + jitter as f32).clamp(0.0, 1.0);
            let base_g = (c.color_tile[1] + jitter as f32 * 0.8).clamp(0.0, 1.0);
            let base_b = (c.color_tile[2] + jitter as f32 * 0.5).clamp(0.0, 1.0);
            let color = [
                lerp(base_r, moss_green[0], moss_weight as f32),
                lerp(base_g, moss_green[1], moss_weight as f32),
                lerp(base_b, moss_green[2], moss_weight as f32),
            ];

            // ORM: lower (exposed) areas and moss are rougher.
            let rough = 0.55 + (1.0 - ramp_shaped as f32) * 0.3 + moss_weight as f32 * 0.1;

            SurfaceSample::matte(h_val, color, rough)
        }
    }
}

impl ShingleGenerator {
    fn generate_inner(
        &self,
        width: u32,
        height: u32,
        mut ws: Option<&mut Workspace>,
    ) -> Result<TextureMap, TextureError> {
        validate_dimensions(width, height)?;
        let c = &self.config;

        // Surface micro-detail FBM (toroidal for seamless tiling).
        let mut surf_grid = ws.as_deref_mut().map_or_else(Vec::new, |w| w.take_grid());
        sample_grid_into(&self.surf_noise, width, height, &mut surf_grid);

        // Moss noise — low frequency, toroidal.
        let mut moss_grid = ws.as_deref_mut().map_or_else(Vec::new, |w| w.take_grid());
        sample_grid_into(&self.moss_noise, width, height, &mut moss_grid);

        let cell = ShingleCell {
            config: c,
            surf_grid: &surf_grid,
            moss_grid: &moss_grid,
            scale: c.scale.round(),
            exposed: (1.0 - c.overlap).clamp(0.05, 1.0),
            width: width as usize,
        };
        let result = generate_surface(width, height, c.normal_strength, ws.as_deref_mut(), &cell);

        if let Some(ws) = ws {
            ws.return_grid(surf_grid);
            ws.return_grid(moss_grid);
        }
        result
    }
}

impl TextureGenerator for ShingleGenerator {
    fn generate(&self, width: u32, height: u32) -> Result<TextureMap, TextureError> {
        self.generate_inner(width, height, None)
    }

    fn generate_with_workspace(
        &self,
        width: u32,
        height: u32,
        workspace: &mut Workspace,
    ) -> Result<TextureMap, TextureError> {
        self.generate_inner(width, height, Some(workspace))
    }
}

// --- helpers ----------------------------------------------------------------

fn cell_hash(col: i64, row: i64, seed: u32) -> f64 {
    let mut h = seed as u64;
    h ^= (col as u64).wrapping_mul(6_364_136_223_846_793_005);
    h ^= (row as u64).wrapping_mul(1_442_695_040_888_963_407);
    h ^= h >> 33;
    h = h.wrapping_mul(0xff51_afd7_ed55_8ccd);
    h ^= h >> 33;
    (h as f64) * (1.0 / u64::MAX as f64)
}