Annexe N · Bruit procédural

Noise — smoothstep, fade, gradients, FBM

Le bruit procédural (ou noise) est la base de tout contenu généré — terrain, nuages, textures, animations organiques. Ce chapitre couvre des fonctions utilitaires, du plus simple au plus complexe.

smoothstep — la courbe en S

smoothstep est intégrée dans MSL. Elle produit une interpolation douce entre 0 et 1 avec des dérivées nulles aux extrémités — pas de saut brutal.

// MSL natif :
float s = smoothstep(edge0, edge1, x);
// → 0 si x ≤ edge0, 1 si x ≥ edge1, courbe entre les deux

// Formule interne :
// t = clamp((x - edge0) / (edge1 - edge0), 0, 1)
// return t * t * (3 - 2 * t)

// Visualisation :
//  1 ┤              ╭──────
//    │         ╭───╯
//    │    ╭───╯
//  0 ┼────╯
//       e0          e1

inline float inverse_smoothstep(float x)
{
    return 0.5 - sin(asin(1.0 - 2.0 * x) / 3.0);
}

smoothstep vs smootherstep

// smoothstep — Ken Perlin (1ère version)
float smoothstep(float t)
{
    return t * t * (3.0 - 2.0 * t);
}

// smootherstep — Ken Perlin (version améliorée, dérivée 2 aussi nulle)
float smootherstep(float t)
{
    return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
}

// Différence : smootherstep a les dérivées 1ère ET 2ème nulles aux extrémités
// → pas d'artefact de gradient visible dans les textures de bruit

fade() — le smootherstep de Perlin

// Dans Helpers.metal :
inline float fade(float t)
{
    return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
}

// Utilisée dans perlinNoise3D pour interpoler sur X, Y, Z :
float3 u = float3(fade(pf.x), fade(pf.y), fade(pf.z));

// Pourquoi pas lerp direct ?
// Sans fade : les gradients de bruit créent des discontinuités visibles
// aux frontières des cellules — le bruit devient "en damier"
// Avec fade : transitions invisibles, texture organique

gradient3D() — vecteurs de gradient

Le bruit de Perlin associe un vecteur gradient à chaque coin de la grille. Le produit scalaire entre ce gradient et la distance au coin donne la contribution de ce coin.

// Dans Helpers.metal — gradient3D(hash) :
inline float3 gradient3D(uint hash)
{
    uint h = hash & 15; // 16 gradients possibles
    float u = h < 8 ?  1.0 : -1.0;
    float v = h < 4 ?  1.0 : (h == 12 || h == 14 ?  1.0 : -1.0);
    float w = h < 2 ?  1.0 : (h == 12 || h == 13 ? -1.0 :  1.0);
    return normalize(float3(u, v, w));
}

// Ce sont 16 des 12 arêtes d'un cube unité
// → équidistribués sur la sphère unité
// → minimise les artefacts directionnels

// Dans perlinNoise3D :
float3 g000 = gradient3D(aaa);
float n000  = dot(g000, pf - float3(0, 0, 0)); // contribution du coin (0,0,0)

perlinNoise3D() — le pipeline complet

inline float perlinNoise3D(float3 p, constant uint* perm)
{
    float3 pi = floor(p);           // coin entier
    float3 pf = fract(p);           // fraction dans la cellule
    int3   i  = int3(pi) & 255;     // index dans la table de permutation
    float3 u  = float3(
        fade(pf.x), fade(pf.y), fade(pf.z)  // courbe d'interpolation
    );

    // 8 coins du cube → hash → gradient
    uint aaa = perm[perm[perm[i.x  ] + i.y  ] + i.z  ];
    uint baa = perm[perm[perm[i.x+1] + i.y  ] + i.z  ];
    // ... (6 autres)

    // Produit scalaire gradient · distance pour les 8 coins
    float n000 = dot(gradient3D(aaa), pf);
    // ... (7 autres)

    // Interpolation trilinéaire avec fade
    float nx00 = mix(n000, n100, u.x);
    float nxy0 = mix(nx00, nx10, u.y);
    return mix(nxy0, nxy1, u.z);  // résultat ∈ [-1, 1]
}
La table perm[]
perm est une table de permutation de 256 entiers (0–255) mélangés une fois côté CPU. Elle garantit que le hash est déterministe et reproductible. À passer via constant uint* perm [[buffer(n)]] depuis le CPU.

Simplex Noise 2D — plus rapide que Perlin

Le simplex noise utilise une grille triangulaire (simplex) plutôt que cubique. En 2D : 3 coins au lieu de 4. En 3D : 4 coins au lieu de 8. Moins d'artefacts directionnels, plus rapide.

inline float simplexNoise2D(float2 p, uint seed)
{
    // F2 et G2 : facteurs de skew pour passer grille carrée ↔ grille simplex
    const float F2 = 0.5 * (sqrt(3.0) - 1.0); // ≈ 0.366
    const float G2 = (3.0 - sqrt(3.0)) / 6.0;  // ≈ 0.211

    // 1. Skew — trouver la cellule simplex
    float s = (p.x + p.y) * F2;
    float2 pi = floor(p + s);

    // 2. Unskew — position dans la cellule
    float t = (pi.x + pi.y) * G2;
    float2 p0 = p - (pi - t);

    // 3. Déterminer quel triangle (p0.x > p0.y → triangle bas, sinon haut)
    int2 i1 = p0.x > p0.y ? int2(1, 0) : int2(0, 1);

    // 4. Contributions des 3 coins (atténuation radiale)
    float t0 = 0.5 - dot(p0, p0);
    if (t0 > 0.0) { t0 *= t0; n0 = t0 * t0 * dot(g0, p0); }

    return 70.0 * (n0 + n1 + n2); // ∈ [-1, 1]
}

FBM — Fractal Brownian Motion

Le FBM superpose plusieurs octaves de bruit à fréquences et amplitudes croissantes — chaque octave ajoute du détail fin. C'est la base du terrain procédural, des nuages, des textures organiques.

inline float fbm2D(float2 p, int octaves, float persistence,
                   float lacunarity, uint seed)
{
    float value     = 0.0;
    float amplitude = 1.0;  // réduit à chaque octave (persistence)
    float frequency = 1.0;  // double à chaque octave (lacunarity)
    float maxValue  = 0.0;  // pour normaliser en fin de boucle

    for (int i = 0; i < octaves; i++) {
        value    += amplitude * simplexNoise2D(p * frequency, seed + i * 1337);
        maxValue += amplitude;
        amplitude *= persistence;  // typiquement 0.5 → chaque octave = moitié moins fort
        frequency *= lacunarity;   // typiquement 2.0 → chaque octave = 2× plus fin
    }

    return value / maxValue; // normalise dans [-1, 1]
}

// Usage terrain :
float height = fbm2D(worldPos.xz * 0.01, 6, 0.5, 2.0, seed);
//              6 octaves, persistence 0.5, lacunarity 2.0 (standard)
Paramètre Valeur typique Effet si augmenté
octaves 4–8 Plus de détail fin — coût GPU linéaire
persistence 0.5 Surface plus rugueuse, chaotique
lacunarity 2.0 Détails plus fins entre octaves

Domain Warping — tordre l'espace

Le domain warping (Inigo Quilez) utilise du FBM pour déformer les coordonnées d'entrée d'un autre FBM. Résultat : formes organiques impossibles à obtenir autrement — rivières, formations rocheuses, nuages tourbillonnants.

// Dans ton Helpers.metal — warpedNoise() :
inline float warpedNoise(float2 p, uint seed)
{
    // Passe 1 : q = FBM(p)  — première déformation
    float2 q = float2(
        fbm2D(p,             4, 0.5, 2.0, seed),
        fbm2D(p + float2(5.2, 1.3), 4, 0.5, 2.0, seed)
    );

    // Passe 2 : r = FBM(p + q)  — deuxième déformation, utilise q comme offset
    float2 r = float2(
        fbm2D(p + 4.0 * q + float2(1.7, 9.2), 4, 0.5, 2.0, seed),
        fbm2D(p + 4.0 * q + float2(8.3, 2.8), 4, 0.5, 2.0, seed)
    );

    // Résultat : FBM(p + r)  — troisième passe, déformée deux fois
    return fbm2D(p + 4.0 * r, 4, 0.5, 2.0, seed);
}

// Coût : 10× fbm2D avec 4 octaves = 40 appels simplex par pixel
// À utiliser sur texture basse résolution puis upscale, pas par vertex en temps réel

Voronoi — cellules organiques

Le bruit de Voronoi associe chaque point à son "centre de cellule" le plus proche. Utilisé pour les biomes, les textures de pierre, les veines.

inline VoronoiResult voronoi2D(float2 p, uint seed)
{
    float2 pi = floor(p);
    float2 pf = fract(p);

    VoronoiResult result;
    result.distance1 = 100.0; // distance au centre le plus proche
    result.distance2 = 100.0; // distance au 2ème centre (pour les bords)

    // Tester les 9 cellules voisines
    for (int y = -1; y <= 1; y++) {
        for (int x = -1; x <= 1; x++) {
            uint h = hash2(uint2(pi + float2(x,y)) + seed);
            // Point aléatoire à l'intérieur de la cellule voisine
            float2 point = float2(x,y) + float2(hashToFloat(h), hashToFloat(hashPerlin(h)));
            float dist = length(pf - point);
            // Garder les deux plus proches
            if (dist < result.distance1) { result.distance2 = result.distance1; result.distance1 = dist; }
            else if (dist < result.distance2) { result.distance2 = dist; }
        }
    }
    return result;
}

// Usages :
float cell = result.distance1;              // remplissage de cellule
float edge = result.distance2 - result.distance1; // bords entre cellules
uint  id   = result.cellId;                 // couleur unique par cellule (biome)