Le soleil enterré: Une semaine d'éclairage venue d'en bas (la nuit)
Pendant une semaine entière, l’univers du jeu a été éclairé par en dessous à la nuit tombée. Les pentes vers le ciel restaient sombres. Les faces tournées vers le sol, elles, brillaient. La lumière semblait monter d’en dessous comme si, la nuit tombée, le soleil traversait la terre. Rien ne plantait. Aucune erreur dans la console. Ça tournait parfaitement… et c’était parfaitement faux.
Le pire type de bug: celui qui s’affiche
Un crash, au moins, te dit où regarder. Là, j’avais l’inverse: du code qui s’exécute sans broncher et produit une image presque crédible. Assez fausse pour que rien ne semble juste, assez subtile pour ne jamais coincer.
Pendant des jours, j’ai cherché au mauvais endroit. Ma direction de lumière? Mon matériau? Mon brouillard? J’ai suspecté tout l’éclairage avant de comprendre que l’éclairage était innocent. La géométrie aussi, d’ailleurs. Mes normales étaient parfaitement calculées. Le problème, c’est que personne ne les lisait au bon endroit.
Ce qui se passait vraiment
Une normale, c’est le vecteur qui dit “cette surface est tournée par là”. Tout l’éclairage en dépend: la luminosité d’une face se calcule, en gros, par le produit scalaire entre sa normale et la direction de la lumière. Pointe la normale vers le mauvais côté, et la face s’éclaire à l’envers.
Sauf que je n’avais pas inversé une normale. J’avais déplacé l’endroit où le GPU allait la chercher.
Un sommet, en mémoire, c’est juste une suite d’octets: position, puis normale, puis couleur, puis coordonnées de texture, collées les unes derrière les autres. Le GPU ne « comprend » pas cette structure, on doit le lui dire, octet par octet, où commence chaque champ. C’est le rôle du vertex descriptor : un tableau d’offsets. Et mes offsets étaient codés en dur, sur une hypothèse fausse.
// position Vec3 offset 0
// normal Vec3 offset 12 // ❌ faux : en réalité 16
// color Vec4 offset 24
Le piège : en Swift, SIMD3<Float> n’occupe pas 12 octets. Il en occupe 16.
Douze octets de données, plus quatre octets de padding invisible, ajoutés par
le compilateur pour aligner la structure en mémoire. La vraie normale ne commence
donc pas à l’octet 12, mais à l’octet 16.
Mon descriptor pointait sur 12. Le GPU lisait la normale quatre octets trop
tôt : il attrapait la fin du padding de la position, plus une partie seulement
de la vraie normale, et appelait ça « la normale ». Chaque sommet, sans
exception, recevait ainsi un vecteur décalé — faux, mais faux de manière
cohérente. Pas du bruit aléatoire qui aurait sauté aux yeux : une direction
plausible, régulière, juste assez crédible pour passer pour un éclairage. Un
éclairage venu d’en bas. Un soleil enterré.
Le déclic n’est pas venu de la lumière. Il est venu des octets. La seconde où j’ai vérifié la taille réelle de la structure, tout s’est aligné:
MemoryLayout<Vertex>.offset(of: \.normal) // 16, pas 12
Seize. Mon descriptor disait douze. Quatre octets. Une semaine.
Le fix
Arrêter de coder les offsets en dur, et laisser le compilateur calculer la vraie position de chaque champ:
vd.attributes[1].offset = MemoryLayout<Vertex>.offset(of:\.normal)! // = 16
Une expression à la place d’un nombre, par attribut. C’est tout. Une semaine de
doute soldée par le remplacement de trois entiers écrits à la main par une
fonction qui ne se trompe jamais sur le padding, elle.
Détail savoureux que je n’ai compris qu’après : ce bug avait aussi un effet de
bord. Une fois les vraies normales enfin lues, mon terrain s’est mis à brûler
de lumière en plein jour. Logique : j’avais calibré mon sunCol × 3.0 à
l’aveugle, contre des normales cassées qui diluaient l’éclairage. Le fix n’a pas
seulement corrigé le sens de la lumière — il a révélé que tout mon réglage
d’intensité reposait sur une erreur. J’ai dû tout recalibrer. Un bug peut en
cacher un autre, et parfois le second n’apparaît qu’une fois le premier réglé.
La cicatrice
Il reste, en tête de mon fichier Vertex.swift, ce commentaire :
// ⚠️ Ne pas réordonner les champs
Il est là parce que l’ordre des champs décide des offsets, et qu’un seul champ déplacé suffit à tout recasser. Mais il y a une ironie que je garde exprès : juste au-dessus, le commentaire affiche encore le tableau d’offsets d’origine — normal offset 12 (12 bytes) — c’est-à-dire le modèle mental exactement faux qui m’a coûté la semaine. Le code, lui, a été corrigé. Le commentaire, non. Il reste là comme un fossile : le mauvais raisonnement, gravé juste à côté de l’avertissement qu’il a fait naître. Je le laisserai peut-être tel quel. Un post-mortem n’a de valeur que s’il garde la trace de l’erreur, pas seulement de la correction. C’est ça, la vraie leçon de ces bugs : ils ne plantent pas, ils ne hurlent pas. Ils s’affichent. Ils tournent. Ils mentent. Et la seule défense que j’ai trouvée, c’est un commentaire écrit en lettres capitales pour la prochaine personne qui touchera ce code — même quand cette personne, c’est moi, six mois plus tard.