1 puntos por GN⁺ 2024-07-22 | 1 comentarios | Compartir por WhatsApp
  • El box-shadow de CSS suele ser una propiedad para dar profundidad a la UI, pero si superpones cientos o miles sobre un solo div, puede funcionar como una especie de API de dibujo
  • Aunque tiene la limitación de la forma de caja, con esquinas redondeadas, cadenas de múltiples sombras y combinaciones de color, desenfoque y transparencia se pueden crear puntos circulares y efectos por capas
  • En los experimentos, el desenfoque y la transparencia redujeron mucho la cantidad que se podía animar, y al quitarlos se podían dibujar miles de box-shadow en una laptop M1
  • La simulación de pelotas, los píxeles de imágenes, los puntos sobre superficies de cubos y esferas, y las visualizaciones reactivas a la música se implementaron actualizando en cada frame una cadena de box-shadow
  • Al final, se muestra que incluso es posible renderizar una escena usando solo un div y box-shadow, al paralelizar con Web Workers un ray tracer basado en CPU

Usar box-shadow como herramienta de dibujo

  • El box-shadow de CSS es una forma de drop shadow, y originalmente se usa para dibujar una sombra detrás de una imagen o un elemento de UI y dar sensación de profundidad
  • Un drop shadow típico desplaza el raster de la imagen en los ejes x/y y lo dibuja detrás del original con un solo color para crear profundidad
  • filter: drop-shadow() de CSS recibe desplazamientos x/y y un color, y opcionalmente también admite un valor de blur
  • Si se apilan varios filtros de drop shadow, se puede añadir más profundidad a la composición visual

Características y limitaciones de box-shadow

  • El “Box” de box-shadow significa que la forma de la sombra está limitada al bounding box del contenedor
  • Esa restricción puede parecer limitante, pero como la mayoría de las UI están compuestas por cajas, en la práctica sigue siendo útil
  • La implementación de box-shadow en CSS incluye optimizaciones matemáticas para dibujar cajas redondeadas de forma barata, y una caja redondeada puede verse como un círculo
  • Los diseñadores pueden aprovechar box-shadow sin usar imágenes prerenderizadas y así evitar aumentar el tamaño de descarga
  • En un solo div se pueden encadenar múltiples box-shadow, y el ejemplo construye colores y sombras de esa manera
  • Puedes experimentar con ajustes de border-radius en CSS border-radius generator

Formas “incorrectas” de usar box-shadow

  • En diseño de UI normal, las sombras se usan como un elemento de apoyo junto con espaciado, padding, tipografía y accesibilidad para distinguir estados e interacciones
  • Pero box-shadow también puede tratarse como una API de pintado que colorea una cantidad arbitraria de rectángulos en pantalla y aplica blur de forma opcional
  • Los primeros experimentos comenzaron con el arte minimalista del artículo anterior, y lograban resultados visuales solo con una configuración simple de bloques de color
  • Después se creó una herramienta de visualización para ver cómo más box-shadow, blur y transparencia afectaban el rendimiento
    • Generaba una cadena grande de box-shadow y la asignaba a un solo div
    • La animación comenzó cambiando la cadena de box-shadow cada 300 ms y dejando el resto a transition: all
    • Ese enfoque tenía jank y era más lento que asignar el box-shadow en cada frame
  • El ejemplo de 100 box-shadow remezcla paletas de color con toques y muestra a la izquierda el historial reciente de 10 paletas
  • Al aplicar blur, disminuía la cantidad animable, y los colores transparentes también reducían mucho la cantidad que se podía dibujar
  • El tamaño del div también afectaba el rendimiento, y durante la animación parecía intervenir el rasterizador por software
  • Sin transparencia ni blur, una laptop M1 podía dibujar miles de box-shadow

Crear una simulación de pelotas con box-shadow

  • box-shadow no puede rotarse, pero con border-radius puede hacer que se vea como un círculo
  • Se trataron varias sombras circulares como pelotas, y su tamaño se escaló según el valor z para crear una falsa sensación de profundidad 3D
  • La implementación actualiza el estado del juego en requestAnimationFrame y asigna una gran cadena de box-shadow al div en cada frame
  • El proceso de renderizado es el siguiente
    • Ordenar las pelotas según su valor z
    • Calcular el tamaño según z
    • Convertir la posición x/y, el spread y el color de cada pelota en una entrada de box-shadow
    • Unir las entradas con comas para formar una sola cadena
  • El ejemplo con 50 pelotas permite moverlas arrastrando y hacer que reboten dentro de la caja
  • El escalado 3D falso da cierta profundidad, pero cuando una pelota se acerca a la “cámara”, el círculo puede verse roto
    • Eso se debe a que el div base que genera el box-shadow es demasiado pequeño en relación con el escalado
    • Se puede resolver agrandando el contenedor, pero mientras más grande es el contenedor, peor es el rendimiento
  • La versión donde las pelotas chocan entre sí usa una comprobación de colisiones n^2 y, al colisionar, solo refleja la velocidad
    • No simula una interacción física real
    • Para que se vea más claro, la posición z se fija y queda en 2D
  • También se creó un ejemplo donde las pelotas intentan volver a posiciones iniciales aleatorias, y al atraerlas con la fuerza del toque se produce un efecto como arrancar pedazos de una esponja

Imágenes y nubes de puntos 3D

  • El siguiente experimento mapea los píxeles de una imagen como puntos sobre un plano 2D y dibuja cada punto con box-shadow
  • Se leen la posición y el color de los píxeles de la imagen y se guardan en state.particles, usando cada píxel como una partícula
  • El código fuente está en CodeSandbox
  • Este ejemplo renderiza miles de box-shadow en espacio 3D y permite una interacción donde al arrastrar parece que la imagen se dispersa
  • La versión en vivo con rotación automática se ofrece en /box-shadow/v3?width=80&size=5&autoRotate=1 y muestra una advertencia sobre consumo de batería al ejecutarse
  • El ejemplo con mayor cantidad llegaba a unos 12,000 box-shadow, y a ese nivel ya se notaba entrecortado
  • En M1 se podían procesar muchísimos box-shadow, pero en desktop, iPhone y Android antiguos no se alcanzaba el mismo nivel

Colocar puntos sobre superficies de cubos y esferas

  • También siguieron los experimentos para proyectar puntos de manera uniforme sobre la superficie de una malla
  • El cubo se implementa colocando puntos a lo largo de cada cara
    • Se asume que todas las aristas tienen la misma longitud
    • Se recorren las caras y se llenan con puntos según el tamaño dado
    • Al aumentar la cantidad de puntos, mejora la precisión del cubo
  • El ejemplo del cubo permite interactuar con toques y añade una pequeña luz que sigue la posición del mouse para dar más profundidad
  • El cálculo de iluminación no es exacto y usa varias “magic constants”
  • La función de mapeo de partículas del cubo y varios helpers matemáticos se obtuvieron con gypity
    • El primer resultado fue una distribución aleatoria
    • En el segundo intento se logró una distribución uniforme sobre la superficie del cubo
  • La distribución uniforme sobre la superficie de una esfera es más compleja, por lo que se usó un método de spiral discretization
    • La idea es colocar puntos uniformemente sobre una línea que envuelve la esfera desde abajo hacia arriba
    • Se compara con una cuerda rodeando una pelota
  • Con este mapeo lineal también se creó una animación conectada al arreglo de valores de frecuencia de la música
  • A los puntos se les aplicó una animación de paleta con gradientes interpolados en el tiempo, y esta parte fue más difícil que el trabajo anterior
  • El ejemplo de esfera con pocas vueltas muestra que la espiral aproxima una distribución uniforme, pero si no hay suficientes vueltas, se rompe visualmente

Ray tracer con box-shadow

  • La idea de que si se pueden dibujar triángulos con box-shadow casi se podría crear cualquier CGI llevó al experimento de ray tracing
  • El objetivo era dibujar una escena usando solo un div y box-shadow
  • Los ejemplos posteriores combinan renderizado en vivo de baja precisión y renderizado de imagen de alta precisión, y algunos no tienen límite de precisión, así que conviene ejecutarlos con cuidado
  • Los ray tracers y ray marchers son formas exactas pero lentas de generar imágenes, y se usan en toda la industria del CGI
  • Aquí se usó un tracer basado en CPU en lugar de GPU
    • Porque usar GPU le quitaría sentido al experimento y además haría más difícil la implementación
  • El tracer inicial se hizo para renderizar solo esferas, y el modelo de datos de la escena incluía cámara, varias esferas e información de materiales
  • El código inicial obtenido con gypity funcionaba en tiempo real, pero tenía problemas
    • Faltaba la línea que suma el bias coefficient necesario en la optimización de muestreo
    • Había un comentario relacionado, pero faltaba la línea real, y se corrigió después de revisar la técnica de muestreo

progressive rendering y Three.js

  • Después, el código cambió a una estructura de progressive rendering
  • El progressive rendering reparte muchos cálculos de rayos entre varios frames y muestra cómo el render se acerca gradualmente al “ground truth”
  • Esta estructura encaja bien con la posibilidad de mover la cámara de manera interactiva
  • La cámara y los orbit controls usan la librería Three.js
    • Para evitar implementar a mano la matemática de matrices y el soporte móvil
  • Esa versión solo puede renderizar esferas, y todos los elementos de la escena son, en cierta medida, esferas escaladas
  • El ejemplo corre por defecto a poco más de 6% de la resolución completa
  • Mientras más lejos está la escena en pantalla, más nítida parece la baja resolución, y al acercarse se hace más evidente la falta de precisión
  • Con parámetros de consulta se pueden ajustar la escala de resolución, pixelSize, la cantidad de rebotes de rayos y el máximo de muestras
  • Si se aumentan la resolución y las muestras, el resultado mejora, pero se vuelve muy lento

Implementación del tracer y cuellos de botella de rendimiento

  • El renderizado sigue el enfoque estándar: disparar un rayo por cada píxel según la cámara, calcular el color y luego promediar entre frames
  • Como el tracer usaba Three.js, se creaban muchos objetos nuevos y se acumulaban rápidamente como basura
  • Se intentó reutilizar algunos objetos, pero para exprimir el rendimiento al máximo habría sido mejor no usar Three.js
  • Según el profiler, la recolección de basura no marcó una diferencia grande, así que se mantuvo el uso de Three.js
  • La idea básica del tracer es que el rayo rebota contra los objetos de la escena hasta llegar a la luz y devuelve un color según las propiedades del objeto y de la iluminación
  • Se necesitan muchos rayos porque algunos no alcanzan la fuente de luz y otros sí
  • Este tracer usa un modelo de iluminación simple
    • Sin BRDF físicamente correcta
    • Sin texturas
    • Sin subsurface scattering
    • Con iluminación difusa simple y specular reflection
  • Como material para aprender ray tracing, se puede consultar Ray Tracing in One Weekend
  • Después se logró renderizar planos y, tras corregir un error al manipular bits de valores de coma flotante, también se pudieron renderizar rectángulos de luz

Paralelizar con Web Workers

  • La mejora de rendimiento se abordó con multithreading usando Web Workers, en lugar de corregir el problema de GC en Three.js
  • El ray tracing encaja bien con la paralelización porque cada cálculo devuelve un solo resultado y no tiene efectos secundarios
  • El worker manager crea un pool de workers y ofrece dos métodos, render y updateScene, para poder cambiar la escena en tiempo de ejecución
  • El código del worker es básicamente una copia del tracer existente, y para reemplazar la escena fue necesario hacer marshaling de datos
  • Para reducir el overhead de postMessage, el worker renderiza frames completos en lugar de un solo píxel
  • El ejemplo a pantalla completa funciona mucho más rápido que antes
  • La desventaja es que si cambia la cámara o la escena, todos los resultados previos de rayos quedan inválidos y la pantalla puede verse negra
  • La solución se implementó levantando un flag isDirty en los eventos de entrada y dejando que el loop de actualización decida si debe limpiar el frame
  • Puede pasar que un worker envíe un frame calculado con datos de la escena anterior justo después de una actualización de escena
    • Se podría descartar incluyendo un timestamp o scene id en postMessage
    • En el experimento se dejó así, porque un frame con rayos incorrectos pronto queda promediado
  • En conjunto, el ray tracing basado en box-shadow funcionó lo bastante bien, y el código fuente está en CodeSandbox
  • Las estadísticas de rendimiento muestran una estimación del total de rayos, la cantidad de muestras y la resolución de renderizado
  • El muestreo predeterminado se detiene en 1200, pero se puede configurar
  • También se puede activar un modelo de iluminación alternativo, más rápido pero menos preciso
  • Los datos de la escena están en JSON, así que es fácil modificarlos directamente

Conclusión: un experimento posible, pero no recomendable

  • Solo con box-shadow de un único div se pueden dibujar simulaciones de pelotas, nubes de puntos, partículas basadas en imágenes, superficies de cubos y esferas, e incluso escenas con ray tracing
  • Aun así, todo el experimento se acerca más a una forma de “no deberías hacer esto” sin un caso práctico real
  • Hay margen de mejora si se añaden carga de triangle mesh, acceleration structure y modelos de iluminación más precisos
  • Al principio, gypity respondió que un ray tracer con box-shadow era imposible, pero el ejemplo real terminó existiendo
  • CSS no es intuitivo, pero a veces tiene posibilidades extrañas que claramente funcionan

1 comentarios

 
GN⁺ 2024-07-22
Opiniones de Hacker News
  • Usar colores transparentes lo vuelve más lento porque limita el procesamiento por lotes de dibujo en GPU.
    El dibujo opaco puede usar el búfer de profundidad para reordenar libremente las operaciones y maximizar el procesamiento por lotes. En el caso de la transparencia, hay que dibujar en orden de pintor para que la mezcla funcione correctamente.

    • Creo que es más complejo que eso. Los navegadores web también usan renderizado por GPU, pero no son motores de videojuegos, y si redibujaran todos los objetos de la pantalla en cada frame, las páginas grandes y complejas podrían trabarse fácilmente.
      En particular, Chromium intenta minimizar la cantidad total de capas, renderiza cada capa como un mapa de píxeles y luego compone en cada frame las capas visibles en la imagen final. En la práctica, muchas capas no cambian sus píxeles y solo se mueven de posición, así que basta con componerlas sin rasterizarlas en cada frame.
      Si hay muchos box-shadow sin transparencia, Chromium puede rasterizar todo de una sola vez en una capa, pero si hay muchos box-shadow transparentes, podría convertir cada uno en una capa separada. En ese caso puede ser ineficiente, pero también es un enfoque necesario si esos box-shadow semitransparentes deben moverse de forma independiente en la página.
    • Hay casos en los que el orden de pintor inverso es mejor que el orden de pintor, porque permite omitir objetos que quedan completamente cubiertos.
      Se puede empezar con un búfer totalmente transparente (α=0.0), recorrer cada superficie de adelante hacia atrás y, para cada píxel, mezclar en el búfer existente la proporción 1.0-buffer.α del nuevo píxel. Si buffer.α == 1.0, se puede omitir por completo, como con un búfer de profundidad.
      Eso sí, habría que revisar la matemática cuando hay otro objeto transparente detrás de un objeto transparente, y los casos en los que las superficies se superponen de forma cíclica o se atraviesan entre sí son complicados.
    • A las GPU tampoco les gusta el overdraw, así que conviene evitar apilar varias capas de elementos transparentes.
      Esa es también la razón por la que, en general, suele ser mejor dibujar más triángulos que usar texturas transparentes.
    • La etapa de ordenamiento en sí normalmente no es un gran problema. De todos modos, muchas veces se ordena antes de enviar los draw calls. El costo real viene del overdraw.
      El renderizado opaco puede dibujarse de adelante hacia atrás, de modo que solo se renderiza lo que realmente se verá en el framebuffer final, y la cantidad de píxeles después del pase de profundidad es proporcional al tamaño del framebuffer.
      El renderizado transparente debe dibujarse de atrás hacia adelante, y hay que renderizar muchas partes de la escena que luego quedarán parcialmente cubiertas por otros polígonos. Por eso la cantidad de píxeles que pasan por el pipeline de shaders puede crecer hasta ser proporcional al tamaño de la malla.
      Si los elementos no se superponen entre sí, la transparencia casi no debería ralentizar nada. Al fin y al cabo, hay que tocar cada píxel una vez, y lo único que cambió es la fórmula del shader.
    • Hay otro factor a considerar: especialmente en dispositivos móviles, el ancho de banda de memoria puede ser el cuello de botella.
      Si un dibujo opaco queda encima de otro, en el mejor caso se pueden eliminar todas las operaciones de dibujo superpuestas, y en el peor caso solo se usa el ancho de banda correspondiente a cada dibujo individual.
      Con transparencia, si no se pueden combinar de alguna manera las operaciones, hay que volver a leer toda el área superpuesta, por lo que con cada dibujo transparente termina viajando por el bus de memoria un bitmap de al menos el doble del tamaño del framebuffer final.
      Si recordamos que muchos dispositivos móviles ni siquiera tenían suficiente ancho de banda de memoria para hacer dos blits de pantalla completa dentro del tiempo necesario para mantener 60 fps, es un problema bastante grande.
  • Fue una exploración realmente divertida.
    También es cierto que capas es una palabra clave. En un proyecto de sombras de texto que hice hace 14 años, los efectos absurdos pero a veces vistosos también dependían de las capas: https://paulirish.github.io/mothereffingtextshadow/

    • Este artículo también parece tener varias capas y, de hecho, no parece tratar solo sobre box-shadow.
  • Me dio un poco de vergüenza darme cuenta recién en el último párrafo de que gypity se refería a ChatGPT.

    • Es una expresión que usan en el canal de YouTube de Primeagen.
    • Al ver esto me enteré de que esa palabra no era un chiste interno inventado por un compañero, sino una expresión que se está volviendo meme.
    • Pensé que era alguna herramienta del lado del cliente relacionada con gyp.
  • Bienvenido por completo el buen viejo hackeo impráctico. Solo hay que recordar que ya existe canvas, y que para estas cosas canvas es más fácil, más rápido y lo hace mejor.

    • Pero tiene menos valor cómico. Lo gracioso de hacer esto solo con box-shadow es justamente lo impráctico que resulta.
    • Canvas implica renunciar a mucho, especialmente en accesibilidad.
    • Canvas sí es mejor, pero eso aplica cuando estás haciendo algo que se mantiene dentro de una caja de tamaño fijo.
    • Claro, es parecido a decir que, como ya existen los teclados, tampoco hace falta hacer música con disqueteras o pollos de goma.
  • La visualización musical fue definitivamente genial. De verdad extraño aquellos tiempos de Winamp, cuando ponías música y abrías las visualizaciones en pantalla completa.
    Ojalá los reproductores de audio por streaming actuales ofrecieran algo así.

    • Todavía uso Winamp, más precisamente WACUP, con visualizaciones de Milkdrop casi todos los días.
      Aunque es cierto que los reproductores de audio por streaming son software con funciones realmente básicas.
  • Al final, Firefox y Chrome todavía renderizan de forma distinta un box-shadow de 1 px con el zoom del navegador al 150%.
    Tengo esperanzas en Baseline 2025.

  • Me gustaría saber si hay referencias para aprender más sobre esos “hacks matemáticos para dibujar muy barato las cajas redondeadas que tanto les gustan a la gente de UI”.

  • Es el tipo de hack que me gusta.
    Parece casi lo opuesto a los artículos de Josh Comeau que había leído sobre este tema: https://www.google.com/search?q=josh+comeau+shadows

  • Un artículo excelente, quizá el mejor que leí este año, terminó al final con your welcome en vez de you're.
    Hay que corregirlo rápido. O tal vez no entendí el chiste.

  • Durante los últimos 30 años me volví bueno programando, pero como no me gustan los videojuegos, casi no hice gráficos.
    Ahora veo eso como un error enorme y llevo más de un año intentando ponerme al día, pero es realmente difícil.

    • Yo empecé haciendo juegos y luego pasé a CRUD o programación simple en Oracle Forms, y de la noche a la mañana empecé a ganar el triple de mi salario original como programador de juegos.