Sincronização entre o segmento lógico do jogo e o segmento de renderização

16

Como se separa a lógica e a renderização do jogo? Eu sei que já parece haver perguntas aqui perguntando exatamente isso, mas as respostas não são satisfatórias para mim.

Pelo que entendi até agora, o ponto de separá-los em segmentos diferentes é para que a lógica do jogo possa começar a executar o próximo tick imediatamente, em vez de esperar o próximo vsync, no qual a renderização finalmente retorna da chamada swapbuffer que está bloqueando.

Mas especificamente quais estruturas de dados são usadas para evitar condições de corrida entre o segmento lógico do jogo e o segmento de renderização. Presumivelmente, o encadeamento de renderização precisa acessar várias variáveis ​​para descobrir o que desenhar, mas a lógica do jogo pode estar atualizando essas mesmas variáveis.

Existe uma técnica padrão de fato para lidar com esse problema. Talvez como copiar os dados necessários para o encadeamento de renderização após cada execução da lógica do jogo. Seja qual for a solução, a sobrecarga da sincronização será menor do que apenas executar tudo em um único encadeamento?

user782220
fonte
1
Eu odeio apenas spam um link, mas acho que é uma leitura muito boa e deve responder a todas as suas perguntas: altdevblogaday.com/2011/07/03/threading-and-your-game-loop
Roy T.
Outro link: software.intel.com/en-us/articles/…
Chewy Gumball
1
Esses links fornecem o resultado final típico que se deseja, mas não detalha como fazê-lo. Você copiaria o gráfico inteiro da cena de cada quadro ou algo mais? As discussões são de alto nível e vagas.
user782220
Eu pensei que os links eram bastante explícitos sobre quanto estado é copiado em cada caso. por exemplo. (do 1º link) "Um lote contém todas as informações necessárias para desenhar um quadro, mas não contém nenhum outro estado do jogo." ou (do 2º link) "Os dados ainda precisam ser compartilhados, mas agora, em vez de cada sistema acessar um local de dados comum para obter dados de posição ou orientação, cada sistema possui sua própria cópia" (Veja especialmente 3.2.2 - Estado Manager)
DMGregory
Quem escreveu esse artigo da Intel não parece saber que a segmentação de nível superior é uma péssima idéia. Ninguém faz algo tão estúpido. De repente, todo o aplicativo precisa se comunicar por meio de canais especializados e existem bloqueios e / ou grandes trocas coordenadas de estado em todos os lugares. Sem mencionar que não há como saber quando os dados enviados serão processados, por isso é extremamente difícil pensar sobre o que o código faz. É muito mais fácil copiar os dados da cena relevantes (imutáveis ​​como ponteiros contados ref., Mutáveis ​​- por valor) em um ponto e deixar o subsistema classificá-los da maneira que desejar.
snake5

Respostas:

1

Eu tenho trabalhado na mesma coisa. A preocupação adicional é que o OpenGL (e que eu saiba, OpenAL), e várias outras interfaces de hardware, sejam efetivamente máquinas de estado que não se dão bem ao serem chamadas por vários threads. Eu não acho que o comportamento deles seja definido, e para o LWJGL (possivelmente também o JOGL), muitas vezes gera uma exceção.

O que acabei fazendo foi criar uma sequência de threads que implementasse uma interface específica e carregá-las na pilha de um objeto de controle. Quando esse objeto recebia um sinal para encerrar o jogo, ele percorria cada encadeamento, chamava um método ceaseOperations () implementado e aguardava o fechamento antes de se fechar. Os dados universais que podem ser relevantes para renderizar som, gráficos ou qualquer outro dado são mantidos em uma sequência de objetos voláteis ou universalmente disponíveis para todos os threads, mas nunca mantidos na memória do thread. Há uma pequena penalidade no desempenho, mas, quando usada corretamente, ela me permitiu atribuir de maneira flexível o áudio a um segmento, os gráficos a outro, a física a outro e assim por diante, sem vinculá-los ao tradicional (e temido) "ciclo do jogo".

Assim, como regra, todas as chamadas do OpenGL passam pelo thread de gráficos, todo o OpenAL pelo thread de áudio, todas as entradas pelo thread de entrada e tudo o que o thread de controle da organização precisa se preocupar é com o gerenciamento de threads. O estado do jogo é mantido na classe GameState, na qual todos eles podem dar uma olhada conforme necessário. Se algum dia eu decidir que, digamos, JOAL foi datado e eu quero usar a nova edição do JavaSound, apenas implemento um thread diferente para o Audio.

Espero que você veja o que estou dizendo, já tenho alguns milhares de linhas neste projeto. Se você quiser que eu tente juntar uma amostra, verei o que posso fazer.

Michael Oberlin
fonte
O problema que você acabará enfrentando é que essa configuração não é dimensionada particularmente bem em uma máquina com vários núcleos. Sim, existem aspectos de um jogo que geralmente são mais bem servidos em seu próprio encadeamento, como áudio, mas grande parte do restante do ciclo do jogo pode realmente ser gerenciado serialmente em conjunto com as tarefas do conjunto de encadeamentos. Se o conjunto de encadeamentos oferecer suporte a máscaras de afinidade, é possível enfileirar facilmente, digamos, tarefas de renderização a serem executadas no mesmo encadeamento e fazer com que o planejador de encadeamentos gerencie as filas de trabalho de encadeamento e faça o trabalho de roubar conforme necessário, fornecendo suporte a vários segmentos e vários núcleos.
Naros
1

Normalmente, a lógica que lida com a renderização gráfica passa (e sua programação, quando eles serão executados etc.) é tratada por um thread separado. No entanto, esse encadeamento já foi implementado (instalado e em execução) pela plataforma usada para desenvolver o loop do jogo (e o jogo).

Portanto, para obter um loop de jogo no qual a lógica do jogo é atualizada independentemente da programação de atualização de gráficos, você não precisa criar threads extras, basta tocar no thread já existente para as atualizações de gráficos.

Isso depende de qual plataforma você está usando. Por exemplo:

  • se você estiver fazendo isso na maioria das plataformas relacionadas ao Open GL ( GLUT para C / C ++ , JOLG para Java , Ação relacionada ao OpenGL ES do Android ), geralmente eles fornecem um método / função que é chamado periodicamente pelo encadeamento de renderização e que você pode integrar-se ao loop do jogo (sem tornar as iterações do gameloop dependentes de quando esse método é chamado). Para GLUT usando C, você faz algo assim:

    glutDisplayFunc (myFunctionForGraphicsDrawing);

    glutIdleFunc (myFunctionForUpdatingState);

  • em JavaScript, você pode usar Web Workers, já que não há multiencadeamento (que você pode acessar por meio de programação) , também pode usar o mecanismo "requestAnimationFrame" para ser notificado quando uma nova renderização gráfica for agendada e fazer as atualizações do estado do jogo de acordo. .

Basicamente, o que você quer é um loop de jogo com etapas mistas: você tem algum código que atualiza o estado do jogo e é chamado dentro do segmento principal do seu jogo, e também deseja periodicamente acessar (ou ser chamado de volta) pelo já thread de renderização de gráficos existente para saber quando é hora de atualizar os gráficos.

Shivan Dragon
fonte
0

Em Java, há a palavra-chave "sincronizada", que bloqueia as variáveis ​​que você transmite para torná-las mais seguras. Em C ++, você pode conseguir a mesma coisa usando o Mutex. Por exemplo:

Java:

synchronized(a){
    //code using a
}

C ++:

mutex a_mutex;

void f(){
    a_mutex.lock();
    //code using a
    a_mutex.unlock();
}

O bloqueio de variáveis ​​garante que elas não sejam alteradas durante a execução do código a seguir, para que as variáveis ​​não sejam alteradas pelo seu thread de atualização enquanto você as renderiza (na verdade elas mudam, mas do ponto de vista do seu thread de renderização, elas não são alteradas " t). Porém, você deve ter cuidado com a palavra-chave sincronizada em Java, pois ela apenas garante que o ponteiro para a variável / Objeto não seja alterado. Os atributos ainda podem mudar sem alterar o ponteiro. Para considerar isso, você pode copiar o objeto você mesmo ou ligar sincronizado em todos os atributos do objeto que não deseja alterar.

zedutchgandalf
fonte
1
Os mutexes não são necessariamente a resposta aqui, porque o OP não apenas precisa desacoplar a lógica e a renderização do jogo, mas também deseja evitar o empate da capacidade de um encadeamento de avançar em seu processamento, independentemente de onde o outro encadeamento esteja atualmente em processamento. ciclo.
Naros5 /
0

O que eu geralmente vi para lidar com a comunicação lógica / renderizada de threads é triplicar o buffer de seus dados. Dessa forma, o encadeamento de renderização diz o bloco 0 do qual lê. O encadeamento lógico usa o balde 1 como fonte de entrada para o próximo quadro e grava os dados do quadro no balde 2.

Nos pontos de sincronização, os índices do significado de cada um dos três depósitos são trocados para que os dados do próximo quadro sejam fornecidos ao encadeamento de renderização e o encadeamento lógico possa continuar adiante.

Mas não há necessariamente uma razão para dividir renderização e lógica em seus respectivos threads. Na verdade, você pode manter o loop do jogo em série e dissociar sua taxa de quadros de renderização da etapa lógica usando a interpolação. Para aproveitar os processadores com vários núcleos usando esse tipo de configuração, é onde você teria um pool de threads que opera em grupos de tarefas. Essas tarefas podem ser simplesmente coisas como, em vez de iterar uma lista de objetos de 0 a 100, você itera a lista em 5 intervalos de 20 em 5 threads, aumentando efetivamente seu desempenho, mas não complicando demais o loop principal.

Naros
fonte
0

Este é um post antigo, mas ainda aparece, então, queria adicionar meus 2 centavos aqui.

Primeiro, liste os dados que devem ser armazenados na interface do usuário / exibir thread vs thread lógico. No thread da interface do usuário, você pode incluir malha 3d, texturas, informações de luz e uma cópia dos dados de posição / rotação / direção.

No segmento de lógica do jogo, você pode precisar do tamanho do objeto do jogo em 3d, primitivas delimitadoras (esfera, cubo), dados de malha 3d simplificados (para colisões detalhadas, por exemplo), todos os atributos que afetam o movimento / comportamento, como velocidade do objeto, taxa de rotação, etc., e também dados de posição / rotação / direção.

Se você comparar duas listas, poderá ver que apenas a cópia dos dados de posição / rotação / direção precisa ser passada da lógica para o encadeamento da interface do usuário. Você também pode precisar de algum tipo de ID de correlação para determinar a qual objeto de jogo esses dados pertencem.

Como você faz isso depende do idioma em que você está trabalhando. No Scala, você pode usar a Memória Transacional de Software, em Java / C ++, algum tipo de bloqueio / sincronização. Eu gosto de dados imutáveis, por isso tenho a tendência de retornar um novo objeto imutável a cada atualização. Isso é um pouco de desperdício de memória, mas com computadores modernos não é um problema. Ainda assim, se você deseja bloquear estruturas de dados compartilhadas, pode fazê-lo. Confira a classe Exchanger em Java, usar dois ou mais buffers pode acelerar as coisas.

Antes de começar a compartilhar dados entre threads, calcule quantos dados você realmente precisa passar. Se você tem um octree particionando seu espaço em 3D e pode ver 5 objetos do jogo em um total de 10 objetos, mesmo que sua lógica precise atualizar todos os 10, é necessário redesenhar apenas os 5 que você está vendo. Para ler mais, consulte este blog: http://gameprogrammingpatterns.com/game-loop.html Não se trata de sincronização, mas mostra como a lógica do jogo é separada da exibição e quais desafios você precisa superar (FPS). Espero que isto ajude,

Marca

Mark Citizen
fonte