Android

Mirage: Cloudy Evolui de uma Biblioteca de Desfoque para uma Ferramenta de Efeitos Gráficos

O projeto Mirage, derivado da biblioteca Cloudy de desfoque multiplataforma para Compose, evoluiu para uma ferramenta geral de efeitos gráficos que permite a execução de shaders GPU personalizados em diferentes plataformas através de uma única API. A novidade principal é o Mirage, que simplifica a criação e aplicação de efeitos visuais complexos em interfaces do usuário sem necessidade de codificação específica para cada plataforma.

Compartilhar
Medium

Mirage: Cloudy Evolui de uma Biblioteca de Desfoque para uma Ferramenta de Efeitos Gráficos

Escreva uma vez, renderize em qualquer lugar

Pressione Enter ou clique para ver a imagem em tamanho completo
Foto por Kurt Cotoaga em Unsplash

Cloudy começou como uma biblioteca Compose Multiplatform de desfoque. Modifier.cloudy lê o plano de fundo capturado atrás de um nó e o desfoca — multiplataforma, por meio de uma única API:

O desfoque é um único efeito com um único ajustável, o raio, e por muito tempo isso foi toda a biblioteca. Mirage é onde isso muda. É o primeiro passo em Cloudy crescendo além “a biblioteca de desfoque” para uma ferramenta geral de efeitos gráficos — uma maneira de executar efeitos de GPU autorizados, não apenas um desfoque fixo, sobre o conteúdo e os planos de fundo em seu UI Compose.

Executar o próprio shader em cada plataforma é mais difícil do que parece, e não por causa da linguagem de shader. Hoje Mirage reduz para AGSL no Android e SKSL no skiko, e desde que ambos são dialetos Skia-família a maioria dos núcleos se alinha quase verbatim — embora isso seja uma conveniência das backends atuais, não uma garantia de que um idioma mais distante como GLSL manteria. A parte teimosa é o runtime ao redor do shader: Android dirige um RuntimeShader, skiko dirige um Skia RuntimeEffect, e cada um tem seu próprio jeito de compilar o programa, vincular uniforms, alimentar o conteúdo e gerenciar a vida útil do efeito. Escreva um efeito à mão e você constrói essa infraestrutura uma vez por plataforma e mantém as cópias em sincronia. Mirage permite que você escreva isso uma vez, como um núcleo de fonte comum, e tira a infraestrutura das suas mãos em todos os lados. É o que transforma “uma biblioteca de efeitos” de uma tarefa chata em algo prático para construir — e o que permite que um novo backend se encaixe mais tarde sem tocar nos próprios efeitos.

Mirage expõe essa superfície através da hierarquia selada Optic: um punhado de tipos de shader que você compõe em uma ordem planejada, sem tocar no nó que liga e desenha eles. Adicionar um efeito é uma linha; removê-lo é apagar essa linha. Os optics embutidos — tons duotônicos, iridescência de filme fino, um brilho espeque líquido-glass — são apenas os primeiros itens no catálogo, e a mesma porta está aberta para os seus.

O demo Mirage: um bloco de plano, uma lista de aparências — aqui a receita Iridescente em toda a largura sobre um cartaz.

Neste artigo, você explorará a arquitetura Mirage: como um plano é declarado uma vez e vinculado por desenho, como os tipos Optic diferem e por que há diferentes tipos em vez de apenas um, e como o interno — o compilador, a cache de programa global do processo e a cadeia de filtro encadeada de camadas — transforma um plano em pixels.

Onde o Mirage é aplicado

Antes de mergulhar no plano, é importante ver onde um efeito cai. Um plano sempre roda contra uma fonte de pixels, e há duas fontes — o conteúdo do nó em si ou a parede de fundo por trás dele —, então Modifier.mirage vem em dois sobrecargas sobre a mesma máquina.

A sobrecarga de conteúdo

Modifier.mirage { … }, aplica o plano aos pixels que o nó desenha. Seu conteúdo alimenta o shader, e a saída do shader substitui-o:

A sobrecarga de conteúdo no demo: mirage { filter(Specular) } corta uma lente refrativa especular no próprio conteúdo do nó.

A sobrecarga de parede de fundo

Modifier.mirage(sky = …) { … }, aplica o mesmo plano à região de uma parede capturada diretamente atrás do nó em vez disso. É a contraparte shader de Modifier.cloudy(sky = sky): onde cloudy emburrece essa parede, mirage(sky) a avalia com um efeito de shader do seu próprio. Um borrão tem aquele único raio para ajustar; uma matéria avaliada é aberta, e o plano é o que expressa isso:

O nó lê a região de parede de fundo diretamente atrás dele — rastreada via sua posição de layout, exatamente como Modifier.cloudy — alimenta esses pixels através das etapas do filtro do plano e desenha o resultado. O próprio conteúdo do nó é desenhado em cima, então você geralmente aplica isso a uma superfície vazia e transparente, deixando apenas a parede avaliada para aparecer. A captura requer um Modifier.sky(sky) ancestral para gravar o fundo — o mesmo detentor que Modifier.cloudy lê — e o efeito é atualizado automaticamente enquanto essa parede rola.

A sobrecarga de parede de fundo no demo: mirage(sky) { filter(Duotone) } avaliada a lista rolando atrás do cartão.

As duas sobrecargas são, de outra forma, idênticas: os mesmos tipos Optic, o mesmo bloco de plano, a mesma vinculação por desenho, a mesma cache. E abaixo da API 33 — onde o Android não tem RuntimeShader — os filtros são ignorados e a fonte bruta é desenhada, então a falha degradada silenciosamente em vez de travar.

O problema fundamental: compor efeitos sem editar o caminho de desenho

Considere um cenário comum: você deseja empilhar dois efeitos em uma superfície. Primeiro, tinga o fundo com um duotomismo — mapeando suas partes escuras para uma cor e suas partes claras para outra, digamos sombras azuis-escuro e destaque de creme. Em seguida, coloque sobre isso um brilho iridescente que flutua e cintila ao longo do tempo. Sem um plano, você entra diretamente no caminho de desenho, e é algo assim:

// Esboço bruto do método manual — não é real Cloudy API.
val duotone = RuntimeShader(DUOTONE_AGSL) // e uma segunda cópia SKSL para skiko
val foil = RuntimeShader(FOIL_AGSL)

Modifier.drawWithCache {
onDrawWithContent {
val t = /* seu próprio relógio de quadros */
duotone.setColorUniform("shadow", indigo)
duotone.setColorUniform("highlight", cream)
foil.setFloatUniform("time", t) // alimente cada uniform, a cada frame
// encadeamento: conteúdo -> duotomismo -> foil, na ordem correta, à mão
// ... e invalidar cada quadro para que o brilho se mova
}
}

Você aloca um RuntimeShader (ou seu equivalente skiko) por efeito, alimenta cada um com seus valores de entrada manualmente a cada quadro, encadeia-os na ordem correta e gira o próprio loop para animar o brilho — então escreve tudo duas vezes, uma em AGSL e outra em SKSL.

Dois problemas surgem disso. Um é que cada efeito está soldado ao local de desenho: reutilize a mesma receita duotomismo-plus-foil em dez telas e você copia essa configuração dez vezes; adicione um terceiro efeito e você edita o loop. O outro é que efeitos completamente diferentes — um que recoloriza cada pixel por conta própria, um que amostra o conteúdo livremente para compostar, um que pinta sobre ignorando o conteúdo inteiramente — acabam entrelaçados em um único bloco de código imperativo, e a ordem em que eles se empilham desaparece nele.

O plano dissolve ambos. Ele transforma cada efeito em um Optic autossuficiente que declara qual tipo ele é, e permite que você compõe optics declarativamente em uma lista ordenada. Adicionar um é uma única linha de filter(...) ou overlay(...); removê-lo deleta essa linha. A compilação, o cacheamento, a vinculação de uniformes e a ordem de desenho tornam-se responsabilidade do nó, não sua.

Introduzindo o plano e a hierarquia de Optics

Um plano é uma lista ordenada dos efeitos a aplicar, escrito como o bloco que você passa para Modifier.mirage (ambas as sobrecargas aceitam o mesmo bloco). Ele roda uma vez, no momento em que o nó se conecta, para fixar essa lista de etapas:

O detalhe-chave é a divisão entre uma vez e por desenho. O bloco do plano fixa a lista de etapas uma única vez, mas cada etapa opcional params re-roda a cada desenho — então ler o estado da captura dentro de um bloco params invalida apenas o desenho, nunca a recomposição. O loop de quadros, vinculação de uniformes e reaproveitamento do programa todos ficam abaixo dessa linha.

Novato em shaders e uniforms?

Um “shader” aqui é um pequeno programa que a GPU executa para calcular cada pixel, e um “uniform” é um valor de entrada — uma cor, uma posição, um tempo — que você passa ao programa em cada quadro.
Passar rapidamente pela linguagem de shaders do Android, o AGSL, ajuda muito a entender o resto deste artigo → documentação do AGSL.

O que você passa para filter e overlay é um Optic, e Optic é uma hierarquia selada cuja divisão é deliberada:

Cada tipo tem acesso fundamentalmente diferente ao conteúdo, e o sistema de tipos define essa diferença no tempo de compilação.

ColorizeOptic é uma transformação ponto a ponto. Você escreve apenas o corpo de um half4 kernel(float2 p, half4 src); o código gera automaticamente as declarações uniform e o conteúdo padrão de amostragem. Isso mapeia um pixel para uma nova cor sem ler seus vizinhos — um tinteiro, uma curva, um gradiente. MirageOptics.Duotone é um exemplo: ele mapeia a luminância sobre um gradiente de sombra → destaque e faz uma transição suave por meio de um amount. Como nunca amostra livremente, não carrega geometria de lente e é o tipo mais barato para aplicar.

ColorizeOptic

CompositeOptic é um kernel de acesso livre. Você escreve o próprio corpo completo de half4 main(float2 xy), e o código gera apenas as declarações uniform e uma prévia comum, deixando a acessibilidade ao conteúdo para o kernel. Este é o tipo que você usa quando um efeito amostra o conteúdo de forma arbitrária e compartilha intermediários entre termos: o glint especular reutiliza uma SDF em seus termos de refração e especular, e o iridescência fina amostra o conteúdo para tingi-lo. MirageOptics.Specular, Chromatic e os looks de filme fino nomeados (OilSlick, SoapBubble, MetallicFoil, Pearl) são todos compostos.

CompositeOptic

GenerateOptic é um gerador sem conteúdo. Seu kernel sintetiza pixels a partir de uniforms apenas — não há um sampler de conteúdo, então referências ao conteúdo são rejeitadas quando o Mirage reduz o kernel, não deixando que fiquem na GPU. Deliberadamente, ele não é um FilterOptic: o sistema de tipos mantém isso fora do caminho de filtro, e apenas overlay aceita-o. Um exemplo é o MirageOptics.Foil: uma folha com reflexos, arco-íris e brilhos que você compõe sobre qualquer coisa produzida pelos filtros, sob um modo de mistura escolhido.

GenerateOptic

Essa divisão selada é a recompensa. Porque FilterOptic está selado e o GenerateOptic fica fora dele, o compilador sabe no momento da escrita quais ópticas podem filtrar conteúdo e quais só podem sobrepor. Você não pode acidentalmente usar overlay com um duotone ou filter com uma foil — a combinação errada simplesmente não compila.

Como uniforms são declarados: MirageParams

Cada óptica carrega uma subclasse MirageParams que declara seus uniforms, e essa declaração é o esquema. Você escreve um único propriedade delegada por uniform:

Cada propriedade delegada faz duas coisas ao mesmo tempo. Ela nomeia um uniform shader — o nome da propriedade é o identificador que o código gerado emite no shader gerado — e ela também exibe uma manipulação tipada (UFloat, UColor, UOffset, USize, UVec4, UTexture, …) que você escreve em cada desenho, seja como amount(0.5f) ou amount.value = 0.5f. A ordem de declaração é a ordem de vinculação: o delegado registra cada slot com entusiasmo em provideDelegate, então o índice do slot é igual à ordem da fonte sem reflexão — que mantém tudo KMP-safe.

Há uma instância por nó, reutilizada em cada desenho, e toda escrita acontece na única fase de desenho, então não há sincronização, e uma escrita escalar ou de cor aloca nada por desenho. Uma manipulação é um slot tipado simples, não uma expressão shader: ela carrega o valor por desenho e seu slot de vinculação, nada mais.

Este é também o lugar onde o catálogo de presets obtém sua forma. Um preset é um kernel mais um conjunto padrão de parâmetros, e a aparência visual vive inteiramente nos valores declarados por padrão — nunca em uma constante shader codificada. É por isso que os cinco looks finos-filmes são o mesmo chromatic fabricado em diferentes parâmetros: um único programa GPU, cinco aparências. Também é por isso que alterar um valor nunca recompila, já que a cache de programas é chavada na fonte do kernel, não nos valores dos uniforms.

Como um plano se torna pixels: declare, compile e cache são executados uma vez quando o nó é anexado; bind e draw são executados em cada quadro, reutilizando o programa armazenado em cache.

Os presets de formato lente compartilham uma base MirageLensParams cujos valores padrão merecem nota:

lensCenter e lensSize têm como padrão Unspecified, resolvidos no momento do bind para o centro e tamanho completo do nó. Esta "auto-framing" é o que permite a um preset simples — filter(MirageOptics.Chromatic), sem bloco de params — cobrir todo o nó decorado. Um valor padrão fixo, em vez disso, pinaria a lente na origem do conteúdo e deixaria tudo fora dela como pass-through, o que em uma carta de fundo seria interpretado como "o efeito está desenhando atrás do conteúdo". Sobrescreva-o por cada desenho de um bloco filter { } quando desejar uma lente interativa rastreada pelo ponteiro.

Como um plano se torna pixels

Com o plano e suas óticas em mãos, o nó executa uma pipeline fixa. O diagrama resume isso: cinco etapas, divididas pela linha entre o que é executado uma vez, quando o nó é anexado e o que é executado em cada desenho.

Pressione Enter ou clique para visualizar a imagem em tamanho completo
Como um plano se torna pixels: declare, compile e cache são executados uma vez quando o nó é anexado; bind e draw são executados em cada quadro, reutilizando o programa armazenado em cache.

O restante desta seção percorre essas cinco etapas da esquerda para a direita.

Declare

O bloco de plano é executado uma vez e produz uma lista ordenada de Stages. Stage está encapsulado sobre as duas formas da aplicação: Stage.Filter (um filtro que transforma o conteúdo) e Stage.Overlay (um gerador sem conteúdo desenhado com um BlendMode). Cada etapa possui a única instância de MirageParams que o nó cria uma vez, além do bloco de parâmetros per-draw fornecido pelo chamador.

Compile

Cada óptica é baixada por categoria. Um kernel ColorizeOptic é envolvido com declarações de uniformes emitidas e uma função principal que amostra o conteúdo; um CompositeOptic recebe as declarações de uniformes e a prévia da lente, mas mantém sua própria função principal; um GenerateOptic recebe o mesmo tratamento menos o amostrador de conteúdo. O resultado é um CompiledProgram per-dialeto — texto AGSL no Android, SKSL em skiko — carregando bandeiras estáticas como usesTime, detectadas ao escanear a fonte por uma referência a mirageTime.

Cache

Programas compilados para GPU são compartilhados através de um cache process-wide, indexado pelo texto-fonte gerado, e não pela identidade do Optic:

Dois optics que se reduzem ao mesmo kernel — digamos, a família de presets cromáticos ou um optic levantado em dois diferentes vals — compartilham um único programa para GPU ao invés de compilar duas vezes.

O que importa aqui é o quê está sendo compartilhado. Apenas o programa para GPU compilado — o artefato caro — é compartilhado. Cada solicitação retorna um novo CachedProgram envolvendo esta chamada's CompiledProgram, então o solicitante sempre vê os padrões de esquema do seu próprio optic; compartilhar o esquema compilado também faria com que todos os optics da mesma fonte fossem anexados ao qual foi compilado primeiro, colapsando todas as cinco aparências finas em um único conjunto padrão. O cache é um singleton process-wide, deliberadamente não vinculado a qualquer nó's attach/detach — uma cache por nó recompilaria em cada re-attach e nunca poderia ser pré-aquecida.

Bind

A cada desenho, o nó caminha pelas mãos de params em ordem de slot e escreve seus valores atuais no coletor uniforme do programa backend. Porque o plano re-roda cada bloco de params aqui, não durante a recomposição, um lensCenter animado ou um iLight controlado por giroscópio custa uma invalidação de desenho e nada mais.

Draw

É aqui que a regra de ordem vive, impulsionada por um MirageFilterChain compartilhado. Os filtros encadeiam como uma pilha de efeitos de renderização ligados ao conteúdo: o estágio 0 registra a fonte (o próprio conteúdo do nó, ou — para a sobrecarga backdrop — a região offset backdrop), e cada filtro subsequente registra a camada anterior. Os overlays então compostam sobre o resultado totalmente encadeado, na ordem declarada, sob seu modo de fusão. As fases não são fusionadas; cada uma é um programa separado aplicado em sequência.

Esse último ponto é por que uma única cadeia serve para ambas as sobrecargas. A cadeia não mantém nenhum relógio, nenhuma Sky, nem a posse de params — ela recebe apenas pares já resolvidos (Stage.Filter, CachedProgram) e uma fonte stage-0. Um nó de conteúdo registra seu próprio conteúdo na fase 0; um nó backdrop registra a região Sky. O encadeamento, o bind por estágio e o pool de camadas são idênticos, então a mecânica é extraída apenas uma vez em vez de ser duplicada. É também por que a ramificação fallback API-33 não precisa de um ramos especiais: quando nenhum estágio é aplicável, a cadeia desenha a fonte stage-0 diretamente na tela, e a região backdrop crua simplesmente aparece.

Colocando tudo junto

Com o plano, os optics e a pipeline em vista, um material de backdrop completo é algumas linhas:

Cada óptica trata de uma preocupação específica. O filtro Duotone gradua o fundo capturado em um tom quente; a sobreposição Foil cria uma camada de efeito cintilante sobre o resultado e, por referenciar o kernel mirageTime, o relógio padrão gira em um loop para animar a imagem. Dois estágios, duas regras de composição, sem conexões manuais de shader — e o mesmo modificador é renderizado no Android, iOS, Desktop e Web a partir de uma única fonte.

Uma variante interativa é igualmente curta. Como o bloco params re-executa a cada desenho, você pode fornecer ao lensCenter um offset rastreado por ponteiro ou passar uma direção do giroscópio para o iLight, e o efeito acompanha a entrada sem necessidade de recompilação. Quando uma exigência muda, você desanexa uma óptica removendo uma linha e anexa outra em seu lugar; o nó é atualizado in-place ao invés de ser reconstruído, e os programas compilados permanecem quentes no cache, então a reabilitação nunca recompila.

Você agora viu toda a arquitetura: o hierarquia fechada Optic e por que filtros e geradores são tipos separados, como as propriedades delegadas declaram uniformes através de um esquema no MirageParams, como um plano divide o declarado uma vez do vinculado por desenho, e como o compilador, o cache baseado em chave de origem e a cadeia de camadas encadeada MirageFilterChain transformam um plano de duas linhas em um material do GPU que funciona em qualquer lugar onde Compose funcione. Como cada óptica é autossuficiente e se anexa com uma única linha, a complexidade de empilhar um tom, uma composição e uma sobreposição nunca ultrapassa três linhas.

E esse é o verdadeiro ponto do Mirage. Modifier.cloudy deu um efeito ao Cloudy; o Mirage dá uma maneira de construir qualquer número, a partir de uma única descrição, com um catálogo em crescimento para começar. O desfoque não é mais toda a biblioteca — ele é apenas um dos muitos efeitos disponíveis. Essa é a mudança que o Mirage inicia: Cloudy é agora uma biblioteca de efeitos gráficos, e o desfoque é apenas sua primeira entrada.

Fonte original

Conteúdo traduzido e adaptado pela redação do Notícias Mobile. Confira também a matéria na fonte original.

Leia a matéria completa