L'éclair procédural

Un pipeline R&D en cours - propagateur Houdini, passation Alembic, rendu Blender

Statut : R&D, pas une technique finalisée. Ceci est une exploration active, pas un pipeline de production éprouvé au combat - les valeurs ci-dessous sont des points de départ encore en cours de validation, pas des réglages par défaut prouvés sur des plans réels. Traitez le code comme une base à adapter, pas une solution clé en main.

Vue d'ensemble du pipeline

La répartition est délibérée : Houdini gère la simulation procédurale du trajet de l'éclair - géométrie et attributs uniquement - et Blender gère le rendu final - shading émissif, bloom, compositing. Les deux communiquent via Alembic (.abc, format Ogawa), qui transporte de façon fiable la géométrie de courbes et les attributs de points personnalisés.

Trois façons de générer un éclair

Fractale L-System - des règles de réécriture récursives, simples à piloter via le SOP L-System natif de Houdini. Un bon point de départ pédagogique, mais la ramification paraît trop régulière pour un réalisme de production.

Simulation Pyro/VDB - un trajet probabiliste éclairé de l'intérieur par un shader pyro émissif. Donne de bons halos et un glow volumétrique mais est difficile à contrôler précisément et coûteux à calculer - moins pertinent ici puisque le rendu est de toute façon délégué à Blender.

DLA / Dielectric Breakdown (l'approche utilisée ci-dessous) - simuler un champ électrique à travers une grille, avec des « leaders » avançant vers le chemin de moindre résistance. La plus physiquement précise des trois, implémentable en VEX sur une grille de champ de potentiel - c'est la logique derrière le propre Labs Lightning SOP de SideFX.

La véritable structure d'un éclair

Il vaut la peine de comprendre la physique avant d'écrire l'algorithme. Le stepped leader - le canal principal - avance par sauts discrets plutôt qu'en ligne continue, chaque pas étant une décision probabiliste pilotée par le champ électrique. Les branches mortes sont des bifurcations qui s'arrêtent faute de potentiel pour continuer, et elles restent visibles comme le résidu caractéristique d'un vrai impact. Le return stroke est le flash lumineux quand le leader atteint sa cible et que le canal s'illumine sur toute sa longueur en retour - très bref, une à trois images. Les streamers sont les filaments lumineux qui persistent après le return stroke, s'estompant exponentiellement sur 8-15 images. Les splits se produisent quand le canal principal se divise en deux branches de force comparable, quand deux chemins de potentiel similaire coexistent.

Spatialiser le trajet en 3D

Quatre techniques, généralement combinées. Un champ de potentiel VDB modélise le champ électrique entre source et cible comme un VDB flottant ; l'éclair descend le gradient avec du bruit fractal pour la déviation locale - la méthode la plus physiquement correcte. Une courbe guide avec attraction définit la trajectoire générale, avec un Point Attract ou un champ de vélocité gardant les branches à l'intérieur d'un volume autour d'elle - bien adapté à une direction artistique propre à un plan. L'évitement d'obstacles par SDF échantillonne le SDF de la géométrie de la scène à chaque pas via volumesample(), permettant à l'éclair d'éviter des solides ou de les rechercher (un paratonnerre, une surface conductrice). Le scatter sur surface cible place des points pondérés sur une surface cible (normale + distance + bruit) pour des arcs de surface - un éclair rampant sur une roche, frappant un objet spécifique. En production, une courbe guide pour le contrôle artistique macro combinée à un champ VDB pour le comportement physique micro est la combinaison la plus solide.

Le réseau de nœuds

La chaîne complète, de la source à l'export :

[GEO_SOURCE]   [GEO_TARGET]
      |               |
  [scatter]       [scatter]
      |               |
[wrangle_init_source]  [wrangle_init_target]
           |
      [merge]  <- source + target dans une seule geo
           |
   [for_each: wrangle_propagator]  <- boucle DLA itérative
           |
   [wrangle_tortuosity]  <- bruit haute fréquence sur la position
           |
   [add (by attribute: parent_id)]  <- reconnecte les points en courbes
           |
   [wrangle_width]  <- rayon depuis l'énergie + branch_depth
           |
   [resample]  <- longueur de segment uniforme
           |
   [polywire]  <- maille les tubes depuis la largeur
           |
   [normal]  <- normales de points propres pour Blender
           |
   [rop_alembic]

Deux points source alimentent le système : un seul point dispersé sur la géométrie source (le leader initial - énergie pleine, profondeur 0) et un seul point dispersé sur la cible (une sentinelle passive que le propagateur recherche, marquée avec branch_depth = -1). Les fusionner en une seule geo permet à une seule boucle wrangle de gérer les deux extrémités.

Le Wrangle propagateur

Celui-ci s'exécute à l'intérieur d'un bloc For-Each (Block Begin réglé sur Fetch Input, Block End réglé sur Feedback Each Iteration - sans ce réglage la boucle n'est pas cumulative, et les points créés à l'itération N n'existent simplement pas pour l'itération N+1). Chaque extrémité active, par itération : trouve la position cible, mélange 60% de sa direction précédente avec 40% d'attraction vers la cible (inertie contre les changements de direction brusques), ajoute une déviation de bruit fractal, avance d'un pas, fait décroître son énergie, et effectue un test de probabilité pour générer une branche avant de se désactiver comme extrémité.

if (i@is_tip == 0) return;

float step_len    = chf("step_length");    // défaut 0.15
float chaos       = chf("chaos");          // défaut 0.4
float freq        = chf("noise_freq");     // défaut 1.2
float p_branch1   = chf("prob_branch1");   // profondeur 0->1, défaut 0.25
float p_branch2   = chf("prob_branch2");   // profondeur 1->2, défaut 0.18
float energy_main = chf("energy_decay_main");   // défaut 0.88
float energy_b1   = chf("energy_decay_b1");     // défaut 0.60
float energy_b2   = chf("energy_decay_b2");     // défaut 0.55
float min_energy  = chf("min_energy");     // seuil d'arrêt, défaut 0.08
int   max_depth   = chi("max_depth");      // défaut 2 (3 niveaux : 0,1,2)
int   seed_offset = chi("seed");           // défaut 42

vector target_pos = {0, -5, 0};
int npts = npoints(0);
for (int k = 0; k < npts; k++) {
    if (point(0, "branch_depth", k) == -1) {
        target_pos = point(0, "P", k);
        break;
    }
}

vector to_target = normalize(target_pos - v@P);
vector prev_dir = normalize(v@dir);
vector base_dir = normalize(lerp(prev_dir, to_target, 0.4));

vector noise_offset = set(
    noise(v@P * freq + set(seed_offset * 0.1, 0, 0)),
    noise(v@P * freq + set(0, seed_offset * 0.1, 0)),
    noise(v@P * freq + set(0, 0, seed_offset * 0.1))
);
noise_offset = fit(noise_offset, set(0,0,0), set(1,1,1), set(-1,-1,-1), set(1,1,1));

vector dir = normalize(base_dir + noise_offset * chaos);
v@dir = dir;

if (f@energy < min_energy) { i@is_tip = 0; return; }

vector new_pos = v@P + dir * step_len;
int new_pt = addpoint(0, new_pos);

int   new_depth  = i@branch_depth;
float new_energy = f@energy;
if      (new_depth == 0) new_energy *= energy_main;
else if (new_depth == 1) new_energy *= energy_b1;
else                      new_energy *= energy_b2;

setpointattrib(0, "branch_depth", new_pt, new_depth);
setpointattrib(0, "is_tip",       new_pt, 1);
setpointattrib(0, "parent_id",    new_pt, i@ptnum);
setpointattrib(0, "energy",       new_pt, new_energy);
setpointattrib(0, "dir",          new_pt, dir);
i@is_tip = 0;

if (new_depth < max_depth) {
    float p_branch = (new_depth == 0) ? p_branch1 : p_branch2;
    float rng = rand(i@ptnum * 1973 + seed_offset + @Time * 100);
    if (rng < p_branch * f@energy) {
        vector up = set(0, 1, 0);
        if (abs(dot(dir, up)) > 0.95) up = set(1, 0, 0);
        vector side = normalize(cross(dir, up));
        float twist = fit01(rand(i@ptnum * 3571 + seed_offset), -1.2, 1.2);
        vector branch_dir = normalize(side * cos(twist) + cross(dir, side) * sin(twist));
        branch_dir = normalize(branch_dir + to_target * 0.3);
        int branch_pt = addpoint(0, v@P + branch_dir * step_len * 0.85);
        float branch_energy = (new_depth == 1) ? f@energy * energy_b2 : f@energy * energy_b1;
        setpointattrib(0, "branch_depth", branch_pt, new_depth + 1);
        setpointattrib(0, "is_tip",       branch_pt, 1);
        setpointattrib(0, "parent_id",    branch_pt, i@ptnum);
        setpointattrib(0, "energy",       branch_pt, branch_energy);
        setpointattrib(0, "dir",          branch_pt, branch_dir);
    }
}

Le nombre d'itérations du Block End (40-80 dans les premiers tests) définit jusqu'où le leader voyage et combien de générations de branches apparaissent. Pour de vrais splits plutôt qu'une ramification unilatérale, le propagateur peut s'exécuter simultanément depuis la source et la cible et se connecter là où les deux fronts se rencontrent - disponible dans le mode avancé du Labs Lightning SOP, pas encore intégré dans cette version.

Largeur, tortuosité et tubes

Le squelette macro sort du propagateur ; une passe de bruit haute fréquence séparée sur la position des points ajoute ensuite le micro-jitter rapide caractéristique d'un vrai éclair, les branches étant plus bruitées que le canal principal (tort_amp_br autour de 0.035 contre tort_amp_main autour de 0.02). La largeur est dérivée de l'énergie et de la profondeur - un canal principal plus épais, des sous-branches plus fines, s'amenuisant vers zéro à mesure que l'énergie d'une branche s'épuise, ce qui est ce qui vend une branche morte comme réellement morte plutôt que juste arrêtée abruptement :

float base_w = (i@branch_depth == 0) ? chf("width_main")
             : (i@branch_depth == 1) ? chf("width_b1") : chf("width_b2");
f@width = base_w * fit(f@energy, 0.0, 1.0, 0.1, 1.0);
if (i@branch_depth == 0 && f@energy > 0.6) f@width *= 1.25; // plus épais aux fourches

Les points se reconnectent en courbes via un Add SOP réglé pour construire des polygones selon l'attribut parent_id - aucun câblage manuel nécessaire, bien qu'un résultat bruité ou déconnecté signifie généralement qu'une valeur de parent_id erronée s'est glissée en dehors des sentinelles source/cible. Une passe Resample (longueur de segment uniforme, traitement en courbe de subdivision) nettoie la courbe avant que PolyWire ne la maille en tubes, lisant le rayon directement depuis l'attribut de point width - PolyWire gère le rayon par point et les jonctions de fourche plus proprement qu'un Sweep ici. Les normales de points sont retirées avant l'export et laissées à Blender pour recalcul, car les normales Alembic importées tendent à produire des artefacts côté Blender.

La passation Alembic vers Blender

Réglages d'export qui comptent : format Ogawa (plus léger et plus rapide que HDF5), Single Partition pour un seul objet (ou partition par branch_depth pour des objets Blender séparés par niveau), Build Hierarchy activé, et une liste explicite d'attributs de points - energy life is_main branch_depth width - plutôt que « Save All », qui traîne des attributs internes de Houdini (pscale, id, is_tip, parent_id) qui ne font rien dans Blender et alourdissent simplement le fichier.

Ce qui passe proprementCe qui ne passe pas
width - lu nativement comme rayon de courbeAttributs de type string - entièrement ignorés
Attributs de points float/int - via le Named Attribute des Geometry NodesAttributs de primitive (pas de point) - parfois perdus
Hiérarchie de courbes, si construite proprement dans HoudiniNoms d'attribut avec caractères spéciaux

Côté Blender, l'import Alembic arrive comme un objet courbe ; une configuration Geometry Nodes relit les attributs via des nœuds Named Attribute (correspondant exactement au nom défini dans Houdini) et reconstruit les tubes, prêts pour un shader émissif multi-couches.

Ce qui reste non validé

Voici l'état actuel d'une exploration active, pas une boucle fermée - il vaut la peine d'être franc sur ce qui n'a pas encore été prouvé plutôt que de le présenter comme terminé :

  • Les vrais splits bidirectionnels (propagation depuis la source et la cible simultanément) ne sont pas implémentés dans cette version
  • Le timing du return stroke (un attribut life animé pilotant le flash) n'a pas été construit ni testé
  • La passation complète Alembic Ogawa → attributs Blender 4.x n'a pas encore été exécutée de bout en bout
  • La configuration Geometry Nodes côté Blender pour la reconstruction des tubes depuis les courbes importées n'existe pas encore
  • Le shader émissif multi-couches (cœur blanc, corona bleue, halo extérieur) n'est encore qu'un plan

Avant de faire confiance à un premier test : confirmer que le Block End est bien réglé sur Feedback Each Iteration (toute la boucle ne fait silencieusement rien de cumulatif sans cela), colorer les points par branch_depth avant le Add SOP pour vérifier la cohérence de la structure arborescente, vérifier le Spreadsheet pour des valeurs de parent_id erronées avant de connecter les courbes, et exporter une seule image en Alembic avant de s'engager sur une séquence animée complète.