1 points par GN⁺ 2024-07-22 | 1 commentaires | Partager sur WhatsApp
  • En CSS, box-shadow est normalement une propriété qui donne de la profondeur à une UI, mais en en superposant des centaines à des milliers sur un seul div, elle peut fonctionner comme une sorte d'API de dessin
  • Malgré la contrainte de la boîte, il est possible de créer des points circulaires et des effets de calque avec des coins arrondis, des chaînes de plusieurs ombres, et des combinaisons de couleurs, de flou et de transparence
  • Lors des tests, le flou et la transparence ont fortement réduit le nombre d'éléments pouvant être animés, alors qu'en les retirant, il était possible de dessiner des milliers de box-shadow sur un portable M1
  • La simulation de balles, les pixels d'image, les points sur la surface d'un cube ou d'une sphère, ainsi que des visualisations réactives à la musique, sont tous implémentés en mettant à jour à chaque frame une chaîne box-shadow
  • Enfin, un ray tracer basé CPU a été parallélisé avec des Web Workers, montrant qu'il est possible de rendre une scène avec un seul div et uniquement box-shadow

Utiliser box-shadow comme un outil de dessin

  • En CSS, box-shadow est une forme de drop shadow à l'origine utilisée pour dessiner une ombre derrière une image ou un élément d'UI afin de créer une impression de profondeur
  • Une drop shadow classique décale le raster d'une image sur les axes x/y et le dessine derrière l'original avec une seule couleur pour donner du relief
  • filter: drop-shadow() en CSS accepte des décalages x/y et une couleur, avec éventuellement une valeur de blur
  • En superposant plusieurs filtres de drop shadow, on peut renforcer la profondeur d'une composition à l'écran

Caractéristiques et limites de box-shadow

  • Le terme « Box » dans box-shadow signifie que la forme de l'ombre est limitée à la bounding box du conteneur
  • Cette contrainte peut sembler restrictive, mais comme la plupart des UI sont construites à partir de boîtes, elle reste utile en pratique
  • L'implémentation CSS de box-shadow bénéficie d'optimisations mathématiques peu coûteuses pour dessiner des boîtes arrondies, et une boîte arrondie peut ressembler à un cercle
  • Les designers peuvent ainsi éviter d'augmenter la taille des téléchargements en utilisant box-shadow au lieu d'images pré-rendues
  • Un seul div peut chaîner plusieurs box-shadow, et les exemples s'appuient sur cette méthode pour composer couleurs et ombres
  • On peut expérimenter le réglage de border-radius avec le générateur CSS border-radius

Une utilisation « incorrecte » de box-shadow

  • Dans un design d'UI classique, les ombres servent d'élément secondaire pour distinguer états et interactions, avec les marges, le padding, la typographie et l'accessibilité
  • Mais box-shadow peut aussi être traité comme une API de peinture capable de colorer à l'écran un nombre arbitraire de rectangles et d'y appliquer éventuellement du blur
  • Les premiers essais sont partis du minimal art d'un article précédent, qui produisait déjà un résultat visuel avec de simples blocs de couleur
  • Un outil de visualisation a ensuite été créé pour observer l'impact de davantage de box-shadow, de blur et de transparence sur les performances
    • Il génère une grande chaîne box-shadow et l'applique à un seul div
    • L'animation a d'abord consisté à changer la chaîne box-shadow toutes les 300 ms et à laisser transition: all faire le reste
    • Cette approche provoquait du jank et était plus lente que le fait de définir box-shadow à chaque frame
  • L'exemple avec 100 box-shadow remixe une palette de couleurs au tap et affiche à gauche l'historique des 10 palettes les plus récentes
  • L'application de blur réduisait le nombre d'éléments animables, et les couleurs transparentes réduisaient elles aussi fortement le nombre d'éléments pouvant être rendus
  • La taille du div influait également sur les performances, et lors de l'animation, le rasterizer logiciel semblait être impliqué
  • Sans transparence ni blur, un portable M1 pouvait dessiner des milliers de box-shadow

Créer une simulation de balles avec box-shadow

  • box-shadow ne peut pas pivoter, mais avec border-radius, il peut prendre l'apparence d'un cercle
  • Plusieurs ombres circulaires ont été traitées comme des balles, avec une mise à l'échelle selon la valeur z pour produire une fausse profondeur 3D
  • L'implémentation met à jour l'état du jeu dans requestAnimationFrame, puis affecte à chaque frame une grande chaîne box-shadow à un div
  • Le rendu suit les étapes suivantes
    • Trier les balles selon leur valeur z
    • Calculer leur taille en fonction de z
    • Convertir la position x/y, le spread et la couleur de chaque balle en entrée box-shadow
    • Joindre les entrées par des virgules pour former une seule chaîne
  • L'exemple avec 50 balles permet de les déplacer par glisser-déposer et de les faire rebondir dans la boîte
  • Cette fausse mise à l'échelle 3D donne un certain relief, mais quand les balles s'approchent de la « caméra », les cercles peuvent paraître cassés
    • Le div de base servant à créer le box-shadow est trop petit par rapport au mode de mise à l'échelle
    • Augmenter la taille du conteneur corrige le problème, mais plus il est grand, plus les performances baissent
  • Une version avec collisions entre balles utilise une détection de collisions en n^2 et ne fait que réfléchir la vitesse au moment du choc
    • Elle ne simule pas de véritables interactions physiques
    • Pour rester lisible, la position en z a été figée afin d'en faire une scène 2D
  • Un autre exemple fait revenir les balles vers leurs positions initiales aléatoires, et le fait de les attirer avec une force tactile donne un effet rappelant un morceau arraché à une éponge

Images et nuages de points 3D

  • L'expérience suivante consiste à mapper les pixels d'une image en points sur un plan 2D, puis à dessiner chaque point avec box-shadow
  • La position et la couleur des pixels de l'image sont lues puis stockées dans state.particles, chaque pixel devenant une particule
  • Le code source est disponible dans cet exemple CodeSandbox
  • Cet exemple rend des milliers de box-shadow dans un espace 3D, avec une interaction qui disperse l'image lorsqu'on la fait glisser
  • Une version live en rotation automatique est proposée via /box-shadow/v3?width=80&size=5&autoRotate=1, avec un avertissement sur la consommation de batterie
  • L'exemple avec un plus grand nombre montait à environ 12 000 box-shadow, niveau où des saccades devenaient visibles
  • Sur M1, un très grand nombre de box-shadow passait, mais pas sur desktop, iPhone ou anciens appareils Android au même niveau

Placer des points sur la surface d'un cube et d'une sphère

  • L'expérimentation s'est poursuivie avec la projection uniforme de points sur la surface de maillages
  • Le cube est implémenté en plaçant des points sur chacune de ses faces
    • Toutes les arêtes sont supposées avoir la même longueur
    • Chaque face est parcourue puis remplie de points selon la taille choisie
    • Plus on augmente le nombre de points, plus la précision du cube augmente
  • L'exemple du cube est interactif au tap et ajoute une petite lumière qui suit la position de la souris pour accentuer la profondeur
  • Le calcul de l'éclairage n'est pas exact et utilise plusieurs « magic constants »
  • La fonction de mapping des particules sur le cube et divers helpers mathématiques ont été obtenus avec gypity
    • Le premier résultat donnait une distribution aléatoire
    • La deuxième tentative a permis d'obtenir une répartition uniforme sur la surface du cube
  • La répartition uniforme sur une sphère est plus complexe et utilise une méthode de spiral discretization
    • L'idée est de placer uniformément des points le long d'une ligne qui enroule la sphère du bas vers le haut
    • L'image utilisée est celle d'une corde enroulée autour d'une balle
  • Ce mapping linéaire a aussi servi à une animation connectée à un tableau de valeurs de fréquences musicales
  • Une animation de palette en dégradé, interpolée dans le temps, a été appliquée aux points, et cette partie s'est révélée plus difficile que les travaux précédents
  • L'exemple de sphère avec peu de rotations montre que la spirale approxime une distribution uniforme, mais se casse visuellement quand le nombre de tours est insuffisant

Un ray tracer en box-shadow

  • L'idée que si l'on peut dessiner des triangles avec box-shadow, on peut presque produire n'importe quel CGI, a mené à une expérience de ray tracing
  • L'objectif était de dessiner une scène entière avec un seul div et box-shadow uniquement
  • Les exemples suivants utilisent à la fois un rendu live basse précision et un rendu d'image haute précision, et certains ne limitent pas la précision, donc prudence à l'exécution
  • Les ray tracers et ray marchers sont des techniques exactes mais lentes de génération d'images, largement utilisées dans l'industrie du CGI
  • Ici, le tracer repose sur le CPU plutôt que sur le GPU
    • Utiliser le GPU aurait affaibli l'intérêt de l'expérience et compliqué l'implémentation
  • Le premier tracer ne rendait que des sphères, et le modèle de données de la scène comprenait une caméra, plusieurs sphères et des informations de matériau
  • Le code initial obtenu via gypity fonctionnait en temps réel, mais avec des problèmes
    • Une ligne ajoutant le bias coefficient nécessaire à l'optimisation de l'échantillonnage manquait
    • Le commentaire correspondant était bien là, mais pas la ligne effective ; après vérification de la technique d'échantillonnage, le problème a été corrigé

Rendu progressif et Three.js

  • Le code a ensuite évolué vers une architecture de progressive rendering
  • Le progressive rendering répartit un grand nombre de calculs de rayons sur plusieurs frames, montrant un rendu qui se rapproche progressivement de la « ground truth »
  • Cette structure se marie bien avec un déplacement interactif de la caméra
  • La caméra et les orbit controls utilisent la bibliothèque Three.js
    • Pour éviter d'implémenter à la main les maths matricielles et la prise en charge mobile
  • Cette version ne peut rendre que des sphères, et tous les éléments de la scène sont en pratique des sphères plus ou moins redimensionnées
  • L'exemple s'exécute par défaut à un peu plus de 6 % de la résolution totale
  • Plus la scène est éloignée à l'écran, plus la faible résolution semble nette ; de près, le manque de précision devient beaucoup plus visible
  • Des paramètres de requête permettent d'ajuster l'échelle de résolution, pixelSize, le nombre de rebonds des rayons et le nombre maximal d'échantillons
  • Augmenter la résolution et les échantillons améliore le résultat, mais ralentit énormément l'exécution

Implémentation du tracer et goulets d'étranglement de performance

  • Le rendu suit l'approche standard : lancer un rayon par pixel depuis la caméra, calculer une couleur, puis moyenner le résultat entre plusieurs frames
  • Comme le tracer utilisait Three.js, de nombreux nouveaux objets étaient créés puis rapidement abandonnés au garbage collector
  • Une certaine réutilisation d'objets a été tentée, mais pour pousser les performances au maximum, il aurait mieux valu se passer de Three.js
  • D'après le profiler, le garbage collection ne changeait pas énormément la donne, et l'usage de Three.js a été conservé
  • L'idée de base du tracer est qu'un rayon rebondit sur les objets de la scène jusqu'à atteindre la lumière, puis renvoie une couleur selon les propriétés des objets et des sources lumineuses
  • Il faut lancer beaucoup de rayons parce que certains atteignent une source lumineuse, et d'autres non
  • Ce tracer utilise un modèle d'éclairage simple
    • Pas de BRDF physiquement correcte
    • Pas de textures
    • Pas de subsurface scattering
    • Seulement une lumière diffuse simple et de la specular reflection
  • Pour apprendre le ray tracing, on peut consulter Ray Tracing in One Weekend
  • Plus tard, il a pu rendre des plans, puis des rectangles lumineux après correction d'une erreur de manipulation des bits sur des valeurs en floating point

Paralléliser avec des Web Workers

  • Au lieu de corriger les problèmes de GC liés à Three.js, l'amélioration des performances est passée par le multithreading avec Web Workers
  • Le ray tracing se prête bien à la parallélisation, car chaque calcul retourne un résultat unique sans effet de bord
  • Le gestionnaire de workers crée un pool de workers et expose deux méthodes, render et updateScene, pour modifier la scène à l'exécution
  • Le code côté worker est une copie du tracer existant, avec du data marshalling nécessaire pour pouvoir remplacer la scène
  • Pour réduire le coût de postMessage, chaque worker rend une frame entière plutôt qu'un pixel isolé
  • L'exemple plein écran fonctionne bien plus vite que la version précédente
  • L'inconvénient est qu'en cas de changement de caméra ou de scène, tous les résultats de calcul de rayons précédents deviennent invalides, ce qui peut rendre l'écran noir
  • La solution consiste à lever un drapeau isDirty lors des événements d'entrée, puis à décider dans la boucle de mise à jour s'il faut effacer la frame
  • Il peut arriver qu'un worker envoie une frame calculée avec les anciennes données de scène juste après une mise à jour
    • On pourrait inclure un timestamp ou un identifiant de scène dans postMessage pour l'ignorer
    • Dans l'expérience, cela a été laissé tel quel, car une frame contenant de mauvais rayons finit vite absorbée dans la moyenne
  • Au final, le ray tracing basé sur box-shadow fonctionne suffisamment bien, et son code source est disponible sur CodeSandbox
  • Les statistiques de performances affichent une estimation du nombre total de rayons, le nombre d'échantillons et la résolution de rendu
  • Le nombre d'échantillons s'arrête par défaut à 1200, mais peut être configuré
  • Un modèle d'éclairage alternatif, plus rapide mais moins précis, peut aussi être activé
  • Les données de scène sont en JSON, donc faciles à modifier directement

Conclusion : une expérience possible, mais déconseillée

  • Avec seulement box-shadow sur un unique div, il est possible de dessiner des simulations de balles, des nuages de points, des particules issues d'images, des surfaces de cube ou de sphère, et même des scènes en ray tracing
  • Cela reste cependant proche d'une approche « à ne pas faire », sans réel cas d'usage pratique
  • Il y aurait encore des améliorations possibles avec le chargement de triangle meshes, une acceleration structure et un modèle d'éclairage plus précis
  • Au départ, gypity avait répondu qu'un ray tracer en box-shadow était impossible, mais un exemple concret a finalement été réalisé
  • Le CSS n'est pas intuitif, mais il recèle parfois des possibilités étranges qui fonctionnent manifestement

1 commentaires

 
GN⁺ 2024-07-22
Commentaires Hacker News
  • Utiliser une couleur transparente ralentit les choses, car cela limite le traitement par lots du dessin GPU.
    Pour les dessins opaques, on peut utiliser le tampon de profondeur pour réordonner librement les opérations et maximiser le batching. Dans le cas transparent, il faut dessiner dans l’ordre du peintre pour que le blending soit correct.

    • Je pense que c’est plus compliqué que ça. Les navigateurs web utilisent bien le rendu GPU, mais ce ne sont pas des moteurs de jeu ; redessiner tous les objets de l’écran à chaque frame peut facilement provoquer des saccades sur des pages grandes et complexes.
      Chromium, en particulier, essaie de minimiser le nombre total de calques : il rend chaque calque sous forme de carte de pixels, puis compose à chaque frame les calques visibles pour produire l’image finale. En pratique, beaucoup de calques ne changent pas de pixels mais seulement de position, donc il suffit de les composer à chaque frame sans les rastériser à nouveau.
      Avec beaucoup de box-shadow sans transparence, Chromium peut tout rastériser d’un coup dans un seul calque ; avec beaucoup de box-shadow transparents, il peut en faire des calques séparés. Cela peut être inefficace, mais c’est aussi nécessaire si des box-shadow semi-transparents doivent se déplacer indépendamment dans la page.
    • L’ordre inverse du peintre est parfois meilleur que l’ordre du peintre, parce qu’il permet d’ignorer les objets entièrement masqués.
      Il suffit de partir d’un tampon totalement transparent (α=0.0), de parcourir chaque face de l’avant vers l’arrière, puis, pour chaque pixel, de mélanger au tampon existant une proportion 1.0-buffer.α du nouveau pixel. Si buffer.α == 1.0, on peut l’ignorer complètement comme avec un tampon de profondeur.
      Cela dit, il faudrait revérifier les maths quand un autre objet transparent se trouve derrière un objet transparent, et les cas où les faces se recouvrent cycliquement ou s’interpénètrent sont délicats.
    • Les GPU n’aiment pas non plus l’overdraw ; il vaut donc mieux éviter d’empiler plusieurs couches d’éléments transparents.
      C’est aussi pour cette raison qu’il est généralement préférable de dessiner davantage de triangles plutôt que d’utiliser des textures transparentes.
    • L’étape de tri elle-même n’est généralement pas le gros problème. On trie souvent de toute façon avant de soumettre les draw calls. Le vrai coût vient de l’overdraw.
      Le rendu opaque peut être fait de l’avant vers l’arrière, de sorte qu’on ne rend réellement que ce qui est visible dans le framebuffer final ; après le passage de profondeur, le nombre de pixels est proportionnel à la taille du framebuffer.
      Le rendu transparent doit être fait de l’arrière vers l’avant, et il faut rendre beaucoup de scènes qui seront ensuite partiellement masquées par d’autres polygones. Le nombre de pixels traversant le pipeline de shaders peut donc devenir proportionnel à la taille du maillage.
      Si les éléments ne se chevauchent pas, la transparence ne devrait quasiment pas ralentir les choses. De toute façon, il faut toucher chaque pixel une fois, et seule la formule du shader a changé.
    • Il y a un autre point à prendre en compte : surtout sur mobile, la bande passante mémoire peut devenir le goulot d’étranglement.
      Si un dessin opaque est placé au-dessus d’un autre, dans le meilleur des cas on peut éliminer toutes les opérations de dessin qui se chevauchent, et dans le pire des cas on ne consomme que la bande passante d’un dessin individuel.
      Avec la transparence, si l’on ne peut pas fusionner les opérations d’une façon ou d’une autre, il faut relire toute la zone de chevauchement ; à chaque dessin transparent, un bitmap d’au moins deux fois la taille du framebuffer final transite donc sur le bus mémoire.
      Quand on pense que beaucoup d’appareils mobiles n’avaient même pas assez de bande passante mémoire pour blitter tout l’écran deux fois dans le temps nécessaire pour maintenir 60 fps, c’est un vrai problème.
  • C’était une exploration vraiment amusante.
    Layering est effectivement le mot important. C’était aussi la clé d’un projet d’ombres de texte que j’ai fait il y a 14 ans, avec des effets absurdes mais parfois assez chouettes : https://paulirish.github.io/mothereffingtextshadow/

    • Cet article semble lui aussi avoir plusieurs couches, et en fait il ne parle pas seulement de box-shadow.
  • J’ai eu un peu honte de ne comprendre qu’au dernier paragraphe que gypity désignait ChatGPT.

    • C’est une expression utilisée sur la chaîne YouTube Primeagen.
    • En voyant ça, j’ai compris que ce mot n’était pas juste une blague interne inventée par un collègue, mais une expression en train de devenir un mème.
    • Je pensais que c’était une sorte d’outil côté client lié à gyp.
  • Les bons vieux hacks impraticables sont totalement bienvenus. Il faut juste se rappeler qu’il existe déjà canvas, et que canvas fait ce genre de choses plus facilement, plus vite et mieux.

    • Mais c’est moins drôle. Ce qui rend le fait de faire ça uniquement avec box-shadow amusant, c’est justement à quel point c’est impraticable.
    • Canvas fait aussi abandonner beaucoup de choses, notamment en matière d’accessibilité.
    • Canvas est meilleur, certes, mais seulement quand on fabrique quelque chose qui reste dans une boîte de taille fixe.
    • Bien sûr, c’est un peu comme dire qu’on a déjà des claviers, donc qu’il n’est pas nécessaire de faire de la musique avec des lecteurs de disquettes ou des poulets en caoutchouc.
  • La visualisation musicale était vraiment cool. L’époque de Winamp, où l’on lançait de la musique puis la visualisation en plein écran, me manque beaucoup.
    J’aimerais que les lecteurs audio en streaming actuels proposent ce genre de fonction.

    • J’utilise encore Winamp, plus exactement WACUP, avec les visualisations Milkdrop presque tous les jours.
      Mais oui, les lecteurs audio en streaming sont vraiment des logiciels aux fonctionnalités très basiques.
  • Au final, Firefox et Chrome rendent encore différemment une box-shadow de 1 px avec un zoom navigateur à 150 %.
    Je mise sur Baseline 2025.

  • Je me demande s’il existe de bonnes références pour en apprendre davantage sur ces « hacks mathématiques permettant de dessiner à très bas coût les boîtes arrondies que les gens de l’UI adorent ».

  • C’est le genre de hack que j’aime.
    Cela semble presque à l’opposé des articles de Josh Comeau que j’ai lus sur le sujet : https://www.google.com/search?q=josh+comeau+shadows

  • Un excellent article, peut-être le meilleur que j’aie lu cette année, se terminait par your welcome au lieu de you're.
    Il faut corriger ça vite. Ou alors je n’ai peut-être pas compris la blague.

  • Ces 30 dernières années, je suis devenu bon en programmation, mais comme je n’aime pas les jeux, je n’ai presque pas fait de graphisme.
    Aujourd’hui, je considère ça comme une énorme erreur et j’essaie de rattraper mon retard depuis plus d’un an, mais c’est vraiment difficile.

    • Moi, j’ai commencé par les jeux, puis je suis passé au CRUD ou à la programmation simple avec Oracle Forms, et du jour au lendemain j’ai gagné trois fois mon salaire de programmeur de jeux.