Saludo, Khabrovsk. Como ya escribimos, enero está lleno de nuevos lanzamientos y hoy anunciamos un set para un nuevo curso de OTUS: "Game Developer for Unity" . En previsión del inicio del curso, compartimos con usted la traducción de material interesante.

Estamos reconstruyendo el núcleo de Unity con nuestra pila tecnológica orientada a datos . Al igual que muchos estudios de juegos, también vemos grandes ventajas en el uso del Sistema de componentes de entidad (ECS), el Sistema de tareas C # (Sistema de trabajos C #) y el Compilador de ráfagas. En Unite Copenhagen, tuvimos la oportunidad de conversar con Far North Entertainment y profundizar en cómo implementan esta funcionalidad DOTS en los proyectos tradicionales de Unity.
Far North Entertainment es un estudio sueco en copropiedad de cinco amigos de ingeniería. Desde el lanzamiento de Down to Dungeon para Gear VR a principios de 2018, la compañía ha estado trabajando en un juego que pertenece al género clásico de los juegos de PC, a saber, un juego post-apocalíptico en modo de supervivencia zombie. Lo que diferencia al proyecto de los demás es la cantidad de zombis que te persiguen. La visión del equipo a este respecto atrajo a miles de zombis hambrientos que te seguían en enormes hordas.
Sin embargo, rápidamente se encontraron con muchos problemas de rendimiento ya en la etapa de creación de prototipos. Crear, morir, actualizar y animar a todo este número de enemigos siguió siendo el principal cuello de botella, incluso después de que el equipo intentara resolver el problema con la
agrupación oblect y una
instancia de nimation .
Esto obligó al director técnico del estudio, Andrés Ericsson, a centrar su atención en DOTS y cambiar la mentalidad de orientada a objetos a orientada a datos. "La idea clave que ayudó a provocar este cambio fue que había que dejar de pensar en objetos y jerarquías de objetos y comenzar a pensar en los datos, cómo se están transformando y cómo acceder a ellos", dijo. . Sus palabras significan que no es necesario construir una arquitectura de código con la vista puesta en los objetos de la vida real de tal manera que resuelva el problema más general y abstracto. Tiene muchos consejos para aquellos que, como él, se enfrentan a un cambio en la visión del mundo:
“Pregúntese cuál es el verdadero problema que está tratando de resolver y qué datos son importantes para obtener una solución. ¿Convertirás el mismo conjunto de datos de la misma manera una y otra vez? ¿Cuántos datos útiles puede caber en una línea de la memoria caché del procesador? Si realiza cambios en el código existente, evalúe la cantidad de datos basura que agrega a la línea de caché. ¿Es posible dividir los cálculos en varios hilos o necesito usar un solo flujo de comandos?El equipo llegó a comprender que las entidades en el Sistema de componentes de Unity son solo identificadores de búsqueda en las secuencias de componentes. Los componentes son solo datos, mientras que los sistemas contienen toda la lógica y filtran entidades con una firma específica, conocida como arquetipos. “Creo que una de las ideas que nos ayudó a visualizar nuestras ideas fue introducir ECS como una base de datos SQL. Cada arquetipo es una tabla en la que cada columna es un componente, y cada fila es una entidad única. En esencia, utiliza sistemas para crear consultas para estas tablas de arquetipos y realizar operaciones en entidades ", dice Anders.
Introduciendo DOTS
Para llegar a este entendimiento, estudió la documentación para el sistema de
componentes de la
entidad , ejemplos de
ECS y
un ejemplo que hicimos junto con Nordeus y presentamos en Unite Austin. La información general sobre la arquitectura orientada a datos también fue muy útil para el equipo. "El
informe de Mike Acton sobre arquitectura centrada en datos con CppCon 2014 es exactamente lo que nos abrió los ojos a esta forma de programación".
El equipo de Far North publicó lo que aprendieron en su
Blog de desarrollo , en septiembre de este año vinieron a Copenhague para compartir sus experiencias con la transición a un enfoque orientado a datos en Unity.
Este artículo se basa en un informe, explica con más detalle los detalles de su implementación de ECS, el Sistema de tareas C # y el compilador Burst. Far North también compartió amablemente muchas muestras de código de su proyecto.
Organización de datos de zombis
"El problema al que nos enfrentamos era interpolar los desplazamientos y rotaciones de miles de objetos en el lado del cliente", dice Anders. Su enfoque inicial orientado a objetos fue crear un script
ZombieView abstracto que heredara la clase padre genérica
EntityView .
EntityView es un
MonoBehaviour conectado a un
GameObject . Actúa como una representación visual del modelo de juego. Cada
ZombieView era responsable de manejar su propia interpolación de movimiento y rotación en su función de
Actualización .
Esto suena normal, hasta que comprenda que cada entidad está ubicada en la memoria en un lugar arbitrario. Esto significa que si está accediendo a miles de objetos, la CPU debe sacarlos de la memoria uno a la vez, y esto sucede extremadamente lento. Si coloca sus datos en bloques ordenados en serie, el procesador puede almacenar en caché una gran cantidad de datos al mismo tiempo. La mayoría de los procesadores modernos pueden recibir aproximadamente 128 o 256 bits del caché en un ciclo.
El equipo decidió convertir enemigos en DOTS con la esperanza de resolver los problemas de rendimiento del lado del cliente. El primero en la línea fue la función
Actualizar en
ZombieView . El equipo determinó qué partes deberían dividirse en diferentes sistemas y determinó los datos necesarios. Lo primero y más obvio fue la interpolación de posiciones y giros, ya que el mundo del juego es una cuadrícula bidimensional. Dos variables flotantes son responsables de hacia dónde van los zombis, y el último componente es la posición de destino, rastrea la posición del servidor para el enemigo.
[Serializable] public struct PositionData2D : IComponentData { public float2 Position; } [Serializable] public struct HeadingData2D : IComponentData { public float2 Heading; } [Serializable] public struct TargetPositionData : IComponentData { public float2 TargetPosition; }
El siguiente paso fue crear un arquetipo para los enemigos. El arquetipo es un conjunto de componentes que pertenecen a una determinada entidad, en otras palabras, es la firma del componente.
El proyecto utiliza prefabricados para determinar los arquetipos, ya que los enemigos requieren más componentes, y algunos de ellos necesitan enlaces a
GameObject . Esto funciona para que pueda ajustar los datos de su componente en
ComponentDataProxy , que lo convertirá en
MonoBehaviour , que a su vez se puede adjuntar al prefab. Cuando crea una instancia con
EntityManager y pasa el prefab, crea una entidad con todos los datos de los componentes que se adjuntaron al prefab. Todos los datos de los componentes se almacenan en fragmentos de memoria de 16 kilobytes llamados
ArchetypeChunk .
Aquí hay una visualización de cómo se organizarán los flujos de componentes en nuestro fragmento de arquetipo:
"Una de las principales ventajas de los fragmentos de arquetipo es que a menudo no es necesario reasignar un montón al crear nuevos objetos, ya que la memoria ya se ha asignado de antemano. Esto significa que crear entidades es escribir datos al final de los flujos de componentes dentro de fragmentos de arquetipo. El único caso en el que es necesario volver a realizar la asignación del montón es cuando se crea una entidad que no se ajusta a los bordes del fragmento. En este caso, se iniciará la asignación de un nuevo fragmento de un arquetipo de 16 KB de tamaño, o si hay un fragmento vacío del mismo arquetipo, puede reutilizarse. Luego, los datos para los nuevos objetos se registrarán en los flujos de componentes del nuevo fragmento ”, explica Anders.
El multihilo de tus zombies
Ahora que los datos estaban densamente empaquetados y colocados en la memoria de manera conveniente para el almacenamiento en caché, el equipo podría usar fácilmente el sistema de tareas C # para ejecutar su código en varios núcleos de CPU en paralelo.
El siguiente paso fue crear un sistema que filtrara todas las entidades de todos los bloques de arquetipos que tenían
componentes PositionData2D ,
HeadingData2D y
TargetPositionData .
Para hacer esto, Anders y su equipo crearon
JobComponentSystem y construyeron su solicitud en la función
OnCreate . Se parece a esto:
private EntityQuery m_Group; protected override void OnCreate() { base.OnCreate(); var query = new EntityQueryDesc { All = new [] { ComponentType.ReadWrite<PositionData2D>(), ComponentType.ReadWrite<HeadingData2D>(), ComponentType.ReadOnly<TargetPositionData>() }, }; m_Group = GetEntityQuery(query); }
El código anuncia una solicitud que filtra todos los objetos del mundo que tienen una posición, dirección y propósito. Luego, querían programar tareas para cada marco utilizando el sistema de tareas C # para distribuir los cálculos en varios flujos de trabajo.
"Lo mejor del sistema de tareas C # es que es el mismo sistema que usa Unity en su código, por lo que no tuvimos que preocuparnos de que los hilos ejecutables se bloqueen entre sí, requieran los mismos núcleos de procesador y causen problemas de rendimiento ", Dice Anders.
El equipo decidió usar
IJobChunk , porque miles de enemigos implicaban la presencia de una gran cantidad de fragmentos de arquetipos que deberían coincidir con la solicitud en tiempo de ejecución.
IJobChunk distribuye los fragmentos correctos en varios flujos de trabajo.
Cada cuadro, una nueva tarea
UpdatePositionAndHeadingJob, es responsable de manejar la interpolación de posiciones y turnos de enemigos en el juego.
El código para programar tareas es el siguiente:
protected override JobHandle OnUpdate(JobHandle inputDeps) { var positionDataType = GetArchetypeChunkComponentType<PositionData2D>(); var headingDataType = GetArchetypeChunkComponentType<HeadingData2D>(); var targetPositionDataType = GetArchetypeChunkComponentType<TargetPositionData>(true); var updatePosAndHeadingJob = new UpdatePositionAndHeadingJob { PositionDataType = positionDataType, HeadingDataType = headingDataType, TargetPositionDataType = targetPositionDataType, DeltaTime = Time.deltaTime, RotationLerpSpeed = 2.0f, MovementLerpSpeed = 4.0f, }; return updatePosAndHeadingJob.Schedule(m_Group, inputDeps); }
Así es como se ve la tarea:
public struct UpdatePositionAndHeadingJob : IJobChunk { public ArchetypeChunkComponentType<PositionData2D> PositionDataType; public ArchetypeChunkComponentType<HeadingData2D> HeadingDataType; [ReadOnly] public ArchetypeChunkComponentType<TargetPositionData> TargetPositionDataType; [ReadOnly] public float DeltaTime; [ReadOnly] public float RotationLerpSpeed; [ReadOnly] public float MovementLerpSpeed; }
Cuando un subproceso de trabajo recupera una tarea de su cola, invoca el núcleo de la tarea.
Así es como se ve el núcleo de ejecución:
public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex) { var chunkPositionData = chunk.GetNativeArray(PositionDataType); var chunkHeadingData = chunk.GetNativeArray(HeadingDataType); var chunkTargetPositionData = chunk.GetNativeArray(TargetPositionDataType); for (int i = 0; i < chunk.Count; i++) { var target = chunkTargetPositionData[i]; var positionData = chunkPositionData[i]; var headingData = chunkHeadingData[i]; float2 toTarget = target.TargetPosition - positionData.Position; float distance = math.length(toTarget); headingData.Heading = math.select( headingData.Heading, math.lerp(headingData.Heading, math.normalize(toTarget), math.mul(DeltaTime, RotationLerpSpeed)), distance > 0.008 ); positionData.Position = math.select( target.TargetPosition, math.lerp( positionData.Position, target.TargetPosition, math.mul(DeltaTime, MovementLerpSpeed)), distance <= 1 ); chunkPositionData[i] = positionData; chunkHeadingData[i] = headingData; } }
“Puede notar que usamos select en lugar de ramificación, esto nos permite deshacernos del efecto llamado predicción de ramificación incorrecta. La función select evaluará ambas expresiones y seleccionará la que coincida con la condición, y si sus expresiones no son tan difíciles de calcular, recomendaría usar select, porque a menudo es más barato que esperar que la CPU se recupere de una predicción de rama incorrecta ". Anders
Aumenta la productividad con la explosión
El último paso para convertir DOTS a la posición enemiga y la interpolación de rumbo es habilitar el compilador Burst. La tarea le pareció bastante simple a Anders: "Dado que los datos se encuentran en matrices adyacentes y dado que utilizamos la nueva biblioteca de matemáticas de Unity, todo lo que tuvimos que hacer fue agregar el atributo
BurstCompile a nuestra tarea".
[BurstCompile] public struct UpdatePositionAndHeadingJob : IJobChunk { public ArchetypeChunkComponentType<PositionData2D> PositionDataType; public ArchetypeChunkComponentType<HeadingData2D> HeadingDataType; [ReadOnly] public ArchetypeChunkComponentType<TargetPositionData> TargetPositionDataType; [ReadOnly] public float DeltaTime; [ReadOnly] public float RotationLerpSpeed; [ReadOnly] public float MovementLerpSpeed; }
El compilador Burst nos proporciona datos múltiples de instrucción única (SIMD); instrucciones de la máquina que pueden funcionar con múltiples conjuntos de datos de entrada y crear múltiples conjuntos de datos de salida con una sola instrucción. Esto nos ayuda a llenar más lugares en el bus de caché de 128 bits con los datos correctos. El compilador Burst, combinado con un sistema de trabajo y composición de datos amigable con la caché, permitió al equipo aumentar significativamente la productividad. Aquí está la tabla que compilaron midiendo el rendimiento después de cada paso de conversión.

Esto significó que Far North se deshizo por completo de los problemas asociados con la interpolación de la posición en el lado del cliente y la dirección de los zombies. Sus datos ahora se almacenan en una forma conveniente para el almacenamiento en caché, y las líneas de caché se llenan solo con datos útiles. La carga se distribuye a todos los núcleos de la CPU, y el compilador Burst produce un código de máquina altamente optimizado con instrucciones SIMD.
Far North Entertainment DOTS Consejos y trucos
- Comience a pensar en términos de flujos de datos, porque en ECS, las entidades son simplemente índices de búsqueda en flujos de datos de componentes paralelos.
- Imagine ECS como una base de datos relacional en la que los arquetipos son tablas, los componentes son columnas y las entidades son índices en una tabla (fila).
- Organice sus datos en arreglos secuenciales para usar la memoria caché del procesador y la captación previa de hardware.
- Olvídate de querer crear jerarquías de objetos y tratar de encontrar una solución común antes de comprender el problema real que estás tratando de resolver.
- Piensa en la recolección de basura. Evite la asignación excesiva de montones en áreas críticas para el rendimiento. Utilice los nuevos contenedores nativos de Unity en su lugar. Pero tenga cuidado, debe lidiar con la limpieza manual.
- Reconozca el valor de sus abstracciones, tenga cuidado con la sobrecarga de invocar funciones virtuales.
- Use todos los núcleos de CPU con el sistema de tareas C #.
- Analizar el nivel de hardware. ¿El compilador Burst realmente genera instrucciones SIMD? Use el Inspector de ráfaga para el análisis.
- Deje de desperdiciar líneas de caché en vacío. Piense en empaquetar datos en líneas de caché como empaquetar datos en paquetes UDP.
El consejo principal que Anders Ericsson quiere compartir es un consejo más general para aquellos cuyo proyecto ya está en desarrollo:
"Trata de identificar áreas específicas en tu juego donde tengas problemas de rendimiento y ve si puedes aplicar DOTS específicamente en Esta zona aislada. ¡No necesita cambiar toda la base de código! ”Planes futuros
"Queremos usar DOTS en otras áreas de nuestro juego, y nos quedamos encantados con los anuncios en Unite sobre animaciones DOTS, Unity Physics y Live Link. Nos gustaría aprender cómo convertir más objetos del juego en objetos ECS, y parece que Unity ha logrado un progreso significativo en la implementación de esto ”, concluye Anders.
Si tiene preguntas adicionales para el equipo de Far North, le recomendamos que se una a su
Discord .
Echa un vistazo a la lista de reproducción
Unite Copenhagen DOTS para ver cómo otros estudios de juegos modernos usan DOTS para crear grandes juegos de alto rendimiento, y cómo los componentes basados en DOTS como DOTS Physics, el nuevo Conversion Workflow y el compilador Burst trabajan juntos.
La traducción ha llegado a su fin, y te
invitamos a asistir a un seminario web gratuito , en el que te diremos cómo crear tu propio zombie shooter en una hora .