1 pontos por GN⁺ 2024-07-22 | 1 comentários | Compartilhar no WhatsApp
  • 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-shadow em 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-shadow a 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 div e box-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-shadow significa 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-shadow sem recorrer a imagens pré-renderizadas, evitando aumentar o tamanho de download
  • Uma única div pode ter vários box-shadow encadeados, 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-shadow també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-shadow e a aplicava a uma única div
    • A animação começou trocando a string de box-shadow a cada 300 ms e deixando transition: all cuidar do resto
    • Esse método tinha jank e era mais lento do que definir box-shadow a cada frame
  • O exemplo com 100 box-shadow faz 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 div també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-shadow nã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 requestAnimationFrame e define uma grande string de box-shadow na div a 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 div usada como base para gerar o box-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
  • A versão com colisão entre bolas usa verificação de colisão n^2 e 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-shadow em 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 div e box-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 rendering distribui 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 render e updateScene, 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 isDirty nos 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
  • No fim, o ray tracing baseado em box-shadow funcionou 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-shadow de uma única div é 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-shadow era 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

 
GN⁺ 2024-07-22
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.

    • Acho que é mais complicado do que isso. Navegadores web também usam renderização por GPU, mas não são engines de jogos, e redesenhar todos os objetos da tela a cada frame pode facilmente causar engasgos em páginas grandes e complexas.
      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.
    • A ordem inversa do pintor às vezes é melhor que a ordem do pintor, porque permite pular objetos que ficam completamente cobertos.
      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.α. Se buffer.α == 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.
    • A GPU também não gosta de overdraw, então é melhor evitar empilhar várias camadas de elementos transparentes.
      É também por isso que geralmente é melhor desenhar mais triângulos do que usar texturas transparentes.
    • A etapa de ordenação em si normalmente não é um grande problema. Muitas vezes você já vai ordenar de qualquer forma antes de enviar os draw calls. O custo real vem do overdraw.
      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.
    • Há mais um ponto a considerar: especialmente em dispositivos móveis, a largura de banda de memória pode ser o gargalo.
      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/

    • Este texto também parece ter várias camadas e, na verdade, não parece ser só um texto sobre box-shadow.
  • Fiquei meio constrangido por só perceber no último parágrafo que gypity se referia ao ChatGPT.

    • É uma expressão usada no canal do Primeagen no YouTube.
    • Ao ver isso, percebi que a palavra não era uma piada interna criada por um colega, mas uma expressão que está virando meme.
    • Achei que fosse algum tipo de ferramenta client-side relacionada a 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.

    • Mas tem menos valor cômico. O motivo de ser engraçado fazer isso só com box-shadow é justamente o quanto é imprático.
    • Canvas faz você abrir mão de muita coisa, especialmente em acessibilidade.
    • Canvas é melhor mesmo, mas isso vale quando se está fazendo algo que fica dentro de uma caixa de tamanho fixo.
    • Claro, é parecido com dizer que, como já existem teclados, também não há necessidade de fazer música com drives de disquete ou uma galinha de borracha.
  • 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.

    • Ainda uso Winamp, mais exatamente WACUP, com visualizações Milkdrop quase todos os dias.
      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”.

  • É 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.

    • Eu comecei com jogos e depois migrei para CRUD ou programação simples em Oracle Forms, e da noite para o dia passei a ganhar três vezes o salário que recebia como programador de jogos.