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, ni profondeur, ni texture, ni hiérarchie visuelle. Rien dans l’image ne suggérait des boîtes contenues dans d’autres, une structure imbriquée, une disposition qui 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 se respectent 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. Si elle lui tourne le dos, elle s’assombrit. Entre les deux, les surfaces inclinées sont plus ou moins éclairées. 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 proportionnelle au cosinus de l’angle entre la normale à la surface (la direction dans laquelle la surface pointe en ce point) 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.

Déplacez la direction de la lumière pour changer l’angle. La luminosité en tout point est cos(θ) : maximale quand la surface fait face directement à la lumière, nulle quand elle s’en détourne. À elle seule, cette règle produit tous les indices de profondeur du treemap.

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 au plus haut, les bords au plus bas, à la manière d’une lentille de contact qui s’incurve depuis son sommet. La normale à la surface en tout point se déduit du gradient de cette forme (fonction quadratique de la position).

Les rectangles imbriqués accumulent les paramètres de coussin de leurs ancêtres, si bien que la structure d’imbrication apparaît directement dans l’ombrage. On y revient après le code.


Pour implémenter tout cela, il faut générer un CGImage (le type de tampon de pixels de Core Graphics) 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 : aucune API par pixel n’est exposée. Dès lors qu’on veut calculer la couleur de chaque pixel à partir d’une formule mathématique, il faut se débrouiller seul.

La solution : calculer les valeurs soi-même, en dehors de SwiftUI, et remettre le résultat à Canvas sous forme d’image. On alloue un tampon de pixels, le remplit avec des valeurs RGBA calculées et 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 cette mécanique :

// Ombrage de Lambert par pixel (simplifié)
for y in 0..<height {
    for x in 0..<width {
        // Normale à la surface depuis le gradient du coussin
        let nx = -(2 * cushionAx * Double(x) + cushionBx)
        let ny = -(2 * cushionAy * Double(y) + cushionBy)
        let nz = 1.0  // contrôle le "gonflant" du coussin ; plus grand = ombrage plus plat
        // Produit scalaire avec la direction de la lumière (mesure combien la surface fait face à la lumière :
        // +1 = face directe, 0 = rasant, négatif = à l'opposé)
        let intensity = max(0, (nx * lx + ny * ly + nz * lz) / sqrt(nx*nx + ny*ny + nz*nz))
        // Moduler la couleur de base
        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 :

Gauche : remplissage plat (sans ombrage). Droite : ombrage de Lambert par pixel avec coussin. Ajustez la profondeur du coussin, l’angle de la lumière, l’élévation et la lumière ambiante. Survolez le rectangle ombré pour inspecter la normale, le produit scalaire et la couleur du pixel en tout point.

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 dans la hiérarchie. Un répertoire racine de 800 pixels de large aurait par exemple cushionAx = -0.002. Un fichier à l’intérieur y ajoute sa propre contribution, donnant un cushionAx = -0.005 combiné : une courbure plus prononcée, un coussin plus marqué.

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. Un plancher ambiant empêche les pixels totalement dans l’ombre de devenir noirs : il reste toujours assez de contraste pour lire la forme. La couleur de base vient de la catégorie du fichier (documents, images, vidéo), et shaded(by:) en ajuste la luminosité.

Sur un canevas de 1600×1000 affichant un scan de 5,2M nœuds, le treemap produit typiquement 6 000 à 7 000 rectangles visibles, chacun ombré indépendamment : la boucle par pixel s’exécute 1 à 2 millions de fois par rendu.


Un à deux millions d’évaluations de pixels, chacune avec une racine carrée et un produit scalaire. Exécuter ça sur le thread principal fige l’interface entière : ni défilement, ni clic, ni réponse tant que le calcul 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 ombre tous dans un seul tampon de pixels (parallélisé sur les cœurs), encapsule le résultat dans un unique CGImage et le met en cache. Tant que le premier rendu n’est pas terminé, le canevas affiche les rectangles en aplat ; le délai est à peine perceptible. Quand Canvas dessine, il lit cette unique image en cache. Le travail du thread principal se résume à un seul appel context.draw(image, in: frame) : rapide, sans calcul, juste du transfert d’une image pré-rendue vers le canevas.

graph TD
    accTitle: Rendering pipeline from layout to screen
    accDescr: TreemapLayout computes rectangles. CushionRenderer computes surface parameters. A per-pixel loop applies Lambert shading. The result is written to a CGImage pixel buffer, cached by rectsVersion, composited via Canvas.draw, and displayed on screen.
    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 une empreinte rectsVersion : un hash de chaque entrée qui affecte le rendu (identité des rectangles, taille du canevas, thème de couleur, filtres actifs, et d’autres encore). Dès qu’une de ces entrées change, l’empreinte change, et le .task(id: rectsVersion) de SwiftUI annule l’ancien rendu et en lance un nouveau. Le Canvas redessine quand la nouvelle image est prête.

Calibrer l’invalidation a pris plus de temps que l’ombrage lui-même : preuve que la gestion d’état est plus retorse que les maths. Invalidation manquée : on zoome dans un répertoire et les couleurs du rendu précédent persistent une demi-seconde. Invalidation excessive : chaque mouvement de souris redessine entièrement le canevas 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 fait émerger la hiérarchie visuelle.

Le treemap contient deux types de rectangles : les fichiers, qui sont des feuilles sans enfants, et les répertoires, nœuds internes dont les enfants sont imbriqués à l’intérieur. L’algorithme de disposition place les rectangles enfants dans les rectangles parents, avec une marge. L’ombrage doit rendre cette hiérarchie visible.

L’approche naïve : dessiner une bordure de 1 px autour de chaque répertoire, peut-être ajouter une ombre portée, peut-être assombrir progressivement les éléments imbriqués. Beaucoup de code spécifique, et un résultat qui ne tient pas à des niveaux d’imbrication profonds.

L’accumulation des paramètres de coussin s’en charge automatiquement : pour calculer le coussin d’un rectangle enfant, on part des paramètres du parent et on y ajoute la contribution propre de l’enfant. Le rectangle parent a une convexité douce sur toute sa surface. Celui de l’enfant vient s’y superposer. 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.

Plus besoin de dessiner des bordures ni d’ajouter des ombres portées : les mathématiques s’en chargent. Décidément, on sous-estime les cosinus.

Gauche : remplissage plat. Droite : ombrage en coussin avec la même disposition. Utilisez le curseur de profondeur pour varier l’intensité du coussin : à zéro, les deux côtés sont identiques. Les frontières de répertoires deviennent visibles sous forme d’arêtes dans l’ombrage, sans aucune bordure dessinée.

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 : pour la première fois, ils la communiquaient réellement.

Vue treemap de Renala montrant des rectangles ombrés en coussin où la surface représente la taille des fichiers et l'ombrage encode la profondeur d'imbrication des répertoires
Le résultat final. Chaque couleur représente une catégorie de fichier. Le gradient d’ombrage rend l’imbrication des répertoires visible sans bordures ni ombres portées. La profondeur est physiquement encodée dans la lumière et l’ombre.

Trois jours. Ça valait le coup.


Références