Fronteiras da interface do usuário

Animações de Lissajous no Silverlight

Charles Petzold

Baixar o código de exemplo

aComumente, consideramos o software mais flexível e versátil que o hardware. Isso certamente é verdadeiro em muitos casos, pois o hardware geralmente está preso em uma configuração, ao passo que o software pode ser reprogramado para executar tarefas completamente diferentes.

Entretanto, algumas partes prosaicas do hardware são, na verdade, bastante versáteis. Considere o comum — ou não tão comum atualmente — CRT (cathode ray tube - tubo de raio catódico). Ele é um dispositivo que emite um fluxo de elétrons dentro de uma tela de vidro. A tela é recoberta por um material fluorescente que reage a esses elétrons brilhando rapidamente.

Em aparelhos de TV e monitores de computador antigos, o bombardeador de elétrons move-se em um padrão constante, percorrendo toda a tela horizontalmente de maneira repetitiva e, ao mesmo tempo, percorrendo-a mais vagarosamente de cima para baixo. A intensidade dos elétrons a qualquer momento determina o brilho de um ponto nesse instante. Para que a cor seja exibida, bombardeadores de elétrons separados são utilizados para criar as cores primárias vermelho, verde e azul.

A direção do bombardeador de elétrons é controlada por eletroímãs e ela pode realmente ser voltada para qualquer local arbitrário da superfície de duas dimensões do vidro. É assim que o CRT é utilizado em um osciloscópio. Normalmente, o feixe percorre toda a tela horizontalmente em uma taxa constante, geralmente em sincronia com uma forma de onda de entrada específica. A deflexão vertical mostra a amplitude dessa forma de onda naquele ponto. A grande persistência do material fluorescente utilizado em osciloscópios permite que toda a forma de onda seja exibida — realmente “congelando” a forma de onda para que seja visualmente examinada.

Os osciloscópios também têm um modo X-Y que permite que a deflexão horizontal e vertical do bombardeador de elétrons seja controlada por duas entradas independentes, geralmente, formas de ondas como senoidais. Com duas senoidais como entrada, a qualquer momento o ponto (x, y) é iluminado, onde x e y são determinados pelas equações paramétricas:

parametric equations

Os valores de A são amplitudes, os valores de ω são frequências e os valores de k são diferenças de fase.

O padrão resultante da interação dessas duas senoidais é uma curva de Lissajous, nomeada depois de o matemático francês Jules Antoine Lissajous (1822 - 1880) ter visualmente criado pela primeira vez essas curvas, emitindo luz entre um par de espelhos anexados a diapasões.

Você pode experimentar um programa do Silverlight que gera curvas de Lissajous em meu site (charlespetzold.com/silverlight/LissajousCurves/LissajousCurves.html). A Figura 1 mostra uma exibição típica.

image: The Web Version of the LissajousCurves Program

Figura 1 A versão para a Web do programa LissajousCurves

Embora não seja muito óbvio em uma captura de tela estática, um ponto verde está se movendo pela tela cinza-escuro e está deixando uma trilha que esmaece em quatro segundos. A posição horizontal desse ponto é orientada por uma senoidal e a posição vertical por outra senoidal. Padrões repetitivos são obtidos quando as duas frequências são razões entre integrais simples.

Agora, é uma verdade reconhecida universalmente que um programa do Silverlight de qualquer sorte deve ser transportado para o Windows Phone 7 e, subsequentemente, revelar todos os problemas de desempenho anteriormente mascarados por computadores desktop de alta potência. Com certeza, esse era o caso desse programa, e eu vou discutir esses problemas de desempenho posteriormente neste artigo. A Figura 2 mostra o programa em execução no emulador do Windows Phone 7.

image: The LissajousCurves Program for Windows Phone 7

Figura 2 O programa LissajousCurves para Windows Phone 7

O código que pode ser baixado consiste em uma única solução do Visual Studio denominada LissajousCurves. O aplicativo Web é composto pelos projetos LissajousCurves e LissajousCurves.Web. O aplicativo Windows Phone 7 possui o nome de projeto LissajousCurves.Phone. A solução também contém dois projetos de biblioteca: Petzold.Oscilloscope.Silverlight e Petzold.Oscilloscope.Phone, mas esses dois projetos compartilham todos os mesmos arquivos de código.

Push ou pull?

Além dos controles TextBlock e Slider, o único outro elemento visual desse programa é uma classe chamada Oscilloscope, derivada de UserControl. Fornecendo dados para Oscilloscope estão duas instâncias de uma classe chamada SineCurve.

SineCurve não tem visuais próprios, mas eu derivei a classe de FrameworkElement, de forma que seja possível colocar as duas instâncias na árvore visual e definir as associações nelas. Na verdade, tudo no programa está conectado por associações — dos controles Slider aos elementos de SineCurve e de SineCurve a Oscilloscope. O arquivo MainPage.xaml.cs da versão para a Web do programa não tem nenhum código além daquele fornecido por padrão, e o arquivo equivalente no aplicativo de telefone apenas implementa a lógica de marcação.

SineCurve define duas propriedades (apoiado por propriedades de dependência) chamadas Frequency e Amplitude. Uma instância de SineCurve fornece os valores horizontais de Oscilloscope e a outra instância fornece os valores verticais.

A classe SineCurve também implementa uma interface que eu chamei de IProvideAxisValue:

public interface IProvideAxisValue {
  double GetAxisValue(DateTime dateTime);
}

A classe SineCurve implementa essa interface com um método particularmente simples que faz referência a dois campos, bem como às duas propriedades:

public double GetAxisValue(DateTime dateTime) {
  phaseAngle += 2 * Math.PI * this.Frequency * 
    (dateTime - lastDateTime).TotalSeconds;
  phaseAngle %= 2 * Math.PI;
  lastDateTime = dateTime;

  return this.Amplitude * Math.Sin(phaseAngle);
}

A classe Oscilloscope define duas propriedades (também apoiada por propriedades de dependência) chamadas XProvider e YProvider do tipo IProvideAxisValue. Para que tudo se mova, a classe Oscilloscope instala um manipulador para o evento CompositionTarget.Rendering. Esse evento é disparado em sincronia com a taxa de atualização da exibição de vídeo e, dessa maneira, funciona como uma ferramenta conveniente para a execução de animações. Em cada chamada para o manipulador CompositionTarget.Rendering, Oscilloscope chama GetAxisValue nos dois objetos SineCurve definidos para suas propriedades XProvider e YProvider.

Em outras palavras, o programa implementa um modelo de pull. O objeto Oscilloscope determina quando necessita de dados e, em seguida, efetua pull dos dados de dois fornecedores de dados. (Como ele exibe esses dados é algo que discutirei em breve.)

Conforme comecei a adicionar mais recursos ao programa, em particular, duas instâncias de um controle adicional que exibia as senoidais, mas que eu eventualmente removi por distração, passei a duvidar da sabedoria desse modelo. Eu tinha três objetos efetuando pull dos mesmos dados a partir de dois fornecedores e pensei que um modelo de push talvez fosse melhor.

Reestruturei o programa de forma que a classe SineCurve instalasse um manipulador para CompositionTarget.Rendering e enviasse os dados por push para o controle de Oscilloscope por meio de propriedades agora nomeadas simplesmente como X e Y do tipo double.

Eu deveria ter antecipado a falha fundamental desse modelo de push específico: o Oscilloscope agora recebe duas alterações separadas em X e Y e constrói, em vez de uma curva suave, vários degraus, conforme exibido na Figura 3.

image: The Disastrous Result of a Push-Model Experiment

Figura 3 O resultado desastroso de um experimento com um modelo de push

Tomar a decisão de voltar para o modelo de pull foi fácil!

Renderizando com WriteableBitmap

Desde o momento em que concebi esse programa, não havia nenhuma dúvida de que usar WriteableBitmap era a melhor solução para implementar a tela real de Oscilloscope.

WriteableBitmap é um bitmap do Silverlight que dá suporte ao endereçamento de pixel. Todos os pixels do bitmap são expostos como uma matriz de inteiros de 32 bits. Os programas podem obter e definir esses pixels de maneira arbitrária. WriteableBitmap também tem um método Render que permite a renderização de visuais de qualquer objeto do tipo FrameworkElement no bitmap.

Se fosse necessário que Oscilloscope apenas exibisse uma curva estática simples, eu usaria Polyline ou Path e nem consideraria WriteableBitmap. Mesmo que fosse necessário alterar o formato dessa curva, Polyline ou Path ainda seriam preferíveis. Mas a curva exibida por Oscilloscope precisa crescer em tamanho, além de precisar ser colorida de maneira irregular. A linha deve desaparecer progressivamente: as partes recém-exibidas são mais brilhantes do que as partes mais antigas da linha. Se eu tivesse usado uma curva única, seriam necessárias várias cores ao longo de seu comprimento. Esse não é um conceito com suporte no Silverlight!

Sem WriteableBitmap, o programa precisaria criar várias centenas de elementos Polyline diferentes, todos coloridos de maneira diferente e distribuídos ao redor, e o layout de disparo seria aprovado após cada evento CompositionTarget.Rendering. Tudo o que sabia sobre programação do Silverlight indicava que WriteableBitmap definitivamente ofereceria um desempenho muito melhor.

Uma versão antiga da classe Oscilloscope processou o evento CompositionTarget.Rendering obtendo novos valores dos dois fornecedores SineCurve, dimensionando-os até o tamanho de WriteableBitmap e, em seguida, construindo um objeto Line a partir do ponto anterior para o ponto atual. Isso foi simplesmente transmitido para o método Render de WriteableBitmap:

writeableBitmap.Render(line, null);

A classe Oscilloscope define uma propriedade Persistence que indica o número de segundos para qualquer cor ou componente alfa de um pixel diminuir de 255 para 0. Fazer com que esses pixels desaparecessem envolveu o endereçamento direto de pixel. O código é mostrado na Figura 4.

Figura 4 Código para esmaecer valores de pixel

accumulatedDecrease += 256 * 
  (dateTime - lastDateTime).TotalSeconds / Persistence;
int decrease = (int)accumulatedDecrease;

// If integral decrease, sweep through the pixels
if (decrease > 0) {
  accumulatedDecrease -= decrease;

  for (int index = 0; index < 
    writeableBitmap.Pixels.Length; index++) {

    int pixel = writeableBitmap.Pixels[index];

    if (pixel != 0) {
      int a = pixel >> 24 & 0xFF;
      int r = pixel >> 16 & 0xFF;
      int g = pixel >> 8 & 0xFF;
      int b = pixel & 0xFF;

      a = Math.Max(0, a - decrease);
      r = Math.Max(0, r - decrease);
      g = Math.Max(0, g - decrease);
      b = Math.Max(0, b - decrease);

      writeableBitmap.Pixels[index] = a << 24 | r << 16 | g << 8 | b;
    }
  }
}

Nesse ponto do desenvolvimento do programa, eu executei as etapas necessárias para também executá-lo no telefone. Na Web e no telefone, o programa pareceu executar corretamente, mas eu sabia que não estava totalmente concluído. Eu não estava vendo curvas na tela de Oscilloscope. Eu estava visualizando uma porção de linhas retas conectadas. E nada destrói a ilusão de um analógico simulado digitalmente mais rápido do que uma porção de linhas extremamente retas!

Interpolação

O manipulador CompositionTarget.Rendering é chamado na sincronização com a atualização da exibição do vídeo. Para a maioria das exibições de vídeo, incluindo a exibição no Windows Phone 7, isso geralmente está na área de 60 quadros por segundo. Em outras palavras, o manipulador de eventos CompositionTarget.Rendering é chamado aproximadamente a cada 16 ou 17 milissegundos. Na verdade, como você poderá ver, essa é apenas a situação ideal. Mesmo que as ondas senoidais sejam um lento ciclo por segundo, para um osciloscópio de 480 pixels de largura, dois exemplos adjacentes podem ter coordenadas de pixels com cerca de 35 pixels de distância.

O osciloscópio precisava fazer a interpolação entre exemplos consecutivos com uma curva. Mas que tipo de curva?

Minha primeira opção era um spline canônico, também conhecido como um spline cardinal. Para uma sequência de pontos de controle p1, p2, p3 e p4, o spline canônico fornece uma interpolação cúbica entre p2 e p3 com um grau de curvatura com base em um fator de “tensão”. É uma solução de finalidade geral.

O spline canônico tinha suporte no Windows Forms, mas nunca foi desenvolvido para o WPF (Windows Presentation Foundation) ou o Silverlight. Felizmente, eu tinha um código do WPF e do Silverlight para o spline canônico que eu desenvolvi para uma entrada de blog em 2009 chamada, de maneira muito apropriada, “Splines canônicos no WPF e no Silverlight” (bit.ly/bDaWgt).

Após a geração de um Polyline com interpolação, o processamento de CompositionTarget.Rendering agora é concluído com uma chamada como esta:

writeableBitmap.Render(polyline, null);

O spline canônico funcionou, mas não estava muito bom. Quando as frequências das duas senoidais são múltiplos de inteiros simples, a curva deve se estabilizar em um padrão fixo. Mas isso não estava acontecendo e eu percebi que a curva interpolada estava um pouco diferente, dependendo dos pontos de exemplo reais. 

Esse problema era intensificado no telefone, principalmente devido ao problema que o pequeno processador do telefone tinha em acompanhar todas as demandas que eu estava inserindo. Em frequências mais altas, as curvas de Lissajous no telefone pareciam suaves e curvilíneas, mas aparentemente movendo-se em padrões quase aleatórios!

Apenas lentamente eu pude perceber que poderia fazer a interpolação com base em tempo. Duas chamadas consecutivas para o manipulador de eventos CompositionTarget.Rendering estão separadas por cerca de 17 ms. Eu poderia simplesmente executar um loop entre todos esses valores de milissegundos intermediários e chamar o método GetAxisValue nos dois fornecedores de SineCurve a fim de construir uma polilinha mais suave.

Essa abordagem funcionou muito melhor.

Aprimorando o desempenho

Um artigo de leitura essencial para todos os programadores do Windows Phone 7 é a página de documentação com considerações de desempenho em aplicativos para o Windows Phone em bit.ly/fdvh7Z. Além de muitas dicas úteis sobre como aprimorar o desempenho em seus aplicativos de telefone, ele também apresenta o significado dos números que são exibidos na lateral da tela quando você executa o programa no Visual Studio, conforme mostrado na Figura 5.

image: Performance Indicators in Windows Phone 7

Figura 5 Indicadores de desempenho no Windows Phone 7

Essa linha de números é habilitada ao definir a propriedade Application.Current.Host.Settings.EnableFrameRateCounter como true, o é feito pelo arquivo App.xaml.cs padrão se o programa estiver em execução no depurador do Visual Studio.

Os primeiros dois números são os mais significativos. Às vezes, se nada estiver acontecendo, esses dois números serão exibidos como zero, mas ambos destinam-se a exibir taxas de quadros — o que significa que eles exibem um número de quadros por segundo. Eu mencionei que a maioria das exibições de vídeo é atualizada em uma taxa de 60 vezes por segundo. Entretanto, um programa aplicativo pode tentar executar animações nas quais cada novo quadro exija mais de 16 ms ou 17 ms de tempo de processamento.

Por exemplo, suponha que um manipulador CompositionTarget.Rendering exija 50 ms para fazer qualquer trabalho que esteja realizando. Nesse caso, o programa atualizará a exibição de vídeo em uma taxa de 20 vezes por segundo. Essa é a taxa de quadros do programa.

Agora, 20 quadros por segundo não é uma taxa de quadros terrível. Lembre-se de que os filmes são executados a uma taxa de 24 quadros por segundo e que a televisão padrão apresenta uma taxa efetiva (levando o entrelaçamento em consideração) de 30 quadros por segundo nos EUA e de 25 quadros por segundo na Europa. Mas, uma vez que a taxa de quadros diminua para 15 ou 10, isso passará a ser perceptível.

O Silverlight para Windows Phone é capaz de descarregar algumas animações para a GPU (Graphics Processing Unit - Unidade de processamento gráfico), de forma que tenha um thread secundário (às vezes, referenciado como o thread de composição ou de GPU) que interage com a GPU. O primeiro número é a taxa de quadros associada a esse thread. O segundo número é a taxa de quadros da interface do usuário, que se refere ao thread principal do aplicativo. Esse é o thread em que qualquer manipulador CompositionTarget.Rendering é executado.

Ao executar o programa LissajousCurves em meu telefone, vejo os números 22 e 11, respectivamente, para os threads da GPU e da interface do usuário, e eles diminuem um pouco quando eu aumento a frequência das senoidais. Será que eu poderia fazer melhor?

Eu comecei a imaginar quanto tempo era necessário para essa instrução crucial em meu método CompositionTarget.Rendering:

writeableBitmap.Render(polyline, null);

Essa instrução deveria ser chamada 60 vezes por segundo, com uma polilinha consistindo em 16 ou 17 linhas, mas, na verdade, ela estava sendo chamada 11 vezes por segundo, com polilinhas de 90 segmentos.

No meu livro “Programming Windows Phone 7” (Microsoft Press, 2010), escrevi algumas lógicas de renderização de linha para XNA e consegui adaptá-las para o Silverlight para essa classe Oscilloscope. Agora, eu não estava chamando o método Render de WriteableBitmap de nenhuma maneira, mas, em vez disso, estava alterando diretamente os pixels no bitmap para desenhar as polilinhas.

Infelizmente, as duas taxas de quadros retornaram zero! Isso me sugeriu que o Silverlight sabe como renderizar linhas em um bitmap muito mais rapidamente do que eu. Também devo observar que meu código não foi otimizado para polilinhas.

Nesse momento, comecei a imaginar se uma abordagem diferente de WriteableBitmap poderia ser razoável. Eu substituí um Canvas pelos elementos WriteableBitmap e Image e, ao passo que cada Polyline era construído, eu simplesmente adicionava a elas o Canvas.

É claro que isso não pode ser feito indefinidamente. Você não quer um Canvas com centenas de milhares de filhos. E, além disso, esses filhos de Polyline devem esmaecer. Eu tentei duas abordagens: a primeira envolvia anexar um ColorAnimation a cada Polyline para diminuir o canal alfa da cor e, em seguida, remover a Polyline do Canvas quando a animação for concluída. A segunda era uma abordagem mais manual de enumeração pelos filhos de Polyline, diminuindo o canal alfa da cor manualmente e removendo o filho quando o canal alfa chegar a zero.

Esses quatro métodos ainda existem na classe Oscilloscope e elas são habilitadas com quatro instruções #define na parte superior do arquivo C#. A Figura 6 mostra as taxas de quadros com cada abordagem.

Figura 6 Taxas de quadros dos quatro métodos de atualização de Oscilloscope

  Thread de composição Thread da interface do usuário
WriteableBitmap com renderização de Polyline 22 11
WriteableBitmap com preenchimentos de estrutura manuais 0 0
Canvas com Polyline com esmaecimento de animação 20 20
Canvas com Polyline com esmaecimento manual 31 15

A Figura 6 mostra que meu instinto original sobre WriteableBitmap estava errado. Nesse caso, é realmente melhor colocar uma porção de elementos Polyline em um Canvas. As duas técnicas de esmaecimento são interessantes. Quando executado por uma animação, o esmaecimento ocorre no thread de composição a 20 quadros por segundo. Quando executado manualmente, ele ocorre no thread da interface do usuário a 15 quadros por segundo. Entretanto, a adição de novos elementos Polyline sempre ocorre no thread da interface do usuário e a taxa de quadros é de 20 quando a lógica de esmaecimento é descarregada para a GPU.

Concluindo, o terceiro método apresenta o melhor desempenho geral.

Sendo assim, o que aprendemos hoje? Claramente, para obter o melhor desempenho, é necessário experimentar. Tentar diferentes abordagens e nunca confiar em seus instintos iniciais.

Charles Petzold é editor colaborador da MSDN Magazine há muito tempo. Seu novo livro, “Programming Windows Phone 7” (Microsoft Press, 2010), está disponível gratuitamente para download em bit.ly/cpebookpdf.

Agradecemos ao seguinte especialista técnico pela revisão deste artigo: Jesse Liberty