No Pixonic DevGAMM Talks, nosso DTO Anton Grigoriev também falou. Nós da empresa já dissemos que estamos trabalhando em um novo jogo de tiro PvP e Anton compartilhou algumas das nuances da arquitetura deste projeto. Ele contou como desenvolver o desenvolvimento para que as mudanças na lógica do jogo do cliente apareçam no servidor automaticamente (e vice-versa) e se é possível não escrever código, mas minimizar o tráfego. Abaixo está um registro e uma transcrição do relatório.
Não vou aprender como fazer algo, vou falar sobre como fizemos. Para que você não pise no mesmo rake e possa usar nossa experiência. Há um ano e meio, nós na empresa não sabíamos como fazer atiradores em celulares. Você diz como é, você tem robôs de guerra, 100 milhões de downloads, 1,5 milhão de DAUs. Mas neste jogo, os robôs são muito lentos, e queríamos fazer um jogo rápido, e a arquitetura dos War Robots não permitia isso.
Sabíamos como e o que fazer, mas não tínhamos experiência. Então contratamos uma pessoa que teve essa experiência e dissemos: faça o mesmo que você já fez cem vezes, só que melhor. Então eles se sentaram e começaram a pensar em arquitetura.

Veio para o Sistema de componentes de entidades (ECS). Eu acho que muitas pessoas sabem o que é. Todos os objetos do mundo são representados por entidades. Por exemplo, um jogador, sua arma, algum objeto no mapa. Eles possuem propriedades descritas pelos componentes. Por exemplo, o componente Transform é a posição do jogador no espaço, e o componente Health é a saúde dele. Existe lógica - é separada e representada por sistemas. Normalmente, os sistemas são o método Execute (), que passa por componentes de um determinado tipo e faz algo com eles, com o mundo do jogo. Por exemplo, o MoveSystem passa por todos os componentes do Movement, analisa a velocidade desse componente, o parâmetro e, com base nisso, calcula a nova posição do objeto, ou seja, escreve para transformar.
Essa arquitetura tem características próprias. Ao desenvolver o ECS, você precisa pensar e fazer as coisas de maneira diferente. Uma das vantagens é a composição, e não a herança múltipla. Lembre-se deste rhombic com herança múltipla em C ++? Todos os seus problemas. Este não é o caso da ECS.

O segundo recurso é a separação da lógica e dos dados, sobre os quais eu já falei. O que isso nos dá? Podemos armazenar o estado do mundo e sua história em lotes, podemos serializá-los, podemos enviar esses dados pela rede e alterá-los em tempo real. São apenas dados na memória - podemos alterar qualquer valor a qualquer momento. Portanto, é muito conveniente alterar a lógica do jogo (ou para depurar).
Também é muito importante acompanhar a ordem de chamada do sistema. Todos os sistemas vão um após o outro, são chamados pelo método Execute () e, idealmente, devem ser independentes. Na prática, isso não acontece. Um sistema muda algo no mundo, outro sistema o usa. E se quebrarmos essa ordem, o jogo será diferente. Provavelmente não muito, mas definitivamente não é o mesmo de antes.
Por fim, um dos principais e mais importantes recursos para nós é que podemos executar o mesmo código no cliente e no servidor.
Dê ao desenvolvedor uma oportunidade, e ele encontrará 99 maneiras e motivos para tomar sua decisão, e não usar os existentes. Eu acho que muitos fizeram isso. Estávamos procurando a estrutura do ECS na época. Consideramos Entitas, Artemis C #, Ash.net e nossa própria solução, que pode ser escrita a partir da experiência de um especialista que veio até nós.

Não tente ler o que está escrito no slide, pois não é tão importante. O importante é a quantidade de verde e vermelho nas colunas. Verde significa que a solução suporta os requisitos, vermelho - não suporta, amarelo - suporta, mas não completamente.
Na coluna, o ECS é potencialmente a nossa solução. Como você pode ver, é mais legal - nós podemos oferecer suporte a muito mais requisitos. Como resultado, não apoiamos alguns deles (principalmente porque não eram necessários), e alguns, sem os quais não podíamos trabalhar mais, tiveram que ser feitos. Escolhemos arquitetura, trabalhamos por um longo tempo, criamos uma versão minimamente jogável e ... fakap.

Acabou sendo a versão mais não jogável. O jogador constantemente revertia, freia, o servidor pendia no meio da partida. Era impossível jogar. Quais foram as razões para as falhas?
Razão # 1 e o mais importante é a inexperiência. Mas como assim? Contratamos uma pessoa experiente que deveria fazer tudo lindamente. Sim, mas, na realidade, demos a ele apenas parte do trabalho. Dissemos: "Aqui está um servidor de jogo para você, trabalhe nele." E em nossa arquitetura (mais sobre isso mais adiante), o cliente desempenha um papel muito importante. E foi essa parte que demos a um homem que não tinha a experiência necessária. Não, ele é um bom programador, senor - simplesmente não havia experiência. I.e. ele não tinha idéia de que tipo de rake poderia haver.
Razão # 2 - alocações irrealistas. 80 KB / quadro. É muito ou não? Se levarmos em conta que temos 30 quadros por segundo, em um segundo obteremos 2,5 MB e, para uma correspondência de 5 minutos, já haverá mais de 600 MB. Em suma, muito. O coletor de lixo começa a tentar intensamente liberar toda essa memória (quando exigimos cada vez mais dela), o que leva a picos. Dado que queríamos 30 quadros por segundo, esses picos interferiram muito conosco. Além disso, no cliente e no servidor.
O principal motivo das alocações foi o fato de alocarmos constantemente matrizes de dados. Quase todas as vezes em todos os quadros. LINQ usado, expressões lambda e Photon. O Photon é uma biblioteca de rede que estamos familiarizados e usamos nos robôs de guerra. E tudo parece estar bem, mas aloca memória toda vez que envia ou recebe dados.
Se resolvermos os primeiros problemas (reescrevemos para nossas coleções personalizadas, fizemos o cache), praticamente não havia nada a ser feito com o Photon, porque é uma biblioteca de terceiros. Só foi possível reduzir o tamanho do pacote e tínhamos 5 Kbytes. Muito? Sim Existe MTU - esse é o tamanho mínimo real do pacote enviado por UDP, sem dividir o pacote em pequenas partes. É aproximadamente 1,5 Kbytes e tivemos 5 (em média, havia mais).
Assim, o Photon cortou nossa embalagem em pequenas e enviou cada peça como confiável, ou seja, com entrega garantida. Cada vez que a peça não chegava, ele a enviava repetidamente. Temos ainda mais latência e a rede funcionou mal.
Todas essas alocações levaram ao fato de que recebemos um quadro de cerca de 100 milissegundos quando foram necessários 33. E aí, renderização, simulação e outras ações - tudo isso leva a CPU. Todos esses problemas são complexos, ou seja, era impossível decidir um, e tudo ficaria bem. Era necessário resolvê-los todos de uma vez.
E outro pequeno problema que ocorreu durante o desenvolvimento - um grande número de repositórios. 5 está escrito no slide, mas parece-me que havia ainda mais deles. Todos esses repositórios (para o cliente, o servidor do jogo, código comum, configurações e outras coisas) foram conectados por sub-módulos nos dois repositórios principais do cliente e do servidor do jogo. Foi difícil trabalhar com isso. Os programadores podem trabalhar com Git, SVN, mas também existem artistas, designers, etc. Eu acho que muitos tentaram ensinar um artista ou designer a trabalhar com um sistema de controle de versão. Isso é realmente difícil, portanto, se o seu designer sabe como fazê-lo - cuide dele, ele é um funcionário valioso. No nosso caso, até os programadores enlouqueceram e, como resultado, reduzimos tudo para um repositório.
Essa foi uma ótima solução para o problema. Temos uma pasta com um servidor lá e uma pasta com um cliente. O servidor consiste em um projeto de servidor de jogo, um gerador de código e ferramentas auxiliares.

Um cliente é um cliente Unity e código comum. Um código comum é uma estrutura de dados mundiais, ou seja, Entidades, componentes e simulação do sistema. Esse código é gerado principalmente pelo gerador do servidor. É usado pelo servidor. I.e. Essa é uma parte comum para o cliente e o servidor.
Vida Pegamos o TeamCity, configuramos em nosso repositório, coletamos e implantamos o servidor. Toda vez que um cliente altera a lógica geral, um servidor de jogo é montado aqui - agora, um programador de servidor não é necessário para isso. Geralmente há um servidor, um cliente e algum recurso. O cliente o vê em casa, o servidor em casa e, algum dia, ele funcionará para eles. No nosso caso, não é assim - o cliente pode escrever esse recurso e tudo funciona no servidor.
A partida consiste em uma parte comum (designada como ECS) e uma apresentação (essas são classes unitárias de MonoBehavior, GameObjects, modelos, efeitos - tudo o que o mundo representa). Eles não estão conectados.

Entre eles, estão os apresentadores, que trabalham com as duas partes. Como você entende, esse é o MVP (Model-View-Presenter) e qualquer uma dessas partes pode ser substituída, se necessário. Há outra parte que funciona com a rede (no slide - Rede). Isso é serialização de informações sobre o mundo, serialização de entrada, envio para o servidor, recebimento pelo servidor, conexão com o servidor, etc.
Mais curtidas. Pegamos e substituímos esta peça por um pacote que não é real, pela rede, mas virtual. Criamos um objeto dentro do cliente e enviamos mensagens a ele. Ele implementa uma simulação de servidor - agora esse objeto faz tudo o que aconteceu no servidor do jogo. Os jogadores restantes são substituídos por bots.

Feito. Temos o jogo e a capacidade de testá-lo sem um servidor de jogo. O que isso significa? Isso significa que o artista, depois de criar um novo efeito, pode clicar no botão Reproduzir no editor, chegar imediatamente à partida no mapa e ver como ele funciona. Ou depure para programadores clientes o que eles escreveram.
Mas fomos além e anexamos a essa camada emulação o jitter ping dos atrasos da rede (é quando os pacotes na rede não chegam na ordem em que foram enviados) e outras coisas da rede. Como resultado, conseguimos uma correspondência praticamente real sem um servidor de jogos. Funciona, verificado.
Vamos voltar à geração de código.

Eu já disse que temos um gerador de código em um servidor de jogos. Existe uma linguagem específica de domínio, que na verdade é uma classe C # simples. Nesse caso, a classe Health. Marcamos com nossos atributos. Por exemplo, há um atributo de componente. Ele diz que a saúde é um componente em nosso mundo. Com base nesse atributo, o gerador criará uma nova classe C # na qual haverá várias coisas. Eles podem ser escritos à mão, mas serão gerados. Por exemplo, o método de adicionar um componente à Entidade, o método de procurar componentes, serializar dados etc. Há um atributo do tipo DontSend, que diz que não é necessário enviar um campo pela rede - o servidor não precisa dele ou o cliente não precisa. Ou o atributo Mach, que informa que o jogador tem um valor máximo de vida de mil. O que isso nos dá? Em vez de um campo que ocupa 32 bits (int), enviamos 10 bits - três vezes menos. Esse gerador de código nos permitiu reduzir o tamanho do pacote de 5 KB para 1.

1 KB <1,5 - ou seja, Conhecemos o MTU. O Photon parou de cortar e a rede ficou muito melhor. Quase todos os seus problemas se foram. Mas fomos além e fizemos uma compactação delta.

É quando você envia um estado completo e, em seguida, apenas suas alterações. Não acontece que o mundo inteiro imediatamente tenha mudado completamente. Apenas algumas partes estão mudando constantemente e essas mudanças são muito menores em tamanho do que o próprio estado. Recebemos uma média de 300 bytes, ou seja, 17 vezes menos do que era originalmente.
Por que isso é necessário se você já entrou no MTU? O jogo está em constante crescimento, novos recursos aparecem, e com eles aparecem objetos, entidades, novos componentes. O tamanho dos dados está aumentando. Se parássemos com 1 KB, em breve retornaríamos ao mesmo problema. Agora, tendo reescrito para compactação delta, não chegaremos a isso muito em breve.
Agora a parte mais doce. Sincronizar Se você joga atiradores, sabe o que é Lag de Entrada - quando você clica no botão e o personagem começa a se mover após algum tempo, por exemplo, meio segundo. Para alguns jogos do gênero mob, isso é normal. Mas no jogo de tiro, você quer que o herói atire e cause dano ali.

Por que o Input Lag está acontecendo? O cliente coleta a entrada (entrada) do jogador e a envia para o servidor do jogo (o envio leva tempo). Em seguida, o servidor do jogo processa (novamente, hora) e envia o resultado de volta (novamente, hora). Isso é um atraso. Como removê-lo? Existe uma coisa chamada previsão - o cliente não espera por uma resposta do servidor e começa imediatamente a tentar fazer a mesma coisa que o servidor do jogo, ou seja, finge. Pega a entrada do player e inicia a simulação. Simulamos apenas um cliente local, porque não conhecemos a opinião de outros jogadores - eles não vêm até nós. Portanto, rodamos o sistema de simulação apenas no nosso player.
Em primeiro lugar, permite reduzir o tempo de simulação. O cliente inicia a simulação assim que recebe a entrada e está várias etapas adiante em relação ao servidor do jogo. Digamos que nesta foto ele esteja simulando o tick 20. Neste ponto, o servidor do jogo simula o tick # 15 no passado. O cliente vê o resto do mundo, novamente, no passado, ele mesmo - no futuro. Enquanto ele envia o 20º tick para o servidor, enquanto essa entrada chega, o servidor do jogo já começará a simular o 18º tick ou já o 20º. Se o dia 18, ele o coloca no buffer, chega ao dia 20, processa e retorna o resultado.
Digamos que agora ele esteja simulando o tick nº 15. Processado, retorna o resultado para o cliente. O cliente tem algum tipo de 15º tick simulado, 15º estado de jogo e o mundo do jogo que ele previu. A comparação com o servidor começa. Na verdade, ele não compara o mundo inteiro, mas apenas o seu cliente, porque não somos responsáveis pelo resto do mundo. Nós somos apenas responsáveis por nós mesmos. Se o jogador coincidiu, está tudo bem, o que significa que simulamos corretamente, a física funcionou corretamente e nenhuma colisão ocorreu. Em seguida, continuamos a simular o 20º tick, o 21º e assim por diante.
Se o cliente / jogador não corresponder, significa que estávamos enganados em algum lugar. Exemplo: como a física não é determinística, ela não calculou corretamente nossa posição ou algo aconteceu. Talvez apenas um bug. Em seguida, o cliente obtém o estado do servidor do jogo, porque o servidor já o confirmou (ele confia no servidor - se não confiasse, os jogadores trapaceariam) e simulará o restante dos dias 15 a 20. Porque esse ramo do tempo agora está errado.
Crie um novo ramo de tempo, ou seja, mundos paralelos. Nós estimulamos novamente esses cinco ticks em um tick. Uma vez que nossa simulação levou 5 milissegundos, mas se precisarmos simular 10 ticks, já são 50 milissegundos e não caímos em nossos 30 milissegundos. Eles otimizaram e obtiveram um milissegundo - agora 10 ticks são processados em 10 milissegundos. Porque ainda há renderização.
Todas essas coisas funcionam no cliente, e a demos à pessoa sem a experiência necessária. Menos - tivemos um fakap e mais - que o programador agora sabe como fazê-lo corretamente.

Este esquema tem características próprias. O cliente na foto à esquerda está tentando rastrear o inimigo. Ele está no 20º tick, o oponente está no 15º tick. Porque o ping e o cliente estão 5 vezes à frente do servidor. O cliente atira e precisa acertar e causar danos, talvez até um tiro na cabeça. Mas a imagem é diferente no servidor - quando o servidor começa a simular o vigésimo tick, o inimigo já pode se mover. Por exemplo, se o inimigo estava se movendo. Em teoria, não devemos entender. Mas se isso funcionasse assim, ninguém jogaria atiradores online devido a erros constantes. Dependendo do ping, a probabilidade de acertar também mudou: quanto pior o ping, pior fica. Portanto, eles fazem isso de maneira diferente.
O servidor leva e rola o mundo inteiro para a teca na qual o jogador viu o mundo. O servidor sabe quando foi, reverte para o 15º tick e vê a figura à esquerda. Ele vê que o jogador deveria ter acertado e causa dano ao seu oponente já no 20º tick. Está tudo bem. Quase. Se o inimigo fugiu e correu atrás de um obstáculo, já atiramos na cabeça através do muro. Mas este é um problema conhecido, os jogadores sabem disso e não se preocupem. Portanto, funciona, não há nada a ser feito sobre isso.

Assim, atingimos 30 ticks por segundo, 30 quadros por segundo. Agora, aproximadamente 600 jogadores estão jogando no nosso servidor ao mesmo tempo. Existem 6 jogadores na partida, ou seja, cerca de 100 correspondências. Não temos um programador de servidor, não precisamos dele. Os clientes escrevem toda a lógica no editor do Unity, Rider, em C # e funcionam em um servidor de jogos. Quase sempre. Reduzimos o tamanho do pacote em 17 vezes e alocamos a memória em 80 vezes - agora menos de um kilobyte no cliente e no servidor. O ping médio foi de 200 a 250 ms, agora é 150. 200 é o padrão para jogos em rede móvel, diferente de um PC, onde tudo acontece muito mais rápido, especialmente em uma rede local.

Planejamos isolar o que está escrito em uma estrutura separada para usá-lo em outros projetos. Mas até agora, não se fala em código aberto. E adicione interpolação lá. Agora temos 30 ticks por segundo, podemos desenhar conforme o tick. Mas existem jogos em que 20 ticks por segundo ou 10. são suficientes, portanto, se empatarmos 10 vezes por segundo, os personagens se moverão em empurrões. Portanto, é necessária interpolação. Nós escrevemos nossa própria biblioteca de rede em vez do Photon - não há alocações de memória lá.
Ainda existem partes que você não pode escrever com as mãos, mas gera código. Por exemplo, quando enviamos o estado do mundo para um cliente, cortamos os dados que ele não precisa. Enquanto fazemos isso com as mãos e quando um novo recurso aparece, e esquecemos de cortar esses dados, algo dá errado. De fato, isso pode ser gerado marcando algum atributo.
Perguntas da platéia
- O que você está usando para geração de código? Sua própria decisão?- Tudo é simples - mãos. Pensamos em preparar algo, mas acabou sendo mais rápido escrever com as próprias mãos. Siga este caminho, funcionou bem então e agora.
- Você recusou o desenvolvedor do servidor, mas não reduziu apenas o tempo de desenvolvimento devido ao fato de o mesmo código ser reutilizado. O Unity não suporta a versão mais recente do C #, possui seu próprio mecanismo sob o capô. Você não pode usar o .NET Core, não pode usar os recursos mais recentes, determinadas estruturas e muito mais. O desempenho não sofre cerca de um terço disso?- Quando começamos a fazer tudo isso, pensamos que, para usar não classes, mas estruturas, deveria ter funcionado muito mais rápido. Escrevemos um protótipo de como ficará no código, como os programadores usarão essas estruturas para escrever lógica. E era terrivelmente desconfortável. Decidimos as aulas e o desempenho que temos agora é suficiente para nós.
- Como você mora agora sem interpolação? E como você finge ser um jogador se o instantâneo não aparece no quadro certo?- Temos interpolação, mas não é visual, mas naqueles pacotes que chegam pela rede. Digamos que temos os 18, 19 e 20 estados. 18-, 20-, 19- , — . , .
— , ?— 2D — , . : UDP, , : , . .
— ?"Sim, claro." - ( , , ), 2 : , .
— . ? ? 1000 , ? , ?— , . , -, , . , 30 .
— , ?— . , ( ), . — , , . - , , . , , , , . .
— ECS, , ? ?— 30 , . 80 , . .
— prediction. 20- - , , - — , ? , . - ?— : . (, 15-) 16-,17-,18- .
— ?— , . , , . Entity ( ), . — , . ID , .
— - — , , , ? , ?— , , . 3D , — , , - . , , . top-down, — . . , , , . .
— ?— . Isso também acontece.
— , , - . - . - , , , 500 , , - - . ?— .

, .. 20- 20- , . — , . : 20- , ? , . , — - . , . , « - , 21-, 18-». : «, - ». .
— .. , ?— , .
— reliable UDP — - ?— Photon, Photon reliable UDP, unreliable, c .
— ?— , -. , . , . , . , . 100%, , 80%, .
— ?— , , Photon , MTU.
— ? ?— , , , . . , . , , , .
— , , ?— , . , . , , . , - . , . , .
— / — - . , .— , . , ( ), -, , -. , . — , .
— , - . ?— , . : ECS, . , ECS . , ECS . , . , , , ( , , , , , ). 2D , , 3D — . 3D , , . . - , . , - -, .
— , ECS , . , , C#?— — .
— .. ES ? , ECS — , , , . .. ECS — , .— , , . , . — , , . , O - , , .
— , ECS- ?— -, ECS , , ( ) — , . , — . — , , . , , , ..
Pixonic DevGAMM Talks