gpu: spike T01 — les atomiques WGSL tiennent, le readback non #184

Merged
Nora merged 27 commits from feature/editor-flame into develop 2026-09-12 10:10:12 +00:00
Owner

Un smoke opt-in (npm run smoke:gpu:atomic-splat, GPU requis, hors CI comme
tous les *.smoke.mjs) qui tranche le point bloquant du plan flame-ifs GPU.

Trois résultats, dont deux inattendus :

  1. atomic<u32> compile et compte juste sous @kmamal/gpu — 65 536 impacts,
    65 536 relus, aucune perte. La tranche ne meurt pas.

  2. La contention redoutée n'existe pas, et joue même à l'envers : la
    distribution concentrée (le coeur d'un attracteur) va DEUX FOIS plus vite que
    l'uniforme à points égaux. Localité L2 + agrégation d'atomiques par warp.
    Le splat monte à 2,2 milliards de points/s — filmer à la densité de
    l'estampe (20 M points, 9 ms) est gratuit, contre ~2670 ms sur CPU.

  3. Le mur se déplace sur la relecture : await mapAsync ne rend la main que sur
    un tic de ~100 ms (piège déjà au README §6). Une passe de 1 ms et une de
    30 ms se lisent donc pareil. Le smoke mesure en PENTE — 201 passes dans une
    seule soumission — sans quoi le cas 2,5 M sortait négatif.

Conséquence pour T04, consignée dans le plan : un seul readback par frame, tout
reste sur GPU jusqu'à la RGBA8 ; et surveiller le débordement u32, dont la marge
n'est que de ×11,6 sur le pixel le plus chaud à 20 M points.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt

Un smoke opt-in (`npm run smoke:gpu:atomic-splat`, GPU requis, hors CI comme tous les *.smoke.mjs) qui tranche le point bloquant du plan flame-ifs GPU. Trois résultats, dont deux inattendus : 1. `atomic<u32>` compile et compte juste sous @kmamal/gpu — 65 536 impacts, 65 536 relus, aucune perte. La tranche ne meurt pas. 2. La contention redoutée n'existe pas, et joue même à l'envers : la distribution concentrée (le coeur d'un attracteur) va DEUX FOIS plus vite que l'uniforme à points égaux. Localité L2 + agrégation d'atomiques par warp. Le splat monte à 2,2 milliards de points/s — filmer à la densité de l'estampe (20 M points, 9 ms) est gratuit, contre ~2670 ms sur CPU. 3. Le mur se déplace sur la relecture : `await mapAsync` ne rend la main que sur un tic de ~100 ms (piège déjà au README §6). Une passe de 1 ms et une de 30 ms se lisent donc pareil. Le smoke mesure en PENTE — 201 passes dans une seule soumission — sans quoi le cas 2,5 M sortait négatif. Conséquence pour T04, consignée dans le plan : un seul readback par frame, tout reste sur GPU jusqu'à la RGBA8 ; et surveiller le débordement u32, dont la marge n'est que de ×11,6 sur le pixel le plus chaud à 20 M points. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
gpu: spike T01 — les atomiques WGSL tiennent, le readback non
All checks were successful
CI / Renderer - couverture JS (pull_request) Has been skipped
CI / Renderer - audit des dépendances JS (pull_request) Successful in 1m21s
CI / PHP - tests + static + lint (pull_request) Successful in 9m3s
CI / Renderer - tests + lint JS (pull_request) Successful in 9m36s
f6cd4655fb
Un smoke opt-in (`npm run smoke:gpu:atomic-splat`, GPU requis, hors CI comme
tous les *.smoke.mjs) qui tranche le point bloquant du plan flame-ifs GPU.

Trois résultats, dont deux inattendus :

1. `atomic<u32>` compile et compte juste sous @kmamal/gpu — 65 536 impacts,
   65 536 relus, aucune perte. La tranche ne meurt pas.

2. La contention redoutée n'existe pas, et joue même à l'envers : la
   distribution concentrée (le coeur d'un attracteur) va DEUX FOIS plus vite que
   l'uniforme à points égaux. Localité L2 + agrégation d'atomiques par warp.
   Le splat monte à 2,2 milliards de points/s — filmer à la densité de
   l'estampe (20 M points, 9 ms) est gratuit, contre ~2670 ms sur CPU.

3. Le mur se déplace sur la relecture : `await mapAsync` ne rend la main que sur
   un tic de ~100 ms (piège déjà au README §6). Une passe de 1 ms et une de
   30 ms se lisent donc pareil. Le smoke mesure en PENTE — 201 passes dans une
   seule soumission — sans quoi le cas 2,5 M sortait négatif.

Conséquence pour T04, consignée dans le plan : un seul readback par frame, tout
reste sur GPU jusqu'à la RGBA8 ; et surveiller le débordement u32, dont la marge
n'est que de ×11,6 sur le pixel le plus chaud à 20 M points.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
Nora added 20 commits 2026-09-11 15:24:11 +00:00
Le plan découpait gros et le disait ; /atomiseur-spec le rend jouable fiche par
fiche. Trois blocs : les fondations pures (PRNG par chaîne, contrat WGSL/JS,
comparaison sous seuil), le moteur d'accumulation (sept fiches — c'est le gros
du travail, un deuxième GENRE de moteur à côté de FieldEngine), puis le pourtour
(aiguillage de motion, éditeur, gardes, mesures).

La cartographie du code a relevé trois divergences avec le plan, notées dans
l'index et dans les fiches concernées — le code fait foi :

- T08 cite `editor/target-path.mjs` comme siège des contrôles de plongée ; c'est
  en fait le garde de nom de fichier de `rendu-json/`. Les vrais contrôles sont
  les ids story-* de `editor/page.html`.
- T03 invoque un « hash perceptuel, comme le fait déjà le dépôt ». Le dépôt n'en
  a aucun : sa pratique est écart moyen + part de pixels sous seuil.
- T09 veut marquer « dans les bandes » un pipeline dont l'en-tête affirme qu'il
  n'en a aucune. Ce sera une surimpression.

Et un fait qui va dans le sens du plan : la collision de clé que T09 redoute
existe déjà, à la chaîne près — pipeline-video.js:187 et pipeline-video-gpu.mjs
écrivent le même `${canonicalHash}/video/${format.code}.mp4`. D'où une fiche qui
peut se jouer tout de suite, avant le moteur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
Le premier morceau du moteur d'accumulation : sur GPU, une invocation = une
marche aléatoire = son propre flux. Écrit en JS pur pour être vérifiable en CI,
là où le shader ne l'est pas ; `wgsl/flame-rng.wgsl.mjs` (fiche 02) interpolera
ces constantes plutôt que de les recopier.

PCG-XSH-RR 32/32 et pas sfc32 : il tient dans un seul mot d'état, ce qui compte
quand on en veut 65 536 en vol. Ce sont les constantes qui ont déjà compilé et
tourné en WGSL au spike T01.

Deux PRNG cohabitent désormais, et il ne faut pas les confondre :
generative/prng.js est celui de l'ŒUVRE (un flux, consommé à la suite, il décide
des octets de l'estampe) ; celui-ci est celui d'un THREAD. D'où la conséquence
assumée du plan §1 — la suite de points du GPU ne sera jamais celle du chaos
game CPU, et le reel GPU reste une maquette.

Le test vérifie position par position que deux chaînes voisines ne se
recouvrent pas : un recouvrement partiel passerait un « les listes diffèrent »,
et coûterait sur GPU la moitié des points sans rien signaler.

Suite complète : 2817/2819 verts, les deux échecs restants étant les
pipeline-slide connus (dérive SIMD libvips, antérieurs et sans rapport).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
Le shader ne peut pas tourner en CI (pas de carte sur le runner), mais son TEXTE
si. Le bloc WGSL interpole les constantes de gpu/flame-rng.mjs au lieu de les
recopier, et un test de contrat échoue si l'une manque : c'est ce qui empêche le
jumeau de diverger en silence — un littéral recopié passerait aujourd'hui et
mentirait au premier changement côté JS.

Trois gardes : les cinq constantes présentes, les trois fonctions que le splat
appellera (pcg, rnd, seedForChain), et aucun @group/@binding/@compute — le bloc
est concaténé DANS une source, pas compilé seul, donc un binding qu'il porterait
entrerait en collision avec ceux de la source d'accueil.

Le spike T01 garde sa copie du shader : un spike doit rester lisible seul, le
coupler au module qu'il vient d'autoriser n'apporterait rien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
La règle d'abord, l'outil ensuite. Le rendu GPU de la flamme ne sera jamais
comparé octet à octet au CPU : le chaos game CPU consomme un seul flux sfc32 à
la suite, le GPU en fait tourner des milliers en parallèle. La suite de points
diffère par construction — pas une imprécision à corriger, le modèle
d'exécution. L'en-tête du module le dit en toutes lettres ; c'est le livrable
autant que le code.

Ce qu'on compare à la place : l'écart moyen et la part de pixels sous seuil.
C'est la pratique RÉELLE du dépôt (deep-field-gpu.smoke.mjs, 2 / 4 / 0,99), pas
le « hash perceptuel » qu'évoquait le plan — vérifié, le dépôt n'en contient
aucun, et en écrire un pour un seul usage serait du YAGNI.

Trois gardes : un écart de bas de mantisse passe, une autre image échoue, et
l'échec NOMME les chiffres (sans eux on ne sait pas si on regarde une dérive de
plateforme ou un bug de portage). Plus un refus de comparer deux tailles
différentes : la boucle aurait rendu un verdict flatteur sur le plus court.

L'erreur est construite à la main, jamais via assert.deepEqual sur les buffers —
le diff de Myers se construit hors du tas V8 et gèle la machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
Un deuxième GENRE de moteur, à côté de field-engines.mjs — pas une quatrième
entrée dedans. Les douze algos portés sont des champs : une invocation par
pixel, stateless, une passe. La flamme est l'inverse : des millions de marches
aléatoires qui écrivent toutes dans le même histogramme atomique.

Deux propriétés valent d'être figées par un test :

- `reduce` passe APRÈS `blur`. C'est l'ordre de flame-ifs.js, qui recalcule son
  maximum après le flou et dit pourquoi (la moyenne 3×3 abaisse le pic).
  L'inverse normaliserait sur un pic absent de l'image : tone-map délavé, sans
  rien pour le nommer.
- seul `splat` dispatche par chaîne, les quatre autres par pixel. Le descripteur
  doit le dire, sinon la mécanique le devinerait avec un
  `if (entryPoint === 'splat')` — le switch qu'on refuse.

Le genre se lit aussi par l'absence : les trois moteurs de champ ne portent pas
de `kind`, et un test l'affirme, parce que la fabrique générique s'appuiera
dessus.

shaderSource/uniformLayout/pack arriveront avec les fiches 06 à 09 et leurs
propres tests : rien ici qui ne soit couvert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
Le jumeau multi-passes de field-gpu.mjs, même signature et même objet rendu, ce
qui permettra à la fabrique générique de servir l'un ou l'autre genre sans que
le pipeline sache lequel il tient. Dépendances @kmamal/gpu injectées : le module
reste pur et le câblage est couvert en CI par un faux appareil.

Le test central est une propriété de PERFORMANCE, et c'est T01 qui l'a rendue
mesurable : `await mapAsync` ne rend la main que sur un tic d'environ 100 ms,
quelle que soit la charge, quand le splat de 20 M de points coûte 9 ms. Relire
entre deux passes coûterait un tic à chaque fois — cinq par frame, un demi-
seconde de rien. D'où : une soumission, un readback, et il porte sur la RGBA8 de
sortie, pas sur l'histogramme (quatre fois plus gros).

Trois autres gardes : l'ordre des passes, le dispatch par chaîne qui ne suit pas
la grille des pixels (arrondi au-dessus des deux côtés, sinon la queue du
canevas n'est jamais balayée), et un `chains` manquant qui échoue AVANT d'encoder
quoi que ce soit — sans quoi zéro workgroup part et la frame sort noire, panne
muette d'une minute à diagnostiquer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
Le coeur du moteur : une invocation = une marche aléatoire, qui splatte par
atomiques dans un histogramme partagé. Port des cinq variations tirables de
lib/flame.js, de applyTransform, du tirage pondéré et du dégradé.

Le test lit DRAWABLE_VARIATIONS au lieu d'écrire la liste : le jour où le CPU
gagne une sixième variation tirable, il devient rouge tout seul. Même règle que
catalog-contract.test.js, qui interroge le catalogue et jamais une copie.

Trois choix qui méritent d'être dits :

- la flamme a son PROPRE dégradé (flameGradient, interpolé, stops[0] réservé au
  fond) plutôt que pickStop (bandes franches) ou sampleStops (cyclique). Les
  mélanger lisserait un algo qui bande, ou banderait un algo qui lisse — c'est
  l'avertissement en tête de palette.wgsl.mjs.
- garde de finitude explicite, appelée dans le splat. Le CPU skippe le point ET
  remet l'orbite à l'origine, sans quoi une singularité 1/r fige la chaîne sur
  l'infini pour tous ses pas restants ; côté GPU c'est pire, un NaN converti en
  u32 est indéfini et empoisonne l'histogramme en silence.
- MAX_TRANSFORMS = 4 (mapCount va de 2 à 4 au catalogue) : un tableau
  d'uniformes ne peut pas être de taille variable, et le packer refusera au-delà
  plutôt que de tronquer la banque sans le dire.

blur, reduce et toneMap arrivent avec les fiches 08 et 09.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
Un u32 qui déborde ne signale rien : il boucle sur zéro. Au rendu, ça se voit
comme un coeur d'attracteur NOIR — l'inverse exact de ce qu'on attend d'une
flamme, et aucun message pour le dire. Le seul endroit où ce risque peut se
voir, c'est un test.

Chiffres du spike T01 : à 20 M de points sur 450×800, le pixel le plus chaud
encaisse 1 449 893 impacts. Avec des incréments en 0..255 le canal rouge frôlait
8,6 % de 2³²-1 — ça passe, mais la marge n'est que de ×11,6, sur une
distribution MODÈLE moins pointue qu'une vraie flamme. D'où 0..15 (facteur 16
racheté) et une marge exigée de ×100, pas de ×2.

Contrepartie assumée et écrite : seize niveaux de teinte par impact, noyés de
toute façon par la log-densité (un pixel visible reçoit des milliers d'impacts).
Bouger la constante, c'est échanger de la marge contre de la finesse — que le
prochain lecteur le sache avant d'y toucher.

Le splat normalise désormais le dégradé : les stops arrivent en 0..255
(uniform-bytes.mjs, comme pour les trois moteurs de champ), la passe toneMap
défera la division.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
Port de boxBlur3x3 (bords tronqués : on divise par le nombre de voisins
réellement lus, pas par 9) et de la recherche du pic de densité.

Deux choses que l'écriture a révélées, et que les tests figent :

- on ne peut pas flouter EN PLACE. Un pixel lirait des voisins déjà écrasés par
  leur propre moyenne : le résultat dépendrait de l'ordre d'exécution des
  workgroups, donc changerait d'une frame à l'autre. D'où un second plan, et un
  test qui vérifie que `blur` lit hist et n'y réécrit jamais. La mécanique gagne
  le buffer correspondant, son test aussi — avec la raison écrite à côté.
- `reduce` lit le plan FLOUTÉ, jamais le brut. L'ordre du descripteur (fiche 04)
  ne vaut que si la lecture suit : le pic d'avant flou est plus haut, et
  normaliser dessus rendrait un tone-map délavé.

Pas d'arbre de réduction, un simple atomicMax dans un slot de queue : le spike
T01 a montré que la contention atomique n'est pas le monstre annoncé (une
distribution concentrée va deux fois PLUS vite qu'une uniforme, localité L2).
L'arbre aurait été du code à maintenir pour rien — la fiche le prévoyait.

Le `clear` remet le slot de maximum à zéro : sans ça une frame hériterait du pic
de la précédente et se normaliserait sur une image qu'elle ne montre plus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
La dernière passe du shader et les octets qui l'alimentent. Le moteur est
désormais complet : shaderSource, layout, byteLength et pack câblés sur le
descripteur, qui les avait laissés vides faute de test pour les couvrir.

Le packer lit les OFFSETS DU LAYOUT, jamais un littéral — règle du dossier, et
seule façon d'éviter qu'un champ déplacé dans le shader ne se lise ailleurs ici.
Il refuse loud deux choses plutôt que de les avaler : une banque plus grosse que
MAX_TRANSFORMS (tronquer changerait l'attracteur en silence) et une variation
sans branche dans le shader (`polar` existe côté CPU mais n'est pas tirable —
retomber sur 0 rendrait une sinusoidal sans un mot).

Le tone-map défait la division du splat : celui-ci accumule en 0..colorScale
pour ne pas déborder d'un u32, la couleur moyenne doit remonter en 0..255 avant
d'être composée. Sans ça l'image sortirait seize fois trop sombre — un test le
dit. Et il normalise sur le pic LU dans le slot de queue, jamais sur une
constante : sinon la luminosité clignoterait le long du clip.

flame-rng gagne `hashSeed` au passage : le shader ne sait pas lire de l'hexa,
c'est le packer qui lui descend les 32 premiers bits du canonical_hash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
createFieldForEngine résout désormais sa mécanique sur le GENRE du moteur :
createFieldGpu pour les champs (une invocation par pixel, une passe),
createAccumulationGpu pour la flamme (une invocation par chaîne, cinq passes).
Les deux rendant le même objet { render, dispose }, les appelants n'ont pas à
savoir lequel ils tiennent.

Écart avec la fiche, assumé : elle proposait de tester gpu-fields.mjs
directement. Impossible — ce module importe device.mjs, donc @kmamal/gpu, absent
du runner de CI et interdit de renderer/package.json (sandbox-isolation.test.mjs
le vérifie). Le test n'aurait jamais tourné là où il compte. D'où un module pur
à part, engine-mechanics.mjs, sans aucun import GPU ; gpu-fields.mjs n'en est
plus que le câblage — exactement ce qu'on avait déjà fait à field-gpu.mjs.

Un genre inconnu échoue en le nommant : retomber silencieusement sur le champ
compilerait un shader multi-passes comme s'il n'avait qu'un point d'entrée, pour
un 422 obscur une minute plus tard.

Suite complète : 2850/2852, les deux échecs restants étant les pipeline-slide
connus (dérive SIMD libvips, antérieurs et sans rapport).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
Le bloc « fondations pures » et le bloc « moteur » sont verts. L'index note les
deux écarts que l'atomisation n'avait pas vus — le flou qui ne peut pas se faire
en place (d'où un second plan dans la mécanique, et un test déjà vert étendu avec
sa raison) et gpu-fields.mjs qui n'est pas testable en CI puisqu'il importe
@kmamal/gpu — plus une simplification confirmée : pas d'arbre de réduction, un
simple atomicMax, comme la fiche 08 l'avait prévu sur la foi du spike T01.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
Échantillon, bornes robustes, projection : tout vient de lib/flame.js, rien n'est
réécrit. Le bac à sable COPIE le moteur de prod, il ne le modifie pas, et les
trois constantes (SAMPLE_POINTS, BOUNDS_TRIM, FRAME_MARGIN) sont recopiées à la
valeur près — ce sont des paramètres d'identité.

Sur CPU par décision du plan : 60 000 points, négligeables devant les 20 M de
l'accumulation, face à un tic de readback de 100 ms. Le porter sur GPU plus tard
si la mesure le réclame ; sur les chiffres de T01 c'est improbable.

Le shader n'a pas de closure : il reçoit scale/midX/midY. Ces trois nombres sont
extraits en SONDANT la projection CPU, jamais en recopiant sa formule — un jumeau
divergerait au premier changement de marge et personne ne le verrait. Le test le
vérifie là où ça compte : les trois nombres décrivent la MÊME application que la
fonction, en plusieurs points, pas seulement une qui lui ressemble.

Deux propriétés d'identité figées au passage : la rotation swirl ne consomme
aucun tirage (sans quoi le grain bouillonnerait d'une frame à l'autre), et
l'échantillon suit la géométrie sur le MÊME flux (un createRng remis à zéro
entre les deux cadrerait un autre attracteur que l'estampe, sans rien casser).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
Une entrée, et le registre porte désormais deux GENRES de moteur : fieldEngineFor
cherche dans les deux tables, le pipeline continue de ne connaître qu'un code
d'algo. flame-ifs devient filmable, et l'éditeur le marquera sans une ligne de
plus (editor/catalog.mjs projette supportedAlgorithms()).

Quatre `null` au descripteur, et c'est le sujet : formCode, power et baseRange
décrivent une FRACTALE — une formule dans le switch du shader, un exposant, une
largeur de plan complexe à filmer. Une flamme n'en a aucun ; les remplir
reviendrait à inventer des valeurs. Un test sépare donc les deux familles au lieu
d'exiger ces champs de tout le monde.

boundaryProbeFor jette au lieu de rendre undefined. La flamme se CADRE, elle ne
se plonge pas : « intérieur ? » et « près du bord ? » n'ont pas de sens sur un
attracteur. Un undefined aurait traversé fieldParamsFor puis planDive pour casser
une minute plus tard, loin de la cause.

Quatre tests existants sont passés au rouge en chemin — la fiche l'avait prévu.
Ils sont mis à jour, pas neutralisés : les boucles de sonde itèrent maintenant
les algos qui PLONGENT (helper `divingAlgorithms`, dérivé de PROBE_BY_ENGINE et
non d'une liste écrite à la main), et le compte de l'offre passe de 12 à 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
Le pipeline n'avait qu'une motion, en dur, et son en-tête l'assumait : « la
motion est TOUJOURS la plongée GPU ». Vrai tant que le bac à sable ne filmait que
des fractales. La flamme se CADRE automatiquement : les surcharges de viewport
d'une plongée y sont absorbées et toutes les frames reviendraient identiques —
exactement le bug qui a fait écrire la motion swirl côté CPU.

Deux entrées, une signature commune : le contexte passe entier, chaque
constructeur y prend ce qui le concerne (la plongée veut formule, dimensions et
sonde ; la flamme un nombre de tours). C'est ce qui permettra au pipeline de ne
pas savoir laquelle il tient — et donc de ne pas porter de switch sur un code
d'algo.

swirlPath est réutilisé TEL QUEL, pas recopié : il est pur, ne touche ni canvas
ni rng. Une seule rampe angulaire pour les deux moteurs.

Pas de repli ken-burns, contrairement au frameSourceFor de prod : ici un repli
silencieux se paierait d'un encodage complet avant de se voir.

Le test compare les angles à EPSILON près, jamais en strictEqual : cos(2π) ne
vaut pas exactement 1, et le dépôt s'est déjà fait prendre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
createRenderDeepZoomVideo appelait planDive sans condition. Pour une flamme,
c'était pire qu'inutile : plusieurs frames de sondage, puis une sonde de ciblage
qu'elle n'a pas — la garde de la fiche 12 le disait déjà fort. La motion vient
maintenant du registre (MOTION_BY_ENGINE), jamais d'un switch sur un code
d'algo, et le contexte part entier à buildPath pour que ce fichier reste
ignorant de l'algo qu'il filme.

La motion vit dans le registre GPU et NON dans meta.motion des modules : le
meta dit ce que fait la PROD (les deep-* y sont retombés en ken-burns,
mandelbrot y porte zoom), or le bac à sable plonge sur les douze champs — c'est
sa raison d'être. Le lire ferait filmer un ken-burns au moteur écrit exprès pour
plonger.

Deux besoins révélés en intégrant, chacun avec son test :

- un crochet `prepare` sur le moteur. La flamme dérive de ses params sa banque
  tournée, sa projection et son découpage en chaînes — recalculés À CHAQUE frame
  puisque swirl avance. Le résultat sert aux deux côtés, les octets d'uniformes
  ET le nombre de workgroups : les appliquer séparément les ferait diverger.
- fieldParamsFor ne réclame une sonde que si l'algo en a une. La question doit
  pouvoir se poser sans provoquer l'erreur, d'où hasBoundaryProbe.

Et une conséquence en aval, rattrapée : l'éditeur indexe les comportements de
couleur par moteur, il en fallait un quatrième. La flamme colorie par DENSITÉ —
teinte interpolée dans stops[1..], luminosité par log-densité, stops[0] cuit en
fond. Son seul knob de couleur au paramSpec est gamma.

Suite complète : 2863/2865, les deux échecs restants étant les pipeline-slide
connus (dérive SIMD libvips, antérieurs et sans rapport).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
Le risque n'était pas théorique : pipeline-video.js:187 et pipeline-video-gpu.mjs
écrivaient EXACTEMENT la même chaîne, `<hash>/video/<format>.mp4`. Sans
conséquence jusqu'ici — le CLI branche un storage disque et les deep-* ne sont
pas filmés en prod — mais flame-ifs a désormais une vraie vidéo de production en
swirl. Deux fichiers se ressemblent, un seul fait foi.

La clé du bac à sable porte maintenant son préfixe en tête (`maquette-gpu/`) et
un hash tronqué : aplatie par le storage disque du CLI, elle donne un nom de
fichier court, triable avec ses semblables, et qui dit « maquette » avant tout le
reste. C'est ce nom-là qui protège, pas la théorie — d'où un test dessus.

La garde a deux sens, et le second est celui qui sert : isProductionVideoKey
permet à un faux storage de REFUSER une clé de prod. Un test l'utilise pour
rendre un reel de flamme et une plongée, et échouerait si le pipeline
fabriquait encore la mauvaise forme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
zoomFactor, centerX, centerY décrivent où descendre dans un plan complexe. Une
flamme n'en a pas : son cadrage est recalculé sur ses propres bornes à chaque
rendu et absorbe toute surcharge de viewport — c'est très exactement ce qui a
rendu les motions zoom et phase inutilisables sur cet algo côté production, les
frames revenaient identiques et rien ne le disait.

Écart avec la fiche, signalé à l'atomisation déjà : le plan citait
editor/target-path.mjs comme siège de ces contrôles. C'est en fait le garde de
nom de fichier de rendu-json/. Les vrais contrôles sont les trois ids story-*
de page.html, et c'est eux qui sont masqués.

Ça ne contredit pas la règle « RIEN n'est masqué » de param-groups.mjs : elle
porte sur les PARAMS de l'algo (un param non rangé reste réglable, sinon on
fabrique un body incomplet sans le voir). Ici ce sont des réglages de CIBLAGE,
qui ne pilotent rien pour cet algo.

Masqué ET vidé : une valeur saisie avant de changer d'algo resterait dans le DOM
et partirait dans le JSON, invisible puisque le champ ne s'affiche plus.

Toute la décision vit dans un module pur, testé ; page.mjs ne fait que basculer
l'attribut — c'est l'architecture du dossier, où les dix fichiers de test
couvrent les petits modules et la page reste de la colle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
Ces trois-là ne peuvent pas venir du catalogue, et c'est normal : swirl est un
knob VIDÉO que PHP ne tire jamais (il n'est dans aucun paramSpec), swirlTurns
vit dans le meta du module, iterations est un DEFAULTS surchargé par
meta.videoParams. L'éditeur projette le paramSpec — sans cette table, il ne
pourrait pas les découvrir. Un test l'affirme en interrogeant le catalogue réel.

Écart avec la fiche, assumé : elle demandait aussi `workingCap`. Non. Côté CPU
il plafonne la taille d'accumulation avant un upscale bilinéaire ; le moteur GPU
accumule directement à la taille de la frame, il n'y a pas de passe d'upscale à
piloter. L'exposer afficherait un curseur qui ne commande rien — le « nom
fantôme » que color-behaviour.test.mjs interdit déjà pour les knobs de couleur,
et pour la même raison : une panne silencieuse à l'écran.

Les défauts sont EXPLICITES, séparés des bornes. defaultParamsFor prend le
milieu — ce qui ouvrirait la flamme sur un demi-tour de rotation, donc une autre
image que l'estampe. swirl ouvre à 0 (la géométrie de l'œuvre) et iterations à
20 M, la densité pleine : ouvrir sur la qualité dégradée serait absurde quand
c'est justement ce que le GPU vient lever (9 ms la frame au lieu de 2670).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
gpu: un marquage visible « maquette » sur chaque frame (fiche 18)
All checks were successful
CI / Renderer - couverture JS (pull_request) Has been skipped
CI / Renderer - audit des dépendances JS (pull_request) Successful in 1m30s
CI / PHP - tests + static + lint (pull_request) Successful in 9m35s
CI / Renderer - tests + lint JS (pull_request) Successful in 9m53s
18cebc0549
L'autre moitié de la garde anti-confusion : output-key.mjs protège le NOM,
celui-ci protège le PIXEL. Un fichier renommé, ou une capture d'écran, perd le
premier — pas le second. D'où « sur chaque frame », pas seulement la première.

Écart avec le plan, déjà signalé à l'atomisation : il voulait marquer « dans les
bandes » un pipeline qui n'en a aucune, et dont l'en-tête l'affirme (« AUCUNE
bande blanche, AUCUN cartouche, AUCUNE icône CTA » depuis le passage au plein
cadre). C'est donc une surimpression, posée par-dessus les bandeaux édito pour
rester lisible quel que soit le storytelling.

La bande suit le format plutôt que d'être en dur : le bac à sable filme au ratio
demandé, une géométrie figée sortirait du cadre sur un carré. Opacité 0,55 — un
filigrane opaque cacherait l'image qu'on vient de rendre pour la regarder. Et
aucune option pour le couper : une garde qu'on peut désactiver n'est pas une
garde, c'est la « phrase » que le plan voulait dépasser.

Un test existant est entré en conflit, à raison : il vérifiait qu'AUCUNE bande
n'est composée (garde du passage au plein cadre). Il est précisé, pas affaibli —
au-dessus du filigrane la frame doit rester intégralement noire, et la bande du
bas doit au contraire porter le marquage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
Mesuré sur la carte, pas déduit : avec `layout: 'auto'`, Dawn dérive un bind
group layout PAR POINT D'ENTRÉE, ne contenant que les bindings que cette entrée
utilise réellement. `clear` ne touche pas la sortie, `toneMap` ne touche pas
l'histogramme — les cinq layouts divergent, et un bind group portant les quatre
buffers est refusé par chacun d'eux.

Les trois moteurs de champ peuvent s'en passer : ils n'ont qu'un `main`. Le
moteur d'accumulation, lui, a besoin d'un `createBindGroupLayout` explicite,
donc d'une visibilité à nommer — d'où `GPUShaderStage` remonté de `device.mjs`
jusqu'à `GPU_DEPS`. Un seul bind group pour les cinq passes, au lieu d'un par
passe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
Deux manques que ni le smoke ni les tests unitaires ne pouvaient voir : tous
deux fabriquaient à la main des params qu'aucun body de `rendu-json/` ne porte.
Le premier `gen-video-deep-gpu.mjs` sur un body réel jetait.

1. Le `canonicalHash` s'arrêtait au bord du pipeline. Les trois moteurs de CHAMP
   n'en ont pas besoin — leur image tient toute dans les uniformes déjà packés —
   donc `fieldParamsFor` ne le descendait pas. Un moteur d'ACCUMULATION en TIRE
   sa géométrie et en amorce ses 65 536 flux : sans lui, la première frame jette
   dans `createRng`.

2. `warmup` n'est au `paramSpec` d'aucun algo : c'est une constante du moteur,
   donc aucun body ne la portera jamais. `DataView.setUint32(undefined)` écrit
   `0` sans broncher — la marche perdait son rodage et splattait ses premiers
   pas avant d'être retombée sur l'attracteur, en silence.

`prepareFlameParams` applique désormais les `DEFAULTS` de l'algo de prod, par le
même `resolve` que lui. IMPORTÉS, jamais recopiés : un jumeau dériverait au
premier réglage et la maquette mentirait sur l'estampe. D'où l'unique
changement côté prod, un `export` sur un `const` déjà gelé — aucun octet de
rendu ne change, aucun `rng()` ne bouge, donc pas de bump d'`engine_version`
(golden hash de `flame-ifs` vert, 18/18).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
Le banc de la famille flamme, et les chiffres qu'il rend. Deux bancs distincts,
et le README le dit maintenant explicitement parce que les confondre est facile :
le smoke isole le moteur sur 450 × 800, le reel entier tourne plein cadre à
1080 × 1920.

  une frame à 20 M points (450 × 800)  98,6 ms   contre ~2670 ms au CPU
  reel de 450 frames, bout en bout     50,7 s    contre 292,6 s au CPU à 2,5 M
  pixels encrés 2,5 M → 20 M           7,1 %  → 18,4 %   (à canevas égal)

Ce que ça tranche :

- La réserve « le gain est ×3 » du plan : c'est ×5,8 sur le temps, à HUIT FOIS
  la densité. Mais le motif de la tranche reste la qualité, pas la vitesse — la
  frame entière passe sous le tic de readback de 100 ms, il n'y a plus rien à
  gagner côté calcul.
- La réserve ouverte par T9 du plan `swirl` : sur un tirage filmé, l'estampe
  montrait 7,3 % de pixels encrés contre 35,4 % au mi-tour, sans qu'on sache
  départager le tirage de la qualité dégradée. Moitié-moitié — la qualité coûte
  un facteur 2,6 sur la matière visible. La trappe `meta.videoParams` n'est pas
  un réglage d'optimisation, elle change ce que le spectateur voit.
- Une part d'encre ne se compare qu'à canevas égal : le reel étale le même
  budget sur 5,76 fois plus de pixels et rend donc plus maigre. Le moteur CPU se
  comporte pareil, ce n'est pas un défaut du portage.

Et jamais une parité d'octets : `meanDiff` 5,34, `passRatio` 0,72 sur le même
tirage. Un flux `sfc32` à la suite contre 65 536 en parallèle — c'est le modèle
d'exécution, pas une imprécision à corriger.

Le `.gitignore` suit le renommage de la fiche 17 : l'ancien motif `*_video_*` ne
rattrape pas les `maquette-gpu_*.mp4`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
19 fiches sur 19 jouées, plus les deux défauts que seul un reel entier révèle.
Go-live : aucun — `renderer/gpu/` est hors de l'image Docker et n'est jamais
appelé par PHP. Pas de rebuild, pas de `app:catalog:sync`, pas de migration,
aucun bump d'`engine_version` ni de `VIDEO_ENGINE_VERSION`.

Convention du dépôt : un plan livré disparaît, il reste récupérable en git. Ce
qui devait survivre est remonté là où on le cherchera :

- `renderer/gpu/README.md` §11 — le moteur d'accumulation, les chiffres mesurés
  et datés, les deux bancs à ne pas confondre, et les quatre pièges (deux que
  seule la carte révèle, deux que seul le reel entier révèle) ;
- `CLAUDE.md` — le bac à sable a désormais DEUX genres de moteur, résolus par le
  registre et non par un `switch` ; et la sortie GPU est une maquette, jamais la
  vidéo publiée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
`renderer/.gitignore` ne couvre que `renderer/`, or le CLI écrit sa sortie
relativement au cwd : un `node renderer/gen-video-deep-gpu.mjs` lancé depuis la
racine y dépose son MP4, et git proposait donc de committer plusieurs dizaines
de Mo de maquette. Même préfixe que la garde de la fiche 17.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QE2RdcPX6NLG13GrSPGjkt
removing water mark
All checks were successful
CI / Renderer - couverture JS (pull_request) Has been skipped
CI / Renderer - audit des dépendances JS (pull_request) Successful in 1m33s
CI / PHP - tests + static + lint (pull_request) Successful in 8m14s
CI / Renderer - tests + lint JS (pull_request) Successful in 8m49s
169104dc00
Nora merged commit 16c4d6f339 into develop 2026-09-12 10:10:12 +00:00
Nora deleted branch feature/editor-flame 2026-09-12 10:10:12 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Nora/ArtAutomatic!184
No description provided.