Mirage: Cloudy Evolui de uma Biblioteca de Desfoque para uma Ferramenta de Efeitos Gráficos
Escreva uma vez, renderize em qualquer lugar
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.
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 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.
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.
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.
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.
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.
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.
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.

