Guia de compressão de animação de esqueleto


Este artigo será uma breve visão geral de como implementar um esquema simples de compactação de animação e alguns conceitos relacionados. Não sou de forma alguma um especialista neste assunto, mas há muito pouca informação sobre esse assunto, e ela é bastante fragmentada. Se você quiser ler artigos mais detalhados sobre esse tópico, recomendo que você acesse os seguintes links:


Antes de começar, vale a pena dar uma breve introdução à animação esquelética e a alguns de seus conceitos básicos.

Noções básicas de animação e compactação


Animação esquelética é um tópico bastante simples, se você se esquecer de esfolar. Temos o conceito de um esqueleto contendo transformações dos ossos de um personagem. Essas transformações ósseas são armazenadas em um formato hierárquico; de fato, eles são armazenados como um delta entre sua posição global e a posição dos pais. A terminologia aqui é confusa, porque no mecanismo de jogo local é freqüentemente chamado de espaço de modelo / personagem e global é o espaço do mundo. Na terminologia da animação, local é chamado de espaço do pai do osso e global é o espaço do personagem ou o espaço do mundo, dependendo se há movimento do osso da raiz; mas não vamos nos preocupar com isso. O importante é que as transformações ósseas sejam armazenadas localmente em relação aos pais. Isso tem muitas vantagens, especialmente quando se mistura (mistura):se a mistura das duas posições fosse global, elas seriam interpoladas linearmente na posição, o que levaria a um aumento e diminuição dos ossos e à deformação do personagem.E se você usar deltas, a mistura será realizada de uma diferença para outra; portanto, se a transformação delta de um osso entre duas poses for a mesma, o comprimento do osso permanecerá constante. Eu acho que é mais fácil (mas não totalmente preciso) agir dessa maneira: o uso de deltas leva a um movimento "esférico" das posições ósseas durante a mistura, e a mistura de transformações globais leva a um movimento linear das posições ósseas.

A animação esquelética é apenas uma lista ordenada de quadros-chave com uma taxa de quadros (geralmente) constante. O quadro principal é a pose do esqueleto. Se queremos fazer uma pose entre os quadros-chave, amostramos os dois quadros-chave e misturamos entre eles, usando a fração do tempo entre eles como o peso do mix. A imagem abaixo mostra uma animação criada a 30fps. A animação tem um total de 5 quadros e precisamos fazer a pose 0,52 s após o início. Portanto, precisamos amostrar a pose no quadro 1 e a pose no quadro 2 e, em seguida, misturar entre elas com um peso de mistura de aproximadamente 57%.


Um exemplo de uma animação de 5 quadros e uma solicitação de pose em um tempo intermediário.

Tendo as informações acima e acreditando que a memória não é um problema para nós, o salvamento seqüencial da pose seria a maneira ideal de armazenar a animação, como mostrado abaixo:


Armazenamento simples de dados de animação

Por que isso é perfeito? A amostragem de qualquer quadro-chave se resume a uma operação simples de memcpy. A amostragem de uma pose intermediária requer duas operações com erro e uma operação de mistura. Do ponto de vista do cache, copiamos usando dois blocos de dados memcpy em ordem, ou seja, após copiar o primeiro quadro, um dos caches já terá um segundo quadro. Você pode dizer: espere, quando fazemos a mistura, precisamos misturar todos os ossos; E se a maioria deles não mudar entre os quadros? Não seria melhor armazenar ossos como registros e misturar apenas transformações alteradas? Bem, se isso for implementado, poderá ocorrer um pouco mais de falha de cache ao ler registros individuais, e você precisará acompanhar quais conversões precisa misturar, e assim por diante ... A mixagem pode parecer um monte de trabalho,mas, em essência, é a aplicação de uma instrução a dois blocos de memória que já estão no cache. Além disso, o código de mixagem é relativamente simples, geralmente apenas um conjunto de instruções SIMD sem ramificação, e um processador moderno as processa em questão de momentos.

O problema dessa abordagem é que é necessária uma quantidade extremamente grande de memória, especialmente em jogos em que as seguintes condições são verdadeiras para 95% dos dados.

  • Os ossos têm um comprimento constante
    • Os personagens na maioria dos jogos não esticam os ossos, portanto, dentro da mesma animação, os registros de transformações são constantes.
  • Geralmente não escalamos os ossos.
    • Escala raramente é usada em animações de jogos. É usado ativamente em filmes e efeitos visuais, mas muito pouco em jogos. Mesmo quando usado, a mesma escala é geralmente usada.
    • De fato, na maioria das animações que criei em tempo de execução, aproveitei esse fato e mantive toda a transformação óssea em 8 variáveis ​​de flutuação: 4 para girar o quaternion, 3 para mover e 1 para escalar. Isso reduz significativamente o tamanho da pose no tempo de execução, proporcionando maior produtividade ao misturar e copiar.

Com tudo isso em mente, se você observar o formato de dados original, poderá ver como é ineficiente gastar memória. Duplicamos os valores de deslocamento e escala de cada osso, mesmo que eles não mudem. E a situação está ficando rapidamente fora de controle. Geralmente, os animadores criam animações com uma frequência de 30 fps e, nos jogos no nível AAA, um personagem geralmente tem cerca de 100 ossos. Com base nessa quantidade de informações e no formato de 8 flutuantes, precisamos de cerca de 3 KB por pose e 94 KB por segundo de animação como resultado. Os valores se acumulam rapidamente e, em algumas plataformas, podem obstruir facilmente toda a memória.

Então, vamos falar sobre compressão; Ao tentar compactar dados, há vários aspectos a serem considerados:

  • Taxa de compressão
    • Quanto conseguimos reduzir a quantidade de memória ocupada
  • Qualidade
    • Quanta informação perdemos dos dados de origem
  • Taxa de compressão
    • .

Estou preocupado principalmente com qualidade e velocidade e menos com memória. Além disso, trabalho com animações de jogos e posso aproveitar o fato de que, de fato, para reduzir a carga na memória, não precisamos usar deslocamento e escala nos dados. Devido a isso, podemos evitar uma diminuição na qualidade causada por uma diminuição no número de quadros e outras soluções com perdas.

Também é extremamente importante observar que você não deve subestimar o efeito da compactação de animação no desempenho: em um dos meus projetos anteriores, a taxa de amostragem diminuiu cerca de 35% e também houve alguns problemas de qualidade.

Quando começamos a trabalhar com a compactação de dados de animação, há duas áreas importantes a serem consideradas:

  • Com que rapidez podemos compactar elementos individuais de informação em um quadro-chave (quaternions, float etc.).
  • Como podemos compactar a sequência de quadros-chave para remover informações redundantes.

Discretização de dados


Quase toda esta seção pode ser reduzida a um princípio: discretizar dados.

A discretização é uma maneira difícil de dizer que queremos converter um valor de um intervalo contínuo em um conjunto discreto de valores.

Flutuação de Discretização


Quando se trata de amostrar valores flutuantes, nos esforçamos para pegar esse valor flutuante e representá-lo como um número inteiro usando menos bits. O truque é que um número inteiro não pode realmente representar um número de origem, mas um valor em um intervalo discreto mapeado para um intervalo contínuo. Geralmente, é usada uma abordagem muito simples. Para provar um valor, primeiro precisamos de um intervalo para o valor original; Após receber esse intervalo, normalizamos o valor inicial para esse intervalo. Então esse valor normalizado é multiplicado pelo valor máximo possível para o tamanho de saída especificado desejado em bits. Ou seja, para 16 bits multiplicamos o valor por 65535. Em seguida, o valor resultante é arredondado para o número inteiro mais próximo e armazenado. Isso é mostrado claramente na imagem:


Um exemplo de amostragem de um flutuador de 32 bits para um número inteiro de 16 bits não assinado.

Para obter o valor original novamente, simplesmente executamos as operações na ordem inversa. É importante observar aqui que precisamos registrar em algum lugar o intervalo inicial do valor; caso contrário, não poderemos decodificar o valor amostrado. O número de bits no valor amostrado determina o tamanho da etapa no intervalo normalizado e, portanto, o tamanho da etapa no intervalo original: o valor decodificado será um múltiplo desse tamanho da etapa, o que nos permite calcular facilmente o erro máximo que ocorre devido ao processo de amostragem, para que possamos determinar o número de bits. necessário para a nossa aplicação.

Não darei exemplos do código fonte, porque existe uma biblioteca bastante conveniente e simples para executar operações básicas de amostragem, o que é uma boa fonte sobre este tópico: https://github.com/r-lyeh-archived/quant (eu diria que você não deve usar sua função de discretização de quaternion, mas mais sobre isso posteriormente).

Compressão Quaternion


A compressão do quaternion é um tópico bem estudado, por isso não repetirei o que as outras pessoas explicaram melhor. Aqui está um link para uma postagem de compactação de captura instantânea que fornece a melhor descrição sobre este tópico: https://gafferongames.com/post/snapshot_compression/ .

No entanto, tenho algo a dizer sobre o assunto. As mensagens de bitsquid, que falam sobre a compressão do quaternion, sugerem compactar o quaternion para 32 bits usando aproximadamente 10 bits de dados para cada componente do quaternion. É exatamente isso que a Quant faz, porque é baseada em mensagens de bitsquid. Na minha opinião, essa compressão é muito grande e, nos meus testes, causou forte agitação. Talvez os autores tenham usado hierarquias menos profundas do personagem, mas se você multiplicar mais de 15 quaternions dos meus exemplos de animação, o erro combinado será bastante sério. Na minha opinião, o mínimo absoluto de precisão é de 48 bits por quaternion.

Redução de tamanho devido à amostragem


Antes de começarmos a considerar os diferentes métodos de compactação e a organização dos registros, vamos ver que tipo de compactação obteremos se simplesmente aplicarmos a discretização no circuito original. Usaremos o mesmo exemplo de antes (um esqueleto de 100 ossos); portanto, se usarmos 48 bits (3 x 16 bits) por quaternion, 48 bits (3 × 16) para mover e 16 bits para escalar, então no total para conversão precisamos de 14 bytes em vez de 32 bytes. Isso representa 43,75% do tamanho original. Ou seja, por 1 segundo de animação com uma frequência de 30FPS, reduzimos o volume de cerca de 94 KB para cerca de 41 KB.

Isso não é nada ruim, a discretização é uma operação de custo relativamente baixo, portanto isso não afetará muito o tempo de descompactação. Encontramos um bom ponto de partida para o início e, em alguns casos, isso será suficiente para implementar animações dentro do orçamento de recursos e garantir excelente qualidade e desempenho.

Compressão de registro


Tudo se torna muito complicado aqui, especialmente quando os desenvolvedores começam a tentar técnicas como reduzir o quadro-chave, o ajuste de curvas, etc. Também nesta fase, estamos realmente começando a reduzir a qualidade das animações.

Em quase todas essas decisões, supõe-se que as características de cada osso (rotação, deslocamento e escala) sejam armazenadas como um registro separado. Portanto, podemos inverter o circuito, como mostrei anteriormente:


Salvando dados de ossos como registros

Aqui, simplesmente salvamos todos os registros seqüencialmente, mas também podemos agrupar todos os registros de rotações, deslocamentos e escalas. A idéia básica é que passamos do armazenamento de dados de cada pose para o armazenamento de registros.

Feito isso, podemos usar outras maneiras de reduzir ainda mais a memória ocupada. O primeiro é começar a soltar quadros. Nota: isso não requer um formato de registro e esse método pode ser aplicado no esquema anterior. Esse método funciona, mas leva à perda de pequenos movimentos na animação, porque descartamos a maioria dos dados. Essa técnica foi usada ativamente no PS3, e às vezes tivemos que descer para frequências de amostragem incrivelmente baixas, por exemplo, até 7 quadros por segundo (geralmente para animações não muito importantes). Tenho lembranças ruins disso, como programador de animação vejo claramente os detalhes perdidos e a expressividade, mas se você olhar do ponto de vista do programador de sistemas, podemos dizer que a animação é "quase" a mesma, porque em geral o movimento é preservado, mas ao mesmo tempo economize muita memória.

Vamos omitir essa abordagem (na minha opinião, é muito destrutiva) e considerar outras opções possíveis. Outra abordagem popular é criar uma curva para cada registro e realizar a redução dos quadros-chave na curva, ou seja, removendo quadros-chave duplicados. Do ponto de vista das animações de jogos, com essa abordagem, as gravações de movimento e escala são perfeitamente compactadas, às vezes sendo reduzidas a um quadro-chave. Essa solução é não destrutiva, mas requer descompactação, porque toda vez que precisamos obter a transformação, precisamos calcular a curva, porque não podemos mais apenas acessar os dados na memória. A situação pode ser melhorada um pouco se você calcular animações em apenas uma direção.e armazene o estado do amostrador de cada animação para cada osso (ou seja, de onde obter o cálculo da curva), mas você deve pagar por isso com um aumento na memória e um aumento significativo na complexidade do código. Nos sistemas modernos de animação, geralmente não reproduzimos animações do começo ao fim. Freqüentemente, em determinados períodos de tempo, eles fazem transições para novas animações, graças a coisas como mistura sincronizada ou correspondência de fases. Frequentemente, mostramos poses individuais, mas não consecutivas, para implementar coisas como misturar mira / olhar para um objeto, e muitas vezes as animações são reproduzidas na ordem inversa. Portanto, eu não recomendo usar essa solução, simplesmente não vale o aborrecimento causado pela complexidade e possíveis erros.

Há também o conceito de não apenas excluir chaves idênticas nas curvas, mas também especificar um limite no qual chaves semelhantes são excluídas; isso leva ao fato de que a animação fica mais desbotada, semelhante ao método de descartar quadros, porque o resultado final é o mesmo em termos de dados. Os esquemas de compactação de animação são freqüentemente usados, nos quais os parâmetros de compactação são definidos para cada registro, e os animadores são constantemente atormentados com esses valores, tentando manter a qualidade e reduzir o tamanho ao mesmo tempo. Esse é um fluxo de trabalho doloroso e estressante, mas é necessário se você trabalhar com a memória limitada das gerações mais antigas de consoles. Felizmente, hoje temos um grande orçamento de memória e não precisamos de coisas tão terríveis.

Todos esses aspectos são divulgados nos posts da Riot / BitSquid e Nicholas (consulte os links no início do meu artigo). Não vou falar sobre eles em detalhes. Em vez disso, falarei sobre o que decidi sobre compactar os registros ...

eu ... decidi não compactar os registros.

Antes de começar a acenar, deixe-me explicar ...

Quando salvo os dados nos registros, armazeno os dados de rotação para todos os quadros. Quando se trata de movimento e escala, eu acompanho se o movimento e a escala são estáticos durante a compactação e, nesse caso, salvo apenas um valor por registro. Ou seja, se o registro se mover ao longo de X, mas não ao longo de Y e Z, salvarei todos os valores de mover o registro ao longo de X, mas apenas um valor de mover o registro ao longo de Y e Z.

Essa situação surge para a maioria dos ossos em cerca de 95% de nossas animações, portanto, no final, podemos reduzir significativamente a memória ocupada, absolutamente sem perder a qualidade. Isso requer trabalho do ponto de vista da criação de conteúdo (DCC): não queremos que os ossos tenham leves movimentos e zooms no fluxo de trabalho de criação de animação, mas esse benefício vale o custo extra.

No nosso exemplo de animação, existem apenas dois registros com movimentação e não há registros com escala. Em 1 segundo de animação, o volume de dados diminui de 41 KB para 18,6 KB (ou seja, até 20% do volume dos dados originais). A situação se torna ainda melhor com o aumento da duração da animação, gastamos recursos apenas em turnos de gravação e movimentos dinâmicos, e o custo das gravações estáticas permanece constante, o que economiza mais em animações longas. E não precisamos experimentar perda de qualidade causada por amostragem.

Com todas essas informações em mente, meu esquema final de dados fica assim:


Um exemplo de esquema de dados de animação compactada (3 quadros por registro)

Além disso, salvei o deslocamento no bloco de dados para iniciar os dados de cada osso. Isso é necessário porque, às vezes, precisamos coletar dados de apenas um osso sem ler a pose inteira. Isso nos fornece uma maneira rápida de acessar diretamente os dados do registro.

Além dos dados de animação armazenados em um bloco de memória, também tenho opções de compactação para cada registro:


Exemplo de parâmetros de compactação para registros do meu mecanismo Kruger

Esses parâmetros armazenam todos os dados necessários para decodificar os valores amostrados de cada registro. Eles também monitoram a estática dos registros para que eu saiba como lidar com dados compactados quando me deparei com um registro estático durante a amostragem.

Você também pode observar que a discretização de cada registro é individual: durante a compactação, acompanho os valores mínimo e máximo de cada característica (por exemplo, movendo-se ao longo do X) de cada registro para garantir que os dados sejam discretizados dentro do intervalo mínimo / máximo e mantenho a precisão máxima. Não creio que seja geralmente possível criar intervalos de amostragem globais sem destruir seus dados (quando os valores estão fora do intervalo) e sem cometer erros significativos.

Seja como for, aqui está um breve resumo das minhas tentativas estúpidas de implementar a compactação de animação: no final, quase uso a compactação.

All Articles