Estruturas de dados para interpolação e encadeamento?

20

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.

Andrew Russell
fonte

Respostas:

4

Não tente replicar todo o estado do jogo. Interpolar seria um pesadelo. Apenas isole as partes que são variáveis ​​e necessárias pela renderização (vamos chamar isso de "Estado Visual").

Para cada classe de objeto, crie uma classe de acompanhamento que será capaz de manter o objeto Estado Visual. Este objeto será produzido pela simulação e consumido pela renderização. A interpolação será facilmente conectada no meio. Se o estado for imutável e transmitido por valor, você não terá problemas de encadeamento.

A renderização geralmente não precisa saber nada sobre relações lógicas entre os objetos; portanto, a estrutura usada para a renderização será um vetor simples ou, no máximo, uma árvore simples.

Exemplo

Design tradicional

class Actor
{
  Matrix4x3 position;
  float fuel;
  float armor;
  float stamina;
  float age;

  void Simulate(float deltaT)
  {
    age += deltaT;
    armor -= HitByAWeapon();
  }
}

Usando estado Visual

class IVisualState
{
  public:
  virtual void Interpolate(const IVisualState &newVS, float f) {}
};
class Actor
{
  struct VisualState: public IVisualState
  {
    Matrix4x3 position;
    float fuel;
    float armor;
    float stamina;
    float age;

    virtual auto_ptr<IVisualState> Interpolate(const IVisualState &newVS, float f)
    {
      const VisualState &newState = static_cast<const VisualState &>(newVS);
      IVisualState *ret = new VisualState;
      ret->age = lerp(this->age,newState.age);
      // ... interpolate other properties as well, using any suitable interpolation method
      // liner, spline, slerp, whatever works best for the given property
      return ret;
    };
  };

  auto_ptr<VisualState> state_;

  void Simulate(float deltaT)
  {
    state_->age += deltaT;
    state_->armor -= HitByAWeapon();
  }
}
Suma
fonte
11
Seu exemplo seria mais fácil de ler se você não usasse "new" (uma palavra reservada em C ++) como um nome de parâmetro.
Steve S
3

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.

deft_code
fonte
3
Eu encontrei isso também. Um loop físico de 120Hz com uma atualização de quadro de quase 60Hz torna a interpolação quase inútil. Infelizmente, isso funciona apenas para o conjunto de jogos que podem oferecer um loop físico de 120Hz.
Eu apenas tentei mudar para um loop de atualização de 120Hz. Isso parece ter o duplo benefício de tornar minha física mais estável e tornar meu jogo mais suave a taxas de quadros de menos de 60Hz. A desvantagem é que ele quebra toda a minha física de jogo cuidadosamente ajustada - então essa é definitivamente uma opção que precisa ser escolhida no início de um projeto.
Andrew Russell
Além disso: na verdade, não entendo sua explicação sobre seu sistema de interpolação. Soa um pouco como extrapolação, na verdade?
Andrew Russell
Boa decisão. Na verdade, descrevi um sistema de extrapolação. Dada a posição, velocidade e quanto tempo desde a última atualização física, extrapolo onde o objeto estaria se o mecanismo de física não tivesse parado.
Deft_code 9/09/10
2

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.

bluescrn
fonte
11
Parece que pelo menos o Quake 3 estava usando essa abordagem, com "tick" padrão sendo 20 fps (50 ms).
Suma
Interessante. Suponho que tenha suas vantagens em jogos para PC multijogador altamente competitivos, para garantir que PCs mais rápidos / taxas de quadros mais altas não obtenham muita vantagem (controles mais responsivos ou diferenças pequenas mas exploráveis ​​no comportamento físico / de colisão) ?
bluescrn
11
Você, em 10 anos, não encontrou nenhum jogo que rodasse a física que não estivesse em sintonia com a simulação e o renderizador? Porque no momento em que você faz isso, você praticamente terá que interpolar ou aceitar o jerkiness percebido em suas animações.
Kaj 10/10
2

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.

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.

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 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.

Kylotan
fonte
1

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 GameStateque 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?

Ricket
fonte
É realmente uma estrutura interessante. No entanto, não tenho certeza de que funcionaria bem para um jogo - como o caso geral, é uma árvore bastante plana de objetos que mudam exatamente uma vez por quadro. Também porque a alocação dinâmica de memória é um grande não-não.
Andrew Russell
A alocação dinâmica em um caso como esse é muito fácil de fazer com eficiência. Você pode usar um buffer circular, crescer de um lado, relase do segundo.
Suma
... que não seria a alocação dinâmica, apenas uso dinâmico de memória pré-alocados;)
Kaj
1

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.

Dwayne
fonte