En Pixonic DevGAMM Talks, nuestro DTO Anton Grigoriev también habló. Nosotros en la compañía ya dijimos que estamos trabajando en un nuevo tirador PvP y Anton compartió algunos de los matices de la arquitectura de este proyecto. Dijo cómo construir el desarrollo para que los cambios en la lógica del juego del cliente aparezcan automáticamente en el servidor (y viceversa), y si es posible no escribir código, sino minimizar el tráfico. A continuación hay un registro y una transcripción del informe.
No aprenderé cómo hacer algo, hablaré sobre cómo lo hicimos. Para que no pises el mismo rastrillo y puedas usar nuestra experiencia. Hace un año y medio, nosotros en la compañía no sabíamos cómo hacer tiradores en teléfonos móviles. Dices cómo es, tienes War Robots, 100 millones de descargas, 1,5 millones de DAU. Pero en este juego, los robots son muy lentos, y queríamos hacer un tirador rápido, y la arquitectura de War Robots no lo permitió.
Sabíamos cómo y qué hacer, pero no teníamos experiencia. Luego contratamos a una persona que tenía esta experiencia y dijo: haz lo mismo que ya has hecho cientos de veces, solo que mejor. Luego se sentaron y comenzaron a pensar en la arquitectura.

Llegó al Entity Component System (ECS). Creo que mucha gente sabe lo que es. Todos los objetos del mundo están representados por entidades. Por ejemplo, un jugador, su arma, algún objeto en el mapa. Tienen propiedades que se describen por componentes. Por ejemplo, el componente Transformar es la posición del jugador en el espacio, y el componente Salud es su salud. Hay lógica: está separada y representada por sistemas. Típicamente, los sistemas son el método Execute (), que pasa a través de componentes de cierto tipo y hace algo con ellos, con el mundo del juego. Por ejemplo, MoveSystem pasa por todos los componentes de Movimiento, observa la velocidad en este componente, el parámetro y, sobre la base de esto, calcula la nueva posición del objeto, es decir lo escribe para transformar.
Tal arquitectura tiene sus propias características. Cuando se desarrolla en ECS, debe pensar y hacer las cosas de manera diferente. Una de las ventajas es la composición en lugar de la herencia múltiple. ¿Recuerdas esta rómbica con herencia múltiple en C ++? Todos sus problemas. Este no es el caso en ECS.

La segunda característica es la separación de lógica y datos, de la que ya hablé. ¿Qué nos da esto? Podemos almacenar el estado del mundo y su historia en lotes, podemos serializarlo, podemos enviar estos datos a través de la red y cambiarlos en tiempo real. Estos son solo datos en la memoria: podemos cambiar cualquier valor en cualquier momento. Por lo tanto, es muy conveniente cambiar la lógica del juego (o para depurar).
También es muy importante realizar un seguimiento del orden de las llamadas al sistema. Todos los sistemas van uno tras otro, son llamados por el método Execute () e, idealmente, deberían ser independientes. En la práctica, esto no sucede. Un sistema cambia algo en el mundo, otro sistema lo usa. Y si rompemos este orden, el juego será diferente. Probablemente no mucho, pero definitivamente no es lo mismo que antes.
Finalmente, una de las características principales y más importantes para nosotros es que podemos ejecutar el mismo código tanto en el cliente como en el servidor.
Dale una oportunidad al desarrollador, y encontrará 99 formas y razones para tomar su decisión, y no usará las existentes. Creo que muchos lo hicieron. Estábamos buscando el Marco ECS en ese momento. Consideramos Entitas, Artemis C #, Ash.net y nuestra propia solución, que podría escribirse a partir de la experiencia de un especialista que vino a nosotros.

No intente leer lo que está escrito en la diapositiva, no es tan importante. Lo importante es cuánto verde y rojo hay en las columnas. Verde significa que la solución es compatible con los requisitos, rojo - no es compatible, amarillo - es compatible, pero no del todo.
En la columna, ECS es potencialmente nuestra solución. Como puede ver, está más fresco: podríamos admitir muchos más requisitos. Como resultado, no admitimos algunos de ellos (principalmente porque no eran necesarios), y algunos, sin los cuales no podríamos trabajar más, tuvieron que hacerse. Elegimos la arquitectura, trabajamos durante mucho tiempo, hicimos una versión mínimamente jugable y ... fakap.

Resultó la versión más injugable. El jugador constantemente retrocedía, frenaba, el servidor colgaba en el medio del partido. Era imposible jugarlo. ¿Cuáles fueron las razones de los fracasos?
Razón # 1 y lo más importante es la inexperiencia. Pero como es eso? Contratamos a una persona con experiencia que se suponía que debía hacer todo maravillosamente. Sí, pero en realidad le dimos solo una parte del trabajo. Dijimos: "Aquí hay un servidor de juegos para usted, trabaje en él". Y en nuestra arquitectura (más sobre esto más adelante), el cliente juega un papel muy importante. Y fue esta parte la que le dimos a un hombre que no tenía la experiencia necesaria. No, es un buen programador, señor. Simplemente no había experiencia. Es decir no tenía idea de qué tipo de rastrillo podría haber.
Razón # 2 - asignaciones poco realistas. 80 KB / trama. ¿Es mucho o no? Si tenemos en cuenta que tenemos 30 cuadros por segundo, en un segundo obtenemos 2.5 MB, y para un partido de 5 minutos ya hay más de 600 MB. En resumen, mucho. El recolector de basura comienza a tratar intensamente de liberar toda esta memoria (cuando le exigimos más y más), lo que conduce a picos. Dado que queríamos 30 cuadros por segundo, estos picos nos interferían mucho. Además, tanto en el cliente como en el servidor.
La razón principal de las asignaciones fue que constantemente asignamos matrices de datos. Casi cada vez cada cuadro. Utiliza LINQ, expresiones lambda y Photon. Photon es una biblioteca de red con la que estamos familiarizados y utilizamos en War Robots. Y todo parece estar bien, pero asigna memoria cada vez que envía datos o los recibe.
Si solucionamos los primeros problemas (reescribimos a nuestras colecciones personalizadas, hicimos el almacenamiento en caché), entonces prácticamente no había nada que hacer con Photon, porque es una biblioteca de terceros. Solo era posible reducir el tamaño del paquete, y teníamos 5 Kbytes. Mucho? Si Hay MTU: este es el tamaño de paquete real mínimo que se envía a través de UDP, sin dividir el paquete en partes pequeñas. Es aproximadamente 1.5 Kbytes, y tuvimos 5 (en promedio, había más).
En consecuencia, Photon cortó nuestro paquete en pequeños y envió cada pieza como confiable, es decir Con entrega garantizada. Cada vez que la parte no llegaba, la enviaba una y otra vez. Obtuvimos aún más latencia y la red funcionó mal.
Todas estas asignaciones condujeron al hecho de que recibimos un marco de aproximadamente 100 milisegundos cuando se necesitaba 33. Y allí, renderización, simulación y otras acciones, todo esto requiere la CPU. Todos estos problemas son complejos, es decir era imposible decidir uno, y todo estaría bien. Era necesario resolverlos todos a la vez.
Y otro pequeño problema que se produjo durante el desarrollo: una gran cantidad de repositorios. 5 está escrito en la diapositiva, pero me parece que había aún más. Todos estos repositorios (para el cliente, el servidor del juego, el código común, la configuración y algo más) estaban conectados por submódulos en los dos repositorios principales para el cliente y el servidor del juego. Fue difícil trabajar con él. Los programadores pueden trabajar con Git, SVN, pero también hay artistas, diseñadores, etc. Creo que muchos trataron de enseñarle a un artista o diseñador cómo trabajar con un sistema de control de versiones. Esto es realmente difícil, por lo que si su diseñador sabe cómo hacerlo, cuídelo, es un empleado valioso. En nuestro caso, incluso los programadores se asustaron y, como resultado, redujimos todo a un solo repositorio.
Esta fue una gran solución al problema. Tenemos una carpeta con un servidor allí y una carpeta con un cliente. El servidor consta de un proyecto de servidor de juegos, un generador de código y herramientas auxiliares.

Un cliente es un cliente de Unity y un código común. Un código común es una estructura de datos mundial, es decir Entidades, componentes y simulación de sistemas. Este código es generado principalmente por el generador del servidor. Es usado por el servidor. Es decir Esta es una parte común para el cliente y el servidor.
La vida Tomamos TeamCity, lo configuramos en nuestro repositorio, recopilamos e implementamos el servidor. Cada vez que un cliente cambia la lógica general, se ensambla un servidor de juegos aquí, ahora no se necesita un programador de servidor para esto. Por lo general, hay un servidor, un cliente y algunas características. El cliente lo corta en casa, el servidor en casa, y algún día funcionará para ellos. En nuestro caso, no es así: el cliente puede escribir esta función y todo funciona en el servidor.
La coincidencia consta de una parte común (designada como ECS) y una presentación (estas son clases unitarias MonoBehavior, GameObjects, modelos, efectos, todo lo que el mundo representa). No están conectados

Entre ellos hay presentadores, que trabaja con ambas partes. Como comprenderá, este es MVP (Model-View-Presenter) y cualquiera de estas partes se puede reemplazar si es necesario. Hay otra parte que funciona con la red (en la diapositiva - Red). Esto es serialización de información sobre el mundo, serialización de entrada, envío al servidor, recepción por el servidor, conexión al servidor, etc.
Más me gusta. Tomamos y reemplazamos esta parte con un paquete que no es real, a través de la red, sino virtual. Creamos un objeto dentro del cliente y le enviamos mensajes. Implementa una simulación de servidor: ahora este objeto hace todo lo que sucedió en el servidor del juego. Los jugadores restantes son reemplazados por bots.

Listo Obtuvimos el juego y la capacidad de probarlo sin un servidor de juegos. ¿Qué significa esto? Esto significa que el artista, después de haber hecho un nuevo efecto, puede hacer clic en el botón Reproducir en el editor, ir inmediatamente al partido en el mapa y ver cómo funciona. O depurar para los programadores del cliente lo que escribieron.
Pero fuimos más allá y nos unimos a esta emulación de capa del jitter ping de los retrasos de la red (esto es cuando los paquetes en la red no llegan en el orden en que fueron enviados) y otras cosas de la red. Como resultado, obtuvimos una coincidencia prácticamente real sin un servidor de juegos. Funciona, verificado.
Volvamos a la generación de código.

Ya he dicho que tenemos un generador de código en un servidor de juegos. Hay un lenguaje específico de dominio, que en realidad es una clase simple de C #. En este caso, la clase Salud. Lo marcamos con nuestros atributos. Por ejemplo, hay un atributo componente. Él dice que la salud es un componente en nuestro mundo. Según este atributo, el generador creará una nueva clase de C # en la que habrá un montón de cosas. Se pueden escribir a mano, pero generará. Por ejemplo, el método de agregar un componente a la entidad, el método de búsqueda de componentes, serialización de datos, etc. Hay un atributo del tipo DontSend, que dice que no es necesario enviar un campo a través de la red: el servidor no lo necesita o el cliente no lo necesita. O el atributo Mach, que informa que el jugador tiene un valor de salud máximo de mil. ¿Qué nos da esto? En lugar de un campo que ocupa 32 bits (int), enviamos 10 bits, tres veces menos. Tal generador de código nos permitió reducir el tamaño del paquete de 5 KB a 1.

1 KB <1.5 - es decir Conocimos a la MTU. El fotón dejó de cortar y la red mejoró mucho. Casi todos sus problemas se han ido. Pero fuimos más allá e hicimos una compresión delta.

Esto es cuando envía un estado completo, y luego solo sus cambios. No sucede que todo el mundo haya cambiado por completo de inmediato. Solo algunas partes cambian constantemente y estos cambios son mucho más pequeños en tamaño que el estado mismo. Recibimos un promedio de 300 bytes, es decir 17 veces menos de lo que era originalmente.
¿Por qué es esto necesario si ya te metiste en MTU? El juego está en constante crecimiento, aparecen nuevas características, y con ellas aparecen objetos, entidades, nuevos componentes. El tamaño de los datos está creciendo. Si nos detuviéramos en 1 KB, muy pronto volveríamos al mismo problema. Ahora, habiéndolo reescrito para la compresión delta, no alcanzaremos esto muy pronto.
Ahora la parte más dulce. Sincronización Si juegas tiradores, sabes lo que es Input Lag: cuando haces clic en el botón y el personaje comienza a moverse después de un tiempo, por ejemplo, medio segundo. Para algunos juegos en el género de la mafia, esto es normal. Pero en el tirador, quieres que el héroe dispare y haga daño allí mismo.

¿Por qué ocurre el retraso de entrada? El cliente recoge la entrada (entrada) del jugador y la envía al servidor del juego (el envío lleva tiempo). Luego, el servidor del juego lo procesa (nuevamente, tiempo) y envía el resultado nuevamente (nuevamente, tiempo). Esto es un retraso. ¿Cómo quitarlo? Hay una cosa llamada predicción: el cliente no espera una respuesta del servidor e inmediatamente comienza a intentar hacer lo mismo que el servidor del juego, es decir. finge Toma la entrada del jugador y comienza la simulación. Solo simulamos un cliente local, porque no conocemos la opinión de otros jugadores, ellos no vienen a nosotros. Por lo tanto, ejecutamos el sistema de simulación solo en nuestro reproductor.
En primer lugar, permite reducir el tiempo de simulación. El cliente inicia la simulación tan pronto como recibe la entrada y está varios pasos por delante en relación con el servidor del juego. Digamos que en esta imagen está simulando la marca # 20. En este punto, el servidor del juego simula la marca # 15 en el pasado. El cliente ve el resto del mundo, nuevamente, en el pasado, él mismo, en el futuro. Mientras envía el 20 ° tick al servidor, mientras esta entrada llega, el servidor del juego ya comenzará a simular el 18 ° tick o ya el 20 °. Si es el 18, lo coloca en el búfer, llega al 20, procesa y devuelve el resultado.
Digamos ahora que está simulando la marca No. 15. Procesado, devuelve el resultado al cliente. El cliente tiene algún tipo de 15º tic simulado, 15º estado del juego y el mundo del juego que predijo. Comienza la comparación con el servidor. De hecho, él no compara al mundo entero, sino solo a su cliente, porque no somos responsables del resto del mundo. Solo somos responsables de nosotros mismos. Si el jugador coincidió, todo está bien, lo que significa que simulamos correctamente, la física funcionó correctamente y no surgieron colisiones. Luego continuamos simulando la marca 20, la 21 y así sucesivamente.
Si el cliente / jugador no coincide, significa que nos equivocamos en alguna parte. Ejemplo: como la física no es determinista, no calculó correctamente nuestra posición o sucedió algo. Tal vez solo un error. Luego, el cliente toma el estado del servidor del juego, porque el servidor del juego ya lo ha confirmado (confía en el servidor; si no confiaba, los jugadores harían trampa), y volverá a simular el resto del 15 al 20. Porque esta rama del tiempo ahora es errónea.
Crear una nueva rama de tiempo, es decir mundos paralelos. Resimulamos estas cinco marcas en una sola marca. Una vez que nuestra simulación tomó 5 milisegundos, pero si necesitamos simular 10 ticks, ya son 50 milisegundos y no caemos en nuestros 30 milisegundos. Se optimizaron y obtuvieron un milisegundo: ahora se procesan 10 ticks en 10 milisegundos. Porque todavía hay renderizado.
Todas estas cosas funcionan en el cliente, y se lo dimos a la persona sin la experiencia necesaria. Menos, tuvimos un fakap, y además, que el programador ahora sabe cómo hacerlo bien.

Este esquema tiene sus propias características. El cliente en la imagen de la izquierda está tratando de localizar al enemigo. Él está en la marca 20, el oponente está en la marca 15. Porque ping y el cliente están 5 tics por delante del servidor. El cliente dispara y debe golpear con precisión y causar daños, tal vez incluso un tiro en la cabeza. Pero la imagen es diferente en el servidor: cuando el servidor comienza a simular la vigésima marca, el enemigo ya puede moverse. Por ejemplo, si el enemigo se estaba moviendo. En teoría, no deberíamos llegar. Pero si eso funcionara así, entonces nadie jugaría tiradores en línea debido a fallas constantes. Dependiendo del ping, la probabilidad de golpear también cambió: cuanto peor sea el ping, peor será. Por lo tanto, lo hacen de manera diferente.
El servidor lleva y rueda todo el mundo a la teca en la que el jugador vio el mundo. El servidor sabe cuándo fue, lo regresa a la marca 15 y ve la imagen de la izquierda. Él ve que el jugador debería haber golpeado y le hace daño a su oponente ya en la vigésima marca. Todo esta bien. Casi. Si el enemigo huyó y corrió detrás de un obstáculo, entonces disparamos a la cabeza ya a través del muro. Pero este es un problema conocido, los jugadores lo saben y no se preocupen. Entonces funciona, no hay nada que hacer al respecto.

Entonces, hemos alcanzado 30 tics por segundo, 30 cuadros por segundo. Ahora, aproximadamente 600 jugadores están jugando en nuestro servidor al mismo tiempo. Hay 6 jugadores en el partido, es decir Cerca de 100 partidos. No tenemos un programador de servidor, no lo necesitamos. Los clientes escriben toda la lógica en el editor de Unity, Rider, en C # y funciona en un servidor de juegos. Casi siempre Redujimos el tamaño del paquete en 17 veces y redujimos las asignaciones de memoria en 80 veces, ahora incluso menos de un kilobyte en el cliente y el servidor. El ping promedio fue de 200-250 ms, ahora es de 150. 200 es el estándar para los juegos de redes móviles, a diferencia de una PC, donde todo sucede mucho más rápido, especialmente en una red local.

Planeamos aislar lo que está escrito en un marco separado para usarlo en otros proyectos. Pero hasta ahora, no se habla de código abierto. Y agregue interpolación allí. Ahora tenemos 30 tics por segundo, podemos dibujar a medida que avanza. Pero hay juegos en los que 20 tics por segundo o 10 son suficientes. En consecuencia, si dibujamos 10 veces por segundo, los personajes se moverán en tirones. Por lo tanto, se necesita interpolación. Escribimos nuestra propia biblioteca de red en lugar de Photon; allí no hay asignaciones de memoria.
Todavía hay partes que no puedes escribir con tus manos, pero generan código. Por ejemplo, cuando enviamos el estado del mundo a un cliente, eliminamos los datos que no necesita. Mientras hacemos esto con nuestras manos y cuando aparece una nueva característica, y olvidamos recortar estos datos, algo sale mal. De hecho, esto puede generarse marcando algún atributo.
Preguntas de la audiencia
- ¿Qué estás usando para la generación de código? ¿Tu propia decisión?- Todo es simple - manos. Pensamos hacer algo listo, pero resultó ser más rápido solo escribir con nuestras propias manos. Siga este camino, funcionó bien entonces y ahora.
- Rechazó al desarrollador del servidor, pero no solo redujo el tiempo de desarrollo debido al hecho de que se reutiliza el mismo código. Unity no es compatible con la última versión de C #, tiene su propio motor debajo del capó. No puede usar .NET Core, no puede usar las últimas características, ciertas estructuras y más. ¿El rendimiento no sufre aproximadamente un tercio de esto?- Cuando comenzamos a hacer todo esto, pensamos que para usar no clases, sino estructuras, debería haber funcionado mucho más rápido. Escribimos un prototipo de cómo se verá en el código, cómo los programadores usarán estas estructuras para escribir la lógica. Y fue terriblemente incómodo. Nos decidimos por las clases y el rendimiento que tenemos ahora es suficiente para nosotros.
- ¿Cómo vives ahora sin interpolación? ¿Y cómo pretendes ser un jugador si la instantánea no viene en el marco correcto?- Tenemos interpolación, solo que no es visual, sino en aquellos paquetes que vienen a través de la red. Digamos que tenemos los estados 18, 19 y 20. 18-, 20-, 19- , — . , .
— , ?— 2D — , . : UDP, , : , . .
— ?"Sí, por supuesto". - ( , , ), 2 : , .
— . ? ? 1000 , ? , ?— , . , -, , . , 30 .
— , ?— . , ( ), . — , , . - , , . , , , , . .
— ECS, , ? ?— 30 , . 80 , . .
— prediction. 20- - , , - — , ? , . - ?— : . (, 15-) 16-,17-,18- .
— ?— , . , , . Entity ( ), . — , . ID , .
— - — , , , ? , ?— , , . 3D , — , , - . , , . top-down, — . . , , , . .
— ?— . Esto también pasa.
— , , - . - . - , , , 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