Ultimamente, tenho lidado com alguns problemas de tremor na taxa de quadros no meu jogo, e parece que a melhor solução seria a sugerida por Glenn Fiedler (Gaffer nos jogos) no clássico Fix Your Timestep! artigo.
Agora - já estou usando um intervalo de tempo fixo para minha atualização. O problema é que não estou fazendo a interpolação sugerida para renderização. O resultado é que eu recebo quadros duplicados ou ignorados se minha taxa de renderização não corresponder à minha taxa de atualização. Estes podem ser visualmente visíveis.
Então, gostaria de adicionar interpolação ao meu jogo - e estou interessado em saber como outras pessoas estruturaram seus dados e códigos para dar suporte a isso.
Obviamente, precisarei armazenar (onde? / Como?) Duas cópias das informações do estado do jogo relevantes ao meu renderizador, para que ele possa interpolar entre eles.
Além disso - este parece ser um bom lugar para adicionar threads. Eu imagino que um encadeamento de atualização possa funcionar em uma terceira cópia do estado do jogo, deixando as outras duas cópias como somente leitura para o encadeamento de renderização. (Isso é uma boa ideia?)
Parece que ter duas ou três versões do estado do jogo pode apresentar desempenho e - muito mais importante - problemas de confiabilidade e produtividade do desenvolvedor, em comparação com apenas uma versão. Portanto, estou particularmente interessado em métodos para mitigar esses problemas.
De nota particular, eu acho, é o problema de como lidar com adicionar e remover objetos do estado do jogo.
Finalmente, parece que algum estado não é diretamente necessário para a renderização ou seria muito difícil rastrear versões diferentes de (por exemplo: um mecanismo de física de terceiros que armazena um único estado) - então eu estaria interessado em saber como as pessoas lidaram com esse tipo de dados em um sistema como esse.
fonte
Minha solução é muito menos elegante / complicada do que a maioria. Estou usando o Box2D como meu mecanismo de física, portanto, manter mais de uma cópia do estado do sistema não é gerenciável (clone o sistema de física e tente mantê-los sincronizados, pode haver uma maneira melhor, mas não consegui 1).
Em vez disso, mantenho um contador contínuo da geração da física . Cada atualização incrementa a geração física, quando o sistema de física é atualizado duas vezes, o contador de geração também é atualizado duas vezes.
O sistema de renderização controla a última geração renderizada e o delta desde essa geração. Ao renderizar objetos que desejam interpolar sua posição, use esses valores junto com sua posição e velocidade para adivinhar onde o objeto deve ser renderizado.
Eu não lidei com o que fazer se o mecanismo de física fosse muito rápido. Eu quase argumentaria que você não deveria interpolar para movimentos rápidos. Se você fez os dois, precisará tomar cuidado para não fazer com que os sprites pulem, adivinhando muito devagar e adivinhando muito rápido.
Quando escrevi o material de interpolação, eu estava executando os gráficos em 60Hz e a física em 30Hz. Acontece que o Box2D é muito mais estável quando é executado em 120Hz. Por causa disso, meu código de interpolação é muito pouco usado. Ao dobrar o alvo, a taxa de quadros é atualizada em média duas vezes por quadro. Com tremulação que pode ser 1 ou 3 vezes, mas quase nunca 0 ou 4+. A taxa mais alta de física resolve o problema da interpolação por si só. Ao executar a física e a taxa de quadros em 60hz, você pode obter de 0 a 2 atualizações por quadro. A diferença visual entre 0 e 2 é enorme em comparação com 1 e 3.
fonte
Eu ouvi essa abordagem de timesteps sugerida com bastante frequência, mas em 10 anos em jogos, nunca trabalhei em um projeto do mundo real que dependesse de um timestep e interpolação fixos.
Em geral, parece mais esforço do que um sistema de tempo variável (assumindo uma faixa sensata de taxas de quadros, no tipo de faixa 25Hz-100Hz).
Tentei uma vez a abordagem fixa de timestep + interpolação uma vez para um protótipo muito pequeno - sem threading, mas atualização lógica de timestep fixa e renderização o mais rápido possível quando não a atualizava. Minha abordagem era ter algumas classes, como CInterpolatedVector e CInterpolatedMatrix - que armazenavam valores anteriores / atuais e tinham um acessador usado no código de renderização, para recuperar o valor do tempo de renderização atual (que sempre estaria entre o anterior e o atual). horário atual)
Cada objeto do jogo, no final de sua atualização, define seu estado atual para um conjunto desses vetores / matrizes interpoláveis. Esse tipo de coisa pode ser estendido para oferecer suporte à segmentação, você precisará de pelo menos três conjuntos de valores - um que está sendo atualizado e pelo menos dois valores anteriores para interpolar entre ...
Observe que alguns valores não podem ser interpolados trivialmente (por exemplo, 'quadro de animação de sprite', 'efeito especial ativo'). Você pode pular completamente a interpolação ou causar problemas, dependendo das necessidades do seu jogo.
IMHO, é melhor seguir um passo variável - a menos que você esteja criando um RTS ou outro jogo em que tenha um grande número de objetos e mantenha duas simulações independentes em sincronia para jogos em rede (enviando apenas ordens / comandos pelo rede, em vez de posições de objeto). Nessa situação, o tempo fixo é a única opção.
fonte
Sim, felizmente a chave aqui é "relevante para o meu renderizador". Isso pode não ser mais do que adicionar uma posição antiga e um carimbo de data / hora para ela na mistura. Dadas duas posições, você pode interpolar para uma posição entre elas e, se você tiver um sistema de animação em 3D, poderá solicitar a pose naquele momento preciso de qualquer maneira.
É realmente muito simples - imagine que seu renderizador deve ser capaz de renderizar seu objeto de jogo. Ele costumava perguntar ao objeto como ele era, mas agora precisa perguntar como ele era em um determinado momento. Você só precisa armazenar as informações necessárias para responder a essa pergunta.
Parece apenas uma receita para aumentar a dor neste momento. Ainda não pensei em todas as implicações, mas acho que você poderá obter um pouco de taxa de transferência extra ao custo de uma latência mais alta. Ah, e você pode obter alguns benefícios por poder usar outro núcleo, mas não sei.
fonte
Nota: Na verdade, não estou analisando interpolação, portanto esta resposta não a trata; Estou preocupado apenas em ter uma cópia do estado do jogo para o thread de renderização e outra para o thread de atualização. Portanto, não posso comentar sobre o problema da interpolação, embora você possa modificar a seguinte solução para interpolar.
Eu estive pensando sobre isso como eu estava projetando e pensando em um mecanismo multithread. Então, fiz uma pergunta no Stack Overflow, sobre como implementar algum tipo de padrão de design de "registro no diário" ou "transações" . Recebi boas respostas, e a resposta aceita realmente me fez pensar.
É difícil criar um objeto imutável, pois todos os filhos também devem ser imutáveis, e você precisa ter muito cuidado para que tudo seja realmente imutável. Mas se você for realmente cuidadoso, poderá criar uma superclasse
GameState
que contenha todos os dados (e subdata e assim por diante) do seu jogo; a parte "Modelo" do estilo organizacional Model-View-Controller.Então, como Jeffrey diz , as instâncias do seu objeto GameState são rápidas, eficientes na memória e seguras para threads. A grande desvantagem é que, para alterar qualquer coisa sobre o modelo, você precisa recriar o modelo, por isso precisa ter muito cuidado para que seu código não se transforme em uma grande bagunça. Definir uma variável dentro do objeto GameState para um novo valor é mais envolvido do que apenas
var = val;
, em termos de linhas de código.Estou terrivelmente intrigado com isso. Você não precisa copiar toda a estrutura de dados em todos os quadros; você acabou de copiar um ponteiro para a estrutura imutável. Isso por si só é muito impressionante, você não concorda?
fonte
Comecei com três cópias do estado do jogo de cada nó no meu gráfico de cena. Um está sendo gravado pelo encadeamento do gráfico de cena, um está sendo lido pelo renderizador e um terceiro está disponível para leitura / gravação assim que um deles precisar ser trocado. Isso funcionou bem, mas foi complicado demais.
Percebi então que só precisava manter três estados do que seria renderizado. Meu thread de atualização agora preenche um dos três buffers muito menores de "RenderCommands", e o Renderer lê no buffer mais novo que não está sendo gravado no momento, o que impede que os threads esperem um no outro.
Na minha configuração, cada RenderCommand tem a geometria / materiais 3d, uma matriz de transformação e uma lista de luzes que o afetam (ainda fazendo renderização adiante).
Meu segmento de renderização não precisa mais fazer cálculos de seleção ou distância da luz, e isso acelerou consideravelmente as cenas grandes.
fonte