
Peindre des pixels : l’ombrage de Lambert en partant de zéro
J’ai passé trois jours sur des pixels. Ça valait le coup.
Au début du projet, quand le treemap affichait pour la première fois des données réelles, le résultat était techniquement correct et visuellement faux. Des rectangles de couleur plate, aucune profondeur, aucune texture, aucune hiérarchie visuelle. Rien dans l’image ne suggérait que certaines boîtes étaient contenues dans d’autres, qu’on regardait une structure imbriquée, que la forme de la disposition encodait des informations sur le système de fichiers.
On aurait dit une grille colorée, pas un analyseur de disque.
Tous les analyseurs de disque qui ont une bonne allure utilisent le rendu en coussin : une technique d’ombrage qui donne à chaque rectangle un léger relief, comme un coussin, créant profondeur et hiérarchie à partir d’une surface 2D plate. J’avais lu des articles à ce sujet sans jamais l’implémenter, en supposant que la disposition serait plus ardue.
La disposition n’était pas plus ardue. L’ombrage, si. Trois jours pour une fonction cosinus. Je ne code pas vite.
L’œil sait déjà comment la lumière fonctionne. Une surface qui fait face à la lumière est claire. Une surface qui lui tourne le dos est sombre. Les surfaces inclinées sont quelque part entre les deux. On traite ça en millisecondes, sans y penser, chaque fois qu’on regarde un objet physique. L’ombrage de Lambert est le calcul derrière cet instinct : la luminosité en tout point d’une surface est le cosinus de l’angle entre la normale à la surface et la direction de la lumière.
Une seule règle. C’est tout ce qu’il faut pour donner à des rectangles plats sur un écran l’apparence d’une profondeur physique.
Plutôt que des rectangles plats, chaque rectangle est modélisé comme une surface légèrement convexe, comme un coussin de siège. Le centre du rectangle est le point le plus haut. Les bords sont plus bas. La normale à la surface en tout point à l’intérieur du rectangle est calculée à partir du gradient de cette surface, qui est une fonction quadratique de la position.
Les rectangles imbriqués, qui dans le treemap correspondent aux répertoires contenant des fichiers, accumulent les paramètres de coussin de leurs ancêtres. Un fichier profond dans une hiérarchie de répertoires hérite d’une part du coussin de son répertoire parent, de son grand-parent, et ainsi de suite. Le résultat : la structure d’imbrication est directement lisible dans l’ombrage. On distingue des arêtes subtiles aux frontières de répertoires, une profondeur visible à chaque niveau.
L’implémentation nécessite de générer un CGImage pixel par pixel.
L’API Canvas de SwiftUI utilise des commandes GraphicsContext : remplir un chemin, tracer une ligne, dessiner une image. Ce sont des opérations vectorielles. Il n’y a pas d’API par pixel. Si l’on veut calculer la couleur de chaque pixel individuellement à partir d’une formule mathématique, on est livré à soi-même.
La solution : calculer les valeurs des pixels soi-même, en dehors de SwiftUI, et remettre le résultat à Canvas sous forme d’image. On alloue un tampon de pixels, on le remplit avec des valeurs RGBA calculées, et on l’encapsule dans un CGImage. Canvas dessine ensuite le CGImage en une seule opération, rapide et efficace, sans rien savoir de la manière dont les pixels ont été produits.
La boucle par pixel est le cœur de tout cela :
// Per-pixel Lambert shading (simplified)
for y in 0..<height {
for x in 0..<width {
// Surface normal from cushion gradient
let nx = -(2 * cushionAx * Double(x) + cushionBx)
let ny = -(2 * cushionAy * Double(y) + cushionBy)
let nz = 1.0
// Dot product with light direction
let intensity = max(0, (nx * lx + ny * ly + nz * lz) / sqrt(nx*nx + ny*ny + nz*nz))
// Modulate base color
pixels[y * width + x] = baseColor.shaded(by: intensity)
}
}
Déplacez les curseurs ci-dessous pour voir exactement l’effet de chaque paramètre sur l’ombrage d’un vrai rectangle, et survolez n’importe quel pixel pour inspecter sa normale, son produit scalaire et sa valeur RGB finale :
Les paramètres de coussin (cushionAx, cushionBx, cushionAy, cushionBy) encodent deux choses à la fois : la forme du coussin de ce rectangle et la contribution accumulée de chaque répertoire parent au-dessus dans la hiérarchie. La direction de la lumière (lx, ly, lz) est un vecteur normalisé fixe. À chaque pixel, la normale à la surface vient du gradient de la fonction de hauteur quadratique, ce qui la rend linéaire en x et y.
Le produit scalaire donne l’illumination brute. max(0, ...) ramène à zéro les surfaces qui tournent le dos à la lumière. La couleur de base vient de la catégorie du fichier (documents, images, vidéo), et shaded(by:) en module la luminosité.
Pour un canevas de 1600x1000 avec un treemap dense, cette boucle s’exécute un à deux millions de fois par rendu.
Un à deux millions d’évaluations de pixels, chacune impliquant une racine carrée et un produit scalaire. Exécuter ça sur le thread principal et l’interface entière se fige : pas de défilement, pas de clic, aucune réponse tant que chaque pixel n’est pas terminé.
Le pipeline d’ombrage tourne entièrement sur une tâche d’arrière-plan. Une fois que la disposition du treemap a calculé les rectangles, une tâche secondaire les parcourt, génère le CGImage pour chacun, et met le résultat en cache. Quand le Canvas dessine, il lit les images en cache. Le travail du thread principal se résume à une boucle d’appels context.draw(image, in: frame) : rapide, sans calcul, juste du transfert d’images pré-rendues vers le canevas.
graph TD
A["TreemapLayout<br/>calcul des rectangles"] --> B["CushionRenderer<br/>calcul des paramètres de surface"]
B --> C["Boucle par pixel<br/>ombrage de Lambert"]
C --> D["CGImage<br/>tampon de pixels"]
D --> E["Cache<br/>indexé par rectsVersion"]
E --> F["Canvas.draw<br/>composition des couches"]
F --> G["Écran"]
style E fill:#f5f0e8,stroke:#c47c2b
L’invalidation du cache est pilotée par un compteur rectsVersion sur le ViewModel. Quand le scan change, la disposition change, l’utilisateur applique un filtre ou bascule de thème de couleur, le compteur s’incrémente. La tâche de rendu d’arrière-plan détecte la nouvelle version et recalcule. Le Canvas vérifie la version et redessine. Les versions restent synchronisées.
Obtenir l’invalidation correcte a pris plus de temps que l’ombrage lui-même, ce qui en dit long sur la difficulté relative des mathématiques et de la gestion d’état. Une invalidation manquée produit des images périmées : on zoome dans un répertoire et les couleurs du rendu précédent persistent pendant une demi-seconde. Une invalidation excessive produit du recalcul inutile : chaque mouvement de souris déclenche un re-rendu complet et le processeur chauffe. La règle : rectsVersion doit tracer exactement l’ensemble des entrées qui affectent les couleurs des pixels ou la géométrie de la disposition. Ni plus, ni moins.
Le modèle de coussin gère la hiérarchie visuelle.
Le treemap contient deux types de rectangles. Les fichiers sont des feuilles : ils n’ont pas d’enfants. Les répertoires sont des nœuds internes : leurs enfants sont imbriqués à l’intérieur. L’algorithme de disposition place les rectangles enfants à l’intérieur des rectangles parents, avec une marge. L’ombrage doit rendre cette hiérarchie visible.
L’accumulation des paramètres de coussin s’en charge automatiquement. Quand on calcule le coussin d’un rectangle enfant, on part des paramètres du parent et on ajoute la propre contribution de l’enfant par-dessus. Le rectangle parent a une convexité douce sur toute sa surface. Le rectangle enfant a sa propre convexité par-dessus. La surface combinée est plus haute au centre de l’enfant, plus basse aux bords, avec une marche visible à la frontière entre parent et enfant. L’imbrication des répertoires devient physiquement visible sous forme d’arêtes et de gradients.
Pas besoin de dessiner des bordures. Pas besoin d’ajouter des ombres portées. Les mathématiques le font gratuitement. Décidément, on sous-estime les cosinus.
Une fois l’ombrage en place, ça ressemblait enfin à un analyseur de disque. La représentation visuelle faisait son travail : la profondeur, c’est la hiérarchie ; la surface, c’est la taille ; la couleur, c’est la catégorie. L’information est dans les pixels, et pour la première fois, les pixels la communiquaient réellement.

Trois jours. Ça valait le coup.
References
- van Wijk, J.J., van de Wetering, H. (1999). Cushion Treemaps: Visualization of Hierarchical Information. IEEE Symposium on Information Visualization.
- Wikipedia: Lambertian reflectance the cosine law and its derivation
- Wikipedia: Phong shading context for where Lambert fits among shading models
- Apple Developer: CGImage the raw pixel buffer abstraction used for custom rendering
- Apple Developer: Canvas the SwiftUI immediate-mode drawing API
- WWDC 2021: Add rich graphics to your SwiftUI app Canvas API,
TimelineView, custom drawing patterns - WWDC 2021: Explore SwiftUI animation animation and rendering fundamentals in SwiftUI