Quanto você sabe sobre `box-shadow`?
(dgerrells.com)- O CSS box-shadow normalmente é uma propriedade usada para dar sensação de profundidade à UI, mas ao sobrepor centenas ou milhares deles em uma única
div, ele passa a funcionar como uma espécie de API de desenho - Mesmo com a limitação de ser uma caixa, dá para criar pontos circulares e efeitos em camadas com cantos arredondados, várias cadeias de sombras e combinações de cor, blur e transparência
- Nos experimentos, blur e transparência reduziram bastante a quantidade que podia ser animada; sem isso, foi possível desenhar milhares de
box-shadowem um notebook com M1 - Simulação de bolas, pixels de imagem, pontos sobre superfícies de cubos e esferas, e visualizações reativas à música foram todos implementados atualizando a string de
box-shadowa cada frame - No fim, um ray tracer baseado em CPU foi paralelizado com Web Workers, mostrando que até o render de cenas é possível usando apenas uma única
divebox-shadow
Usando box-shadow como ferramenta de desenho
- O CSS
box-shadowé uma forma de drop shadow, originalmente usada para desenhar sombras atrás de imagens ou elementos de UI e dar sensação de profundidade - Um drop shadow comum desloca o raster da imagem nos eixos x/y e o desenha atrás do original com uma única cor para criar profundidade
- O CSS
filter: drop-shadow()recebe deslocamentos x/y e cor, e opcionalmente também aceita um valor de blur - Ao colocar vários filtros de drop shadow em camadas, é possível adicionar profundidade à composição da tela
Características e limitações de box-shadow
- O “Box” de
box-shadowsignifica que o formato da sombra fica limitado à bounding box do contêiner - Essa restrição pode parecer limitante, mas como a maior parte das UIs é composta por caixas, ela é útil em interfaces reais
- A implementação de CSS box-shadow oferece otimizações matemáticas para desenhar caixas arredondadas de forma barata, e caixas arredondadas podem parecer círculos
- Designers podem usar
box-shadowsem recorrer a imagens pré-renderizadas, evitando aumentar o tamanho de download - Uma única
divpode ter váriosbox-shadowencadeados, e o exemplo usa esse método para compor cores e sombras - É possível experimentar ajustes de border-radius no CSS border-radius generator
A forma “errada” de usar box-shadow
- No design de UI convencional, sombras são usadas como elemento auxiliar para diferenciar estados e interações, junto com espaçamento, padding, tipografia e acessibilidade
- Mas
box-shadowtambém pode ser tratado como uma API de pintura que colore um número arbitrário de retângulos na tela e opcionalmente aplica blur - Os experimentos iniciais começaram com a arte minimalista do post anterior, gerando resultados visuais só com uma configuração simples de blocos de cor
- Depois, foi criada uma ferramenta de visualização para observar o impacto de mais
box-shadow, blur e transparência no desempenho- Gerava uma string grande de
box-shadowe a aplicava a uma únicadiv - A animação começou trocando a string de
box-shadowa cada 300 ms e deixandotransition: allcuidar do resto - Esse método tinha jank e era mais lento do que definir
box-shadowa cada frame
- Gerava uma string grande de
- O exemplo com 100
box-shadowfaz remix das paletas de cor por toque e mostra à esquerda o histórico das 10 paletas mais recentes - Ao aplicar blur, a quantidade animável caiu, e cores transparentes também reduziram bastante o número de elementos que podiam ser desenhados
- O tamanho da
divtambém afetava o desempenho, e parecia haver envolvimento do rasterizador por software durante a animação - Sem usar transparência nem blur, um notebook com M1 conseguia desenhar milhares de
box-shadow
Criando uma simulação de bolas com box-shadow
box-shadownão pode ser rotacionado, mas com border-radius é possível fazê-lo parecer circular- Várias sombras circulares foram tratadas como bolas, com escala baseada no valor de z para criar uma falsa sensação de profundidade 3D
- A implementação atualiza o estado do jogo em
requestAnimationFramee define uma grande string debox-shadownadiva cada frame - O processo de renderização funciona assim
- As bolas são ordenadas pelo valor de z
- O tamanho é calculado com base no z
- A posição x/y, o spread e a cor de cada bola são convertidos em um item de
box-shadow - Os itens são unidos por vírgulas em uma única string
- O exemplo com 50 bolas permite arrastar para movê-las e fazê-las quicar dentro da caixa
- A escala 3D falsa dá alguma noção de profundidade, mas quando as bolas se aproximam da “câmera”, os círculos podem parecer quebrados
- Isso acontece porque a
divusada como base para gerar obox-shadowé pequena demais para esse método de escala - Aumentar o tamanho do contêiner resolve, mas quanto maior o contêiner, pior o desempenho
- Isso acontece porque a
- A versão com colisão entre bolas usa verificação de colisão
n^2e apenas reflete a velocidade quando há impacto- Ela não simula interações físicas reais
- Para facilitar a visualização, a posição em z foi fixada, tornando tudo 2D
- Também foi criado um exemplo em que as bolas tentam voltar a posições iniciais aleatórias; ao puxá-las com uma força de toque, surge um efeito parecido com arrancar pedaços de uma esponja
Imagens e nuvens de pontos 3D
- O experimento seguinte mapeia pixels de imagem como pontos em um plano 2D e desenha cada ponto com
box-shadow - As posições e cores dos pixels da imagem são lidas e armazenadas em
state.particles, usando cada pixel como uma partícula - O código-fonte está no exemplo no CodeSandbox
- Esse exemplo renderiza milhares de
box-shadowem espaço 3D e permite uma interação em que arrastar espalha a imagem como se ela se desfizesse - A versão live com rotação automática está disponível em
/box-shadow/v3?width=80&size=5&autoRotate=1, com um aviso sobre consumo de bateria ao executar - Um exemplo com quantidade ainda maior chegou a cerca de 12.000
box-shadow, e nesse nível já mostrava engasgos - No M1 foi possível lidar com muitos
box-shadow, mas desktop, iPhone e Androids mais antigos não alcançaram o mesmo nível
Distribuindo pontos na superfície de cubos e esferas
- Os experimentos continuaram com a projeção uniforme de pontos sobre superfícies de malhas
- O cubo foi implementado distribuindo pontos ao longo de cada face
- Assume-se que todas as arestas têm o mesmo comprimento
- Cada face é percorrida e preenchida com pontos conforme o tamanho definido
- Aumentar a quantidade de pontos melhora a precisão do cubo
- O exemplo do cubo pode ser manipulado por toque e inclui uma pequena luz que segue a posição do mouse para reforçar a sensação de profundidade
- O cálculo de iluminação não é preciso e usa várias “magic constants”
- A função de mapeamento das partículas do cubo e vários helpers matemáticos foram obtidos com o gypity
- O primeiro resultado tinha distribuição aleatória
- Na segunda tentativa, foi obtida uma distribuição uniforme sobre a superfície do cubo
- A distribuição uniforme sobre a superfície de uma esfera é mais complexa, então foi usado um método de spiral discretization
- A ideia é distribuir os pontos uniformemente ao longo de uma linha que envolve a esfera de baixo para cima
- É comparada a uma corda enrolando ao redor de uma bola
- Usando esse mapeamento linear, também foi criada uma animação ligada a um array de valores de frequência da música
- Nos pontos foi aplicada uma animação de paleta em gradiente interpolada ao longo do tempo, e essa parte foi mais difícil do que os trabalhos anteriores
- O exemplo da esfera com poucas rotações mostra que a espiral aproxima uma distribuição uniforme, mas sem rotações suficientes a forma fica quebrada
Ray tracer com box-shadow
- A ideia de que, se fosse possível desenhar triângulos com
box-shadow, quase qualquer CGI poderia ser feita levou ao experimento de ray tracing - O objetivo era desenhar uma cena usando apenas uma única
divebox-shadow - Os exemplos seguintes combinam renderização ao vivo de baixa precisão com renderização de imagem em alta precisão, e alguns não limitam a precisão, então é preciso cuidado ao executar
- Ray tracers e ray marchers são formas precisas, porém lentas, de gerar imagens e são usadas em toda a indústria de CGI
- Aqui foi usado um tracer baseado em CPU, e não em GPU
- Com GPU, a proposta do experimento perderia força e a implementação também seria mais difícil
- O tracer inicial foi feito para renderizar apenas esferas, e o modelo de dados da cena incluía câmera, várias esferas e informações de material
- O código inicial obtido com gypity funcionava em tempo real, mas tinha problemas
- Faltava a linha que somava o bias coefficient necessário na otimização de sampling
- O comentário correspondente existia, mas a linha real não, e isso foi corrigido após pesquisar a técnica de sampling
progressive rendering e Three.js
- Depois, o código passou a usar uma estrutura de progressive rendering
progressive renderingdistribui muitos cálculos de ray ao longo de vários frames, mostrando o render se aproximando gradualmente do “ground truth”- Essa estrutura combina bem com movimentação interativa da câmera
- A câmera e os orbit controls usam a biblioteca Three.js
- Isso evita implementar manualmente matemática de matrizes e suporte mobile
- Essa versão só consegue renderizar esferas, e todos os elementos da cena são esferas escaladas em algum grau
- O exemplo roda por padrão em pouco mais de 6% da resolução total
- Quanto mais longe da tela, mais nítida parece a cena em baixa resolução; de perto, a falta de precisão fica bem mais evidente
- É possível ajustar por query parameter a escala de resolução,
pixelSize, a quantidade de ray bounces e o número máximo de samples - Aumentar resolução e samples melhora o resultado, mas torna tudo muito lento
Implementação do tracer e gargalos de desempenho
- O render segue o método padrão: lançar um ray por pixel a partir da câmera, calcular a cor e depois tirar a média entre frames
- Como o tracer usa Three.js, muitos objetos novos são criados e rapidamente viram lixo
- Houve alguma tentativa de reutilizar objetos, mas para extrair o máximo de desempenho seria melhor não usar Three.js
- Segundo o profiler, a coleta de lixo não fazia tanta diferença, então o uso de Three.js foi mantido
- A ideia básica do tracer é que o ray acerte objetos na cena e continue quicando até alcançar a luz, retornando cores conforme as propriedades dos objetos e da iluminação
- É preciso lançar muitos rays porque alguns chegam à fonte de luz e outros não
- Esse tracer usa um modelo de iluminação simples
- Sem BRDF fisicamente correta
- Sem texturas
- Sem subsurface scattering
- Apenas luz difusa simples e specular reflection
- Como material de estudo sobre ray tracing, vale ver Ray Tracing in One Weekend
- Depois foi possível renderizar planos, e após corrigir um erro de manipulação de bits em valores de floating point, também foi possível renderizar retângulos de luz
Paralelizando com Web Workers
- Em vez de atacar o problema de GC do Three.js, a melhoria de desempenho veio com multithreading usando Web Workers
- Ray tracing se adapta bem à paralelização porque cada cálculo retorna um único resultado e não tem efeitos colaterais
- O worker manager cria um pool de workers e oferece os métodos
rendereupdateScene, permitindo trocar a cena em tempo de execução - O código dos workers é basicamente uma cópia do tracer existente, e foi necessário fazer marshaling de dados para substituir a cena
- Para reduzir o overhead de
postMessage, cada worker renderiza o frame inteiro em vez de um único pixel - O exemplo em tela cheia roda muito mais rápido do que antes
- A desvantagem é que, quando a câmera ou a cena mudam, os resultados anteriores dos rays ficam inválidos e a tela pode parecer preta
- A solução foi usar uma flag
isDirtynos eventos de entrada e deixar o loop de atualização decidir quando limpar o frame - Pode acontecer de um worker enviar um frame calculado com dados antigos da cena logo após a atualização
- Seria possível descartar isso incluindo timestamp ou scene id no
postMessage - No experimento, isso foi deixado como está porque um frame com rays errados logo entra na média e se dilui
- Seria possível descartar isso incluindo timestamp ou scene id no
- No fim, o ray tracing baseado em
box-shadowfuncionou bem, e o código-fonte está no CodeSandbox - As estatísticas de desempenho mostram estimativa do total de rays, número de samples e resolução de render
- O padrão é parar em 1200 samples, mas isso pode ser configurado
- Também é possível ativar um modelo alternativo de iluminação, mais rápido, porém menos preciso
- Os dados da cena estão em JSON, então são fáceis de editar manualmente
Conclusão: experimento possível, mas não recomendado
- Só com o
box-shadowde uma únicadivé possível desenhar simulação de bolas, nuvens de pontos, partículas baseadas em imagem, superfícies de cubos e esferas, e até cenas com ray tracing - Ainda assim, todo o experimento fica mais próximo de um jeito “não faça isso” sem caso de uso prático
- Ainda há espaço para melhorar com carregamento de triangle mesh, acceleration structure e modelos de iluminação mais precisos
- O gypity respondeu no começo que um ray tracer com
box-shadowera impossível, mas um exemplo real acabou sendo criado - CSS não é intuitivo, mas às vezes tem possibilidades estranhas que claramente funcionam
1 comentários
Opiniões no Hacker News
Usar uma cor transparente fica mais lento porque limita o processamento em lote do desenho pela GPU.
Desenhos opacos podem usar o buffer de profundidade para mudar a ordem livremente e maximizar o processamento em lote. No caso de transparência, é preciso desenhar na ordem do pintor para que o blending funcione corretamente.
Em especial, o Chromium tenta minimizar o número total de camadas, renderiza cada camada como um mapa de pixels e depois compõe, a cada frame, as camadas visíveis na imagem final. Na prática, muitas camadas não mudam seus pixels, apenas sua posição, então basta compor sem rasterizar a cada frame.
Com muitos box-shadows sem transparência, o Chromium pode rasterizar tudo de uma vez em uma única camada, mas, com muitos box-shadows transparentes, pode criar uma camada separada para cada um. Nesse caso, pode ser ineficiente, mas também é uma abordagem necessária se box-shadows semitransparentes precisarem se mover independentemente na página.
Basta começar com um buffer totalmente transparente (α=0.0), percorrer cada face da frente para trás e, para cada pixel, fazer o blending do novo pixel com o buffer existente na proporção de
1.0-buffer.α. Sebuffer.α == 1.0, dá para pular completamente, como em um buffer de profundidade.Dito isso, seria preciso conferir de novo a matemática quando há outro objeto transparente atrás de um objeto transparente, e casos em que as faces se sobrepõem de forma cíclica ou se atravessam são complicados.
É também por isso que geralmente é melhor desenhar mais triângulos do que usar texturas transparentes.
Na renderização opaca, é possível desenhar da frente para trás, então você renderiza apenas o que realmente fica visível no framebuffer final, e a quantidade de pixels depois do passe de profundidade é proporcional ao tamanho do framebuffer.
Na renderização transparente, é preciso desenhar de trás para frente, renderizando muitas partes da cena que depois serão parcialmente cobertas por outros polígonos. Por isso, a quantidade de pixels que passa pelo pipeline de shaders pode crescer a ponto de ser proporcional ao tamanho da malha.
Se os elementos não se sobrepõem, a transparência quase não deve deixar nada mais lento. Afinal, cada pixel precisa ser tocado uma vez de qualquer jeito, e o que mudou foi só a fórmula do shader.
Quando um desenho opaco fica por cima de outro desenho, no melhor caso dá para eliminar todas as operações de desenho sobrepostas; no pior, usa-se apenas a largura de banda equivalente aos desenhos individuais.
Com transparência, se não der para combinar as operações de alguma forma, é preciso reler toda a área sobreposta, então, a cada desenho transparente, um bitmap equivalente a pelo menos duas vezes o tamanho do framebuffer final trafega pelo barramento de memória.
Considerando que muitos dispositivos móveis não tinham largura de banda de memória suficiente nem para fazer blit da tela inteira duas vezes dentro do tempo necessário para manter 60 fps, isso se torna um problema considerável.
Foi uma exploração realmente divertida.
Também é verdade que layering é a palavra importante. O efeito absurdo, mas às vezes bonito, de um projeto de sombras de texto que fiz 14 anos atrás também tinha layering como peça central: https://paulirish.github.io/mothereffingtextshadow/
Fiquei meio constrangido por só perceber no último parágrafo que
gypityse referia ao ChatGPT.gyp.Hackear coisas inúteis no bom estilo antigo é totalmente bem-vindo. Mas vale lembrar que já existe canvas, e canvas faz esse tipo de coisa de forma mais fácil, rápida e melhor.
A visualização musical foi definitivamente legal. Sinto muita falta da época do Winamp, quando a gente colocava música para tocar e abria a visualização em tela cheia.
Queria que players de áudio por streaming de hoje também tivessem esse recurso.
Dito isso, é verdade que players de áudio por streaming são softwares com recursos realmente muito básicos.
No fim das contas, Firefox e Chrome ainda renderizam box-shadow de 1px de forma diferente com zoom do navegador em 150%.
Vamos apostar no Baseline 2025.
Fico curioso se há boas referências para aprender mais sobre “hacks matemáticos para desenhar, de forma muito barata, aquelas caixas arredondadas que o pessoal de UI adora”.
https://www.folklore.org/Round_Rects_Are_Everywhere.html
http://wg20.criticalcodestudies.com/index.php?p=/discussion/...
Para uma abordagem moderna com aceleração 3D, há um texto que usa SDF, como outra pessoa supôs.
https://mortoray.com/quickly-drawing-a-rounded-rectangle-wit...
É o tipo de hack que eu gosto.
Parece quase o oposto dos textos do Josh Comeau que li sobre esse assunto: https://www.google.com/search?q=josh+comeau+shadows
Um texto excelente, talvez o melhor que li este ano, terminou no fim com your welcome em vez de
you're.Precisa ser corrigido logo. Ou talvez eu não tenha entendido a piada.
Nos últimos 30 anos, fiquei bom em programação, mas, como não gostava de jogos, quase não mexi com gráficos.
Hoje vejo isso como um erro enorme e há mais de um ano venho tentando recuperar o atraso, mas é realmente difícil.