Resumen de habilidades prácticas de DOTS

[Columna USparkle] Si tiene grandes habilidades, le encanta "investigar un poco", está dispuesto a compartir y aprender de los demás, esperamos que se una, deje que las chispas de la sabiduría choquen y se entrelacen, y permita que la transmisión del conocimiento sea ¡sin fin!

Con el desarrollo de la tecnología, la tecnología del lado del cliente también presta cada vez más atención al alto rendimiento. Después de todo, el juego de alto grado de libertad en el gran mundo se acerca. Bajo esta tendencia, Unity también dio origen a DOTS. , una solución técnica de alto rendimiento que presta atención a Los que son altamente paralelos y amigables con el caché. La versión actual 1.0 de DOTS todavía está en vísperas de la versión oficial. Aunque hay muchas deficiencias, su uso y sus ideas de desarrollo se han determinado básicamente. Además de admitir más funciones de Unity en la futura versión oficial, las ideas de desarrollo no cambiar demasiado.

1. Transferencia de datos entre sistemas

Según el marco de código de ECS, la lógica del código solo se puede escribir en System. En el proceso de desarrollo real, hay muchos escenarios en los que se requiere que el sistema A notifique al sistema B que haga algo.

En el desarrollo anterior del programa Unity, el grado de libertad del código es muy alto. Hay muchas formas de darse cuenta de que el sistema A puede notificar al sistema B que haga algo. La forma más sencilla es implementarlo mediante una función de devolución de llamada, o se puede implementar de manera más elegante implementando un sistema de mensajes a través del modo observador.

Pero DOTS tiene más restricciones. En primer lugar, la función de devolución de llamada no se puede utilizar porque la compilación Burst no admite Delegate. En segundo lugar, el modo de observador no es adecuado para el marco ECS, porque la lógica del marco ECS está totalmente orientada a datos, divide los datos en grupos y llama y procesa lote por lote. En otras palabras, el modo de observador es una llamada activada y la lógica del marco ECS es una llamada circular.

Una forma de pensar es definir un miembro NativeList en ISystem para recibir los datos del mensaje pasados ​​​​al ISystem desde el exterior y luego extraer los datos del mensaje uno por uno en la función OnUpdate para su procesamiento. Pero se encontrarán los siguientes problemas.

Pregunta 1, si esta NativeList se define como miembro de ISystem, otros ISystems no pueden acceder al objeto de este ISystem en su propia función OnUpdate, solo pueden acceder a su SystemHandle correspondiente. Entonces, ¿es posible definir NaitveList como una variable estática? Esto lleva a las preguntas dos y tres.

Por supuesto, también puede llamar a EntityManager.AddComponent (sistema SystemHandle, ComponentType componenteType) para agregar componentes al sistema, y ​​luego otros ISystems pueden acceder a los componentes de este sistema para lograr el propósito de transmisión de mensajes, pero este método solo puede transmitir el datos de un solo componente en primer lugar, los datos no son aplicables si la cantidad aumenta; en segundo lugar, este método no pertenece a la técnica de este artículo. Este es el método estándar oficial de Unity Entities 1.0. Hay suficientes documentos para describir cómo operar, así que no entraré en detalles aquí.

Pregunta 2, la función OnUpdate de ISystem solo puede acceder a la variable contenedora estática modificada por solo lectura. Si no modifica NativeList con solo lectura y accede a él en OnUpdate, recibirá un mensaje de error.

Burst error BC1042: The managed class type Unity.Collections.NativeList1<XXX>* is not supported. Loading from a non-readonly static field XXXSystem.xxx` is not supported

Pregunta 3, si modifica la NativeList estática con solo lectura, debe inicializar la variable al definirla, luego recibirá el siguiente mensaje de error.

(0,0): Burst error BC1091: External and internal calls are not allowed inside static constructors: Unity.Collections.LowLevel.Unsafe.AtomicSafetyHandle.Create_Injected(ref Unity.Collections.LowLevel.Unsafe.AtomicSafetyHandle ret)

Entonces esta idea de usar NativeList no funciona. A continuación se muestran algunas técnicas prácticas exploradas en el proyecto.

1.1 Empaquetar datos en entidades para su transmisión
Ahora cambie su forma de pensar, primero cree una Entidad, organice la información que se transmitirá en IComponentData y vincúlela a la Entidad para formar entidades de mensaje una por una. Otros ISystems pueden atravesar estas entidades de mensaje para realizar la transmisión de datos entre entidades función.

Este enfoque se aplica no sólo a la transferencia de datos entre ISystem e ISystem, sino incluso a la transferencia de datos entre MonoBehaviour e ISystem, y entre ISystem y SystemBase.

El siguiente es un ejemplo específico, que define un MonoBehaviour para vincularse al botón de UGUI, y se llamará a la función OnClick cuando se haga clic en el botón.

public struct ClickComponent : IComponentData
{
    public int id;
}

public class UITest : MonoBehaviour
{
    public void OnClick()
    {
        World world = World.DefaultGameObjectInjectionWorld;
        EntityManager dstManager = world.EntityManager;

        // 每次点击按钮都创建一个Entity
        Entity e = dstManager.CreateEntity();
        dstManager.AddComponentData(e, new ClickComponent()
        {
            id = 1
        });
    }
}

Las líneas 14 a 18 del código sirven para empaquetar el mensaje que se entregará (id = 1) en una Entidad y enviarlo al mundo predeterminado.

A continuación se muestra el código para recibir el mensaje de clic en el botón en otro ISystem.

public partial struct TestJob : IJobEntity
{
    public Entity managerEntity;
    public EntityCommandBuffer ecb;

    [BurstCompile]
    void Execute(Entity entity, in ClickComponent c)
    {
        // TODO...
        UnityEngine.Debug.Log("接收到按钮点击的消息");

        ecb.DestroyEntity(entity);
    }
}

[BurstCompile]
public partial struct OtherSystem : ISystem
{
    void OnUpdate(ref SystemState state)
    {
        var ecbSingleton = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>();
        EntityCommandBuffer ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);
        Entity managerEntity = SystemAPI.GetSingletonEntity<CharacterManager>();

        TestJob job = new TestJob()
        {
            managerEntity = managerEntity,
            ecb = ecb
        };
        job.Schedule();
    }
}

La línea 7 del código, dentro de la función Ejecutar de IJobEntity, accede a todos los mensajes de clic de botón. A través de la llamada a Job en la línea 30 del código, se realiza la transmisión de mensajes de MonoBehaviour a ISystem.

En la línea 12 del código, el mensaje procesado se elimina llamando a ecb.DestroyEntity.

Los siguientes son los resultados de la operación.

Todas las funciones se han realizado aquí, pero si usted es un desarrollador que necesita mirar la ventana Jerarquía de entidades para depurar el código todo el tiempo, este método hará que su ventana parpadee continuamente y será imposible observar las propiedades de la Entidad en la interfaz del motor. Entonces, ¿hay alguna otra manera?

1.2 Utilice DynamicBuffer para recibir datos
Este enfoque es similar a la idea de utilizar NativeList, pero aquí se utiliza DynamicBuffer en lugar de NativeList.

Continúe escribiendo el código siguiendo el ejemplo de la Sección 1.1. Lo que queremos lograr aquí es hacer clic en el botón de UGUI para crear un rol, es decir, crear un sistema de roles para recibir los datos para crear mensajes.

public struct CreateCharacterRequest : IBufferElementData
{
    public int objID;
}

public struct CharacterManager : IComponentData { }

[BurstCompile]
public partial struct CharacterSystem : ISystem
{
    [BurstCompile]
    void OnCreate(ref SystemState state)
    {
        // 创建一个管理器的Entity来管理所有请求
        Entity managerEntity = state.EntityManager.CreateEntity();
        // 创建一个TagComponent来获取管理器的Entity
        state.EntityManager.AddComponentData(managerEntity, new CharacterManager());
        // 创建一个DynamicBuffer来接收创建请求
        state.EntityManager.AddBuffer<CreateCharacterRequest>(managerEntity);

        state.EntityManager.SetName(managerEntity, "CharacterManager");
    }

    [BurstCompile]
    void OnUpdate(ref SystemState state)
    {
        DynamicBuffer<CreateCharacterRequest> buffer = SystemAPI.GetSingletonBuffer<CreateCharacterRequest>();

        for (int i = 0; i < buffer.Length; ++i)
        {
            CreateCharacterRequest request = buffer[i];

            // TODO...
            Debug.Log("创建一个角色...");
        }
        buffer.Clear();
    }

    /// <summary>
    /// 请求创建角色(工作线程/主线程)
    /// </summary>
    /// <param name="request">请求数据</param>
    /// <param name="manager">通过SystemAPI.GetSingletonEntity<EntityManager>()获取</param>
    /// <param name="ecb">ECB</param>
    public static void RequestCreateCharacter(in CreateCharacterRequest request, Entity manager, EntityCommandBuffer ecb)
    {
        ecb.AppendToBuffer(manager, request);
    }

    /// <summary>
    /// 请求创建角色(并行工作线程)
    /// </summary>
    /// <param name="request">请求数据</param>
    /// <param name="manager">通过SystemAPI.GetSingletonEntity<EntityManager>()获取</param>
    /// <param name="ecb">ECB</param>
    public void RequestCreateCharacter(in CreateCharacterRequest request, Entity manager, EntityCommandBuffer.ParallelWriter ecb)
    {
        ecb.AppendToBuffer(0, manager, request);
    }
}

La función OnCreate en la línea 12 del código crea una entidad administradora y hay un DynamicBuffer en esta entidad para guardar los datos de la solicitud de otros sistemas.

La línea 29 del código atraviesa todos los datos de este DynamicBuffer a través de un bucle for, procesa los datos en la línea 34 del código y ahora simplemente imprime una oración.

La línea 36 del código elimina los datos procesados ​​del DynamicBuffer llamando a buffer.Clear().

Las líneas 45 y 56 del código definen dos funciones llamadas RequestCreateCharacter para que las llamen otros ISystems. El segundo parámetro, Entity manager, es especial y requiere que otros ISystems llamen a SystemAPI.GetSingletonEntity() en la función OnUpdate del hilo principal. La diferencia entre estas dos funciones es el tercer parámetro, el primero se pasa en EntityCommandBuffer, el segundo se pasa en EntityCommandBuffer.ParallelWriter, es decir, la primera función se usa para el hilo principal y se ejecuta llamando a la función Schedule Trabajo, la segunda función es para el Trabajo ejecutado llamando a la función ScheduleParallel.

Revise la diferencia entre Ejecutar, Programar y ProgramarParalelo.

  1. Ejecutar: ejecutar bajo el hilo principal.
  2. Programación: se ejecuta bajo el subproceso de trabajo y el mismo trabajo solo se puede ejecutar bajo el mismo subproceso de trabajo.
  3. ScheduleParallel: al ejecutarse bajo el subproceso de trabajo, los datos del mismo trabajo y diferentes fragmentos se asignarán a diferentes subprocesos de trabajo para su ejecución, pero existen muchas restricciones, como no poder escribir en el contenedor asignado en el subproceso principal, etc. .

Echemos un vistazo a cómo enviar un mensaje a CharacterSystem y solicitar la creación de un personaje. Volvamos al código escrito en la Sección 1.1.

public partial struct TestJob : IJobEntity
{
    public Entity managerEntity;
    public EntityCommandBuffer ecb;

    [BurstCompile]
    void Execute(Entity entity, in ClickComponent c)
    {
        CharacterSystem.RequestCreateCharacter(new CreateCharacterRequest()
        {
            objID = c.id
        }, managerEntity, ecb);

        ecb.DestroyEntity(entity);
    }
}

[BurstCompile]
public partial struct OtherSystem : ISystem
{
    void OnUpdate(ref SystemState state)
    {
        var ecbSingleton = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>();
        EntityCommandBuffer ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);
        Entity managerEntity = SystemAPI.GetSingletonEntity<CharacterManager>();

        TestJob job = new TestJob()
        {
            managerEntity = managerEntity,
            ecb = ecb
        };
        job.Schedule();
    }
}

La línea 25 del código obtiene la entidad del administrador llamando a SystemAPI.GetSingletonEntity() y la pasa a la función RequestCreateCharacter en la línea 12 del código.

La línea 9 del código pasa los datos de tipo CreateCharacterRequest a CharacterSystem llamando a la función RequestCreateCharacter.

Los resultados de ejecución son los siguientes.

De esta forma, se utilizan dos métodos para realizar la transferencia de datos entre Sistemas, e incluso entre MonoBehaviour.

Segundo, simular polimorfismo.

El diseño orientado a datos es mucho más difícil que el diseño orientado a objetos. Después de todo, la intención original del diseño orientado a objetos es reducir la dificultad de pensar en el desarrollo y el diseño abstrayendo todo el mundo en objetos. La intención original de la orientación a datos no es reducir la dificultad de pensar, sino hacer que la ejecución sea más eficiente.

Entonces, ¿se puede combinar la eficiencia orientada a datos y los "tontos" orientados a objetos?

El mayor obstáculo aquí es que todos los datos en DOTS usan estructura, y la estructura en sí no admite herencia ni polimorfismo. En muchos casos de diseño de marco, esta característica limitará en gran medida al diseñador.

Por supuesto, pensará que esa interfaz también se puede usar, de modo que la clase de administración pueda administrar diferentes tipos de datos de estructura a través de la interfaz, pero DOTS no reconoce la interfaz, porque DOTS requiere tipos de valores y la interfaz no puede indicar si es una tipo de valor o un tipo de referencia de. En la práctica sabrás que esta idea no es factible.

Por ejemplo, para crear un sistema de máquina de estados, sus unidades constituyentes son estados uno por uno. Según el pensamiento OOD, debe haber una clase base de estado, y luego cada clase de estado específica debe heredar de esta clase base para escribir e implementar. . Un diseño tan simple será muy difícil en el Departamento de Defensa.

A continuación se muestra una técnica que se exploró durante el transcurso del proyecto.

Si está familiarizado con C ++, sabrá que existe una unión, por supuesto, C # también tiene una existencia similar, es decir, StructLayout y FieldOffset. Al utilizar este tipo de diseño preciso u operación de unión, la herencia de clases se puede lograr de forma disfrazada con el menor consumo posible.

A continuación se muestra la definición de la clase base estatal.

using System.Runtime.InteropServices;
using Unity.Entities;

// 状态枚举
public enum StateType
{
    None,
    Idle,
    Chase,
    CastSkill,
    Dead,
}

/// <summary>
/// 所有状态实现类都需要继承自IBaseState
/// </summary>
public interface IBaseState
{
    void OnEnter(Entity entity, ref StateComponent self, ref StateHelper helper);

    void OnUpdate(Entity entity, ref StateComponent self, ref StateHelper helper);

    void OnExit(Entity entity, ref StateComponent self, ref StateHelper helper);
}

/// <summary>
/// 存放状态子类用的组件
/// </summary>
[Serializable]
[StructLayout(LayoutKind.Explicit)]
public struct StateComponent : IBufferElementData
{
    [FieldOffset(0)]
    public StateType stateType;

    [FieldOffset(4)]
    public int id;

    [FieldOffset(8)]
    public NoneState noneState;
    [FieldOffset(8)]
    public IdleState idleState;
    [FieldOffset(8)]
    public ChaseState chaseState;

    public void OnEnter(Entity entity, ref StateComponent self, ref StateHelper helper)
    {
        switch (stateType)
        {
            case StateType.None:
                noneState.OnEnter(entity, ref self, ref helper);
                break;
            case StateType.Idle:
                idleState.OnEnter(entity, ref self, ref helper);
                break;
            case StateType.Chase:
                chaseState.OnEnter(entity, ref self, ref helper);
                break;
        }
    }

    public void OnUpdate(Entity entity, ref StateComponent self, ref StateHelper helper)
    {
        switch (stateType)
        {
            case StateType.None:
                noneState.OnUpdate(entity, ref self, ref helper);
                break;
            case StateType.Idle:
                idleState.OnUpdate(entity, ref self, ref helper);
                break;
            case StateType.Chase:
                chaseState.OnUpdate(entity, ref self, ref helper);
                break;
        }
    }

    public void OnExit(Entity entity, ref StateComponent self, ref StateHelper helper)
    {
        switch (stateType)
        {
            case StateType.None:
                noneState.OnExit(entity, ref self, ref helper);
                break;
            case StateType.Idle:
                idleState.OnExit(entity, ref self, ref helper);
                break;
            case StateType.Chase:
                chaseState.OnExit(entity, ref self, ref helper);
                break;
        }
    }
}

Las líneas 29 a 31 del código definen una estructura llamada StateComponent, que utiliza las dos etiquetas StructLayout y FieldOffset mencionadas anteriormente para controlar el diseño de la memoria.

La línea 34 del código define el stateType miembro del tipo StateType, que indica qué objeto de la clase de implementación se almacena en la estructura StateComponent. Por ejemplo, si el valor de stateType es StateType.Chase, entonces el objeto ChaseState en la línea 44 del código se inicializa y se llena con valores, mientras que el objeto noneState en la línea 40 del código y el objeto idleState en la línea 42 del código no están inicializados.

A partir de la implementación de la función OnEnter en la línea 46 del código, la función OnUpdate en la línea 62 del código y la función OnExit en la línea 78 del código, podemos saber que el valor de stateType determina el objeto que la estructura StateComponent En realidad tiene efecto, es decir, la función OnEnter de StateComponent se llama externamente, la función OnUpdate y la función OnExit, activará la llamada a la función OnEnter, la función OnUpdate y la función OnExit de la subclase correspondiente a IBaseState.

Se puede utilizar un DynamicBuffer para gestionar el estado del personaje, como se muestra a continuación.

Entity characterEntity = GetCharacter();
state.EntityManager.AddBuffer<StateComponent>(characterEntity);

De esta forma se simula el polimorfismo orientado a objetos.

Echemos un vistazo más de cerca a la implementación de la estructura ChaseState para tener una comprensión más completa.

using Unity.Entities;
using Unity.Mathematics;

public struct ChaseState : IBaseState
{
    public Entity target;
    public float duration;

    private float endTime;

    public void OnEnter(Entity entity, ref StateComponent self, ref StateHelper helper)
    {
        endTime = helper.elapsedTime + duration;
    }

    public void OnExit(Entity entity, ref StateComponent self, ref StateHelper helper)
    {

    }

    public void OnUpdate(Entity entity, ref StateComponent self, ref StateHelper helper)
    {
        if (helper.elapsedTime >= endTime)
        {
            // 跳转到下一个状态
            return;
        }
        if (!helper.localTransforms.TryGetComponent(target, out var targetTrans))
        {
            return;
        }
        if (!helper.localTransforms.TryGetComponent(entity, out var selfTrans))
        {
            return;
        }
        float3 dir = math.normalizesafe(targetTrans.Position - selfTrans.Position);
        selfTrans.Position = selfTrans.Position + dir * helper.deltaTime;
        helper.localTransforms[entity] = selfTrans;
    }
}

Se puede ver en el código que este es un estado de persecución del objetivo y la lógica es muy simple. Se determina un tiempo de persecución en la función OnEnter, y OnUpdate verifica el tiempo de persecución y, si excede la duración del estado de persecución, salta al siguiente estado. Entre ellos, StateHelper guarda algunos datos recopilados en la función OnUpdate del hilo principal, incluidas varias búsquedas de componentes.

Con este polimorfismo simulado, se pueden aplicar muchos patrones de diseño orientado a objetos al diseño orientado a datos. Sin embargo, este enfoque tiene dos inconvenientes importantes que es necesario señalar:

  • Dado que se utiliza una estructura similar a una unión para reorganizar la memoria, la huella de memoria de este componente (es decir, el StateComponent mencionado anteriormente) será igual a la huella de la clase más grande entre estas clases de implementación.
  • La cantidad de código que se escribirá para extender la nueva clase de implementación aumenta. Se recomienda escribir un editor que genere código para ayudar a extender la implementación.

3. Depuración de rendimiento

Esta sección es una parte relativamente básica y presenta principalmente algunos consejos que son diferentes de la depuración anterior. Dado que el desarrollo anterior escribía principalmente lógica en el hilo principal, DOTS usa Job, por lo que se distribuirá mucha lógica principal a cada hilo de trabajo (Work Thread).

3.1 Cómo usar Profiler
Cómo Profiler depura el editor y cómo depurar la máquina real, el método es similar al desarrollo anterior, por lo que no entraré en detalles, la diferencia es el análisis de subprocesos múltiples, lo presentaremos brevemente a continuación .

Escriba dos trabajos simples a continuación y observe su desempeño.

public struct Test1Component : IBufferElementData
{
    public int id;
    public int count;
}

[UpdateAfter(typeof(Test2System))]
public partial class Test1System : SystemBase
{
    protected override void OnCreate()
    {
        Entity managerEntity = EntityManager.CreateEntity();
        EntityManager.AddBuffer<Test1Component>(managerEntity);
    }

    protected override void OnUpdate()
    {
        // 创建容器,从工作线程里面取数据
        NativeList<Test1Component> list = new NativeList<Test1Component>(Allocator.TempJob);

        // 遍历DynamicBuffer里的所有元素,并传递给主线程的list,用来在主线程里打印日志
        Dependency = Entities
            .WithName("Test1Job")
            .ForEach((in DynamicBuffer<Test1Component> buffer) =>
            {
                for (int i = 0; i < buffer.Length; ++i)
                {
                    list.Add(buffer[i]);
                }
            }).Schedule(Dependency);

        // 等待工作线程结束
        CompleteDependency();

        // 主线程里打印所有元素
        for (int i = 0; i < list.Length; ++i)
        {
            Test1Component c = list[i];
            Debug.Log("element:" + c.id);
        }
        // 释放容器
        list.Dispose();
    }
}

Las líneas 7 a 8 del código definen un sistema llamado Test1System y el orden de ejecución es inmediatamente después del sistema llamado Test2System.

La línea 19 del código crea una NativeList, la pasa al hilo de trabajo y recopila todos los elementos en DynamicBuffer en el hilo de trabajo.

Revisión: tenga en cuenta aquí que el asignador pasado al constructor del contenedor debe ser Allocator.TempJob, porque es necesario acceder a este contenedor en el trabajo. Al final del marco, recuerde esperar a que el trabajo termine de ejecutarse y desechar el contenedor. . (Por supuesto, también puede usar WithDisposeOnCompletion en la expresión Lambda para liberarlo inmediatamente después de ejecutar Lambda, pero debido a que el hilo principal aún necesita usarlo, el lanzamiento se retrasa).

Las líneas 22 a 30 del código son un trabajo simple que transfiere todos los elementos en DynamicBuffer a NativeList.

Para distinguir mejor el trabajo en Profiler, es mejor usar la función WithName para darle un nombre al trabajo.

La línea 33 del código llama a la función CompleteDependency y espera a que finalice la ejecución del trabajo del subproceso de trabajo en este marco antes de continuar ejecutando el código restante de OnUpdate.

Después de que la línea 36 del código se ejecute en el hilo principal, que es el mismo que el desarrollo anterior y no se describirá en detalle.

public partial class Test2System : SystemBase
{
    public static int index;

    protected override void OnUpdate()
    {
        int id = ++index;

        Entities
            .WithName("Test2Job")
            .ForEach((ref DynamicBuffer<Test1Component> buffer) =>
            {
                // 往DynamicBuffer里面加元素
                buffer.Add(new Test1Component()
                {
                    id = id
                });

                // 下面的代码单纯为了增加性能消耗
                for (int i = 0; i < buffer.Length; ++i)
                {
                    var c = buffer[i];
                    c.count = buffer.Length;
                    for (int j = 0; j < 10000; ++j)
                    {
                        c.count = buffer.Length + j;
                    }
                }
            }).Schedule();
    }
}

Desde la línea 9 hasta la línea 29 del código, se define un sistema llamado Test2System, en este sistema se implementa un trabajo llamado "Test2Job", este trabajo no tiene otras funciones, principalmente aumentar el consumo de rendimiento.

Se ejecuta lo siguiente, echemos un vistazo al consumo de estos trabajos.

Si observa directamente el consumo de rendimiento del hilo principal según la experiencia de depuración anterior, encontrará que Test1System en realidad representa el 91,5%, lo que obviamente no es razonable desde la perspectiva de la lógica del código. En este momento, debes abrir la línea de tiempo para echar un vistazo.

En la captura de pantalla, puede ver la siguiente información:

  • Test2Job se ejecuta antes que Test1Job porque Test1System agrega la etiqueta [UpdateAfter(typeof(Test2System))].
  • El consumo principal de Test1Job es JobHandle.Complete, que está esperando el final del hilo de trabajo de este marco.
  • El consumo real se refleja en la línea de tiempo del hilo de trabajo "Trabajador 0", que son principalmente las decenas de miles de ciclos en Test2Job escritos en el código anterior.

Haga clic en el bloque Test2Job en la línea de tiempo y aparecerá un cuadro emergente. Haga clic en "Mostrar> Jerarquía" en el cuadro de mensaje para ver el consumo específico de este trabajo.

Tenga en cuenta que si desea utilizar puntos de interrupción o funciones Profiler.BeginSample / Profiler.EndSample, el trabajo debe llamar a la función WithoutBurst para evitar el uso de la compilación Burst.

Por lo tanto, cuando se utiliza el análisis de Profiler en DOTS, no solo se debe observar el consumo del hilo principal, sino también el consumo de los hilos de trabajo y la situación de espera del hilo principal, y habrá más factores para el análisis de referencia. .

3.2 Consumo de Búsqueda
Al comienzo de la sección, permítanme explicar a qué se refiere la Búsqueda en el título de la sección. En el marco ECS, si necesita acceder a Component o DynamicBuffer a través de Entity, el funcionario proporciona un conjunto de estructuras, que son ComponentLookup y BufferLookup (la versión anterior de DOTS se llama ComponentDataFromEntity y BufferFromEntity). En este artículo, estas estructuras para el acceso aleatorio a componentes se denominan colectivamente Búsqueda.

En el desarrollo anterior de Unity, era muy sencillo acceder a un MonoBehaviour en un GameObject, al que se podía acceder llamando a gameObject.GetComponent, pero es más difícil en el marco ECS.

Supongamos que queremos escribir una función: crear 30 bolitas y 200 cuadrados, la bolita encontrará los dos cuadrados más cercanos a sí misma y rebotará hacia adelante y hacia atrás entre estos dos cuadrados. Primero escribamos un fragmento de código para ver la situación de Profiler.

protected override void OnUpdate()
{
    float deltaTime = SystemAPI.Time.DeltaTime;
    ComponentLookup<LocalTransform> transforms = SystemAPI.GetComponentLookup<LocalTransform>();

    // 查询所有方块的实体
    NativeArray<Entity> entities = entitiesQuery.ToEntityArray(Allocator.TempJob);

    Entities
        .WithoutBurst()
        .WithName("Test2Job")
        .ForEach((/*小球的实体*/Entity entity, int entityInQueryIndex, /*小球的移动组件*/ref BulletMove move) =>
        {
            float minDist = float.MaxValue;
            LocalTransform targetTransform = default(LocalTransform);

            // 遍历所有方块,查找离小球最近的方块并靠近它
            for (int i = 0; i < entities.Length; ++i)
            {
                Entity targetEntity = entities[i];
                // 上次靠近过的方块先排除掉
                if (move.lastEntity == targetEntity)
                {
                    continue;
                }
                // 通过Lookup获取小球的位置
                if (!transforms.TryGetComponent(entity, out var selfT))
                {
                    continue;
                }
                // 通过Lookup获取方块的位置
                if (!transforms.TryGetComponent(targetEntity, out var targetT))
                {
                    continue;
                }
                float distance = math.distance(targetT.Position, selfT.Position);
                // 找到离小球最近的方块
                if (minDist > distance)
                {
                    minDist = distance;
                    move.targetEntity = targetEntity;
                    targetTransform = targetT;
                }
            }

            if (!transforms.TryGetComponent(entity, out var t))
            {
                return;
            }

            // 朝着离小球最近的方块靠近
            float3 dir = targetTransform.Position - t.Position;
            float3 n = math.normalizesafe(dir);
            t.Position = t.Position + n * deltaTime;

            // 到达离小球最近的方块附近,记录该方块,下一帧开始将不再朝着这个方块靠近
            if (math.length(dir) <= 0.5f)
            {
                move.lastEntity = move.targetEntity;
            }

            transforms[entity] = t;
        }).Schedule();
}

El código anterior es bastante simple y no explicaré demasiado su lógica. Cabe señalar que las líneas 27 y 28 del código llaman a la función TryGetComponent de ComponentLookup dos veces respectivamente. En este ejemplo, hay 200 cuadrados, es decir digamos, cada bola pequeña La función TryGetComponent se llamará 400 veces. El consumo es el siguiente.

Como se puede ver en la figura anterior, solo 400 llamadas por bola cuestan 2,89 ms, lo que supone una gran sobrecarga. Luego, intentemos optimizarlo y cambiar las 400 llamadas a la función TryGetComponent por bola a 200 llamadas por bola.

protected override void OnUpdate()
{
    float deltaTime = SystemAPI.Time.DeltaTime;
    ComponentLookup<LocalTransform> transforms = SystemAPI.GetComponentLookup<LocalTransform>();

    // 查询所有方块的实体
    NativeArray<Entity> entities = entitiesQuery.ToEntityArray(Allocator.TempJob);

    Entities
        .WithoutBurst()
        .WithName("Test2Job")
        .ForEach((/*小球的实体*/Entity entity, int entityInQueryIndex, /*小球的移动组件*/ref BulletMove move) =>
        {
            // 通过Lookup获取小球的位置
            if (!transforms.TryGetComponent(entity, out var t))
            {
                return;
            }

            float minDist = float.MaxValue;
            LocalTransform targetTransform = default(LocalTransform);

            // 遍历所有方块,查找离小球最近的方块并靠近它
            for (int i = 0; i < entities.Length; ++i)
            {
                Entity targetEntity = entities[i];
                // 上次靠近过的方块先排除掉
                if (move.lastEntity == targetEntity)
                {
                    continue;
                }
                // 通过Lookup获取方块的位置
                if (!transforms.TryGetComponent(targetEntity, out var targetT))
                {
                    continue;
                }
                float distance = math.distance(targetT.Position, t.Position);
                // 找到离小球最近的方块
                if (minDist > distance)
                {
                    minDist = distance;
                    move.targetEntity = targetEntity;
                    targetTransform = targetT;
                }
            }

            // 朝着离小球最近的方块靠近
            float3 dir = targetTransform.Position - t.Position;
            float3 n = math.normalizesafe(dir);
            t.Position = t.Position + n * deltaTime;

            // 到达离小球最近的方块附近,记录该方块,下一帧开始将不再朝着这个方块靠近
            if (math.length(dir) <= 0.5f)
            {
                move.lastEntity = move.targetEntity;
            }

            transforms[entity] = t;
        }).Schedule();
}

La línea 15 del código está al comienzo de toda la lógica del Trabajo. Primero, la posición de la bola se obtiene llamando a la función TryGetComponent, para evitar obtener la posición de la bola cada vez que se usa el bucle for. Una vez optimizado el código de esta manera, el número de llamadas a la función TryGetComponent se reduce a la mitad y el consumo es el siguiente.

Como se puede ver en la figura anterior, el consumo de Job se ha reducido de 2,89 ms a 1,31 ms, lo que supone una mejora obvia en el rendimiento.

Según la optimización anterior y una reflexión más profunda, si la posición del bloque actual se registró antes de llamar al trabajo y se guardó en una estructura IComponentData llamada Target, ¿no es necesario llamar a la función TryGetComponent en todo el trabajo? A continuación se muestra el código implementado en base a esta idea.

protected override void OnUpdate()
{
    float deltaTime = SystemAPI.Time.DeltaTime;

    // 查询所有方块的Target组件
    NativeArray<Target> entities = entitiesQuery.ToComponentDataArray<Target>(Allocator.TempJob);

    Entities
        .WithoutBurst()
        .WithName("Test2Job")
        .ForEach((/*小球的实体*/Entity entity, int entityInQueryIndex, /*小球的移动组件*/ref BulletMove move, /*小球的位置组件*/ref LocalTransform t) =>
        {
            float minDist = float.MaxValue;
            int targetID = 0;
            float3 targetPos = float3.zero;

            // 遍历所有方块,查找离小球最近的方块并靠近它
            for (int i = 0; i < entities.Length; ++i)
            {
                Target target = entities[i];
                // 上次靠近过的方块先排除掉
                if (move.lastID == target.id)
                {
                    continue;
                }
                float distance = math.distance(target.position, t.Position);
                // 找到离小球最近的方块
                if (minDist > distance)
                {
                    minDist = distance;
                    targetID = target.id;
                    targetPos = target.position;
                }
            }

            // 朝着离小球最近的方块靠近
            float3 dir = targetPos - t.Position;
            float3 n = math.normalizesafe(dir);
            t.Position = t.Position + n * deltaTime;

            // 到达离小球最近的方块附近,记录该方块,下一帧开始将不再朝着这个方块靠近
            if (math.length(dir) <= 0.5f)
            {
                move.lastID = targetID;
            }
        }).Schedule();
}

Se puede ver en el código que la llamada a la función TryGetComponent está completamente cancelada, echemos un vistazo al consumo del código implementado de esta manera.

¡No es sorprendente que la eficiencia del código haya mejorado nuevamente!

Si tiene más cuidado, encontrará que el consumo de estos tres códigos se muestra en azul en la línea de tiempo, esto es para facilitar la depuración y la compilación Burst está desactivada. Entonces, ¿qué pasará cuando activemos la compilación Burst del último código modificado?

Se puede ver que cuando se activa la compilación en ráfaga, el consumo de trabajo en la línea de tiempo se vuelve verde y el consumo vuelve a disminuir, de 0,718 ms a 0,045 ms.

Los experimentos anteriores demuestran que:

  • La búsqueda no es eficiente en el caso de una gran cantidad de cálculos de entidades, razón por la cual DOTS no recomienda una gran cantidad de usos. A juzgar por el código fuente de Entidades, la función TryGetComponent es un desplazamiento de puntero simple sin lógica complicada, lo que tiene un gran impacto en el rendimiento. Después de todo, este es un acceso aleatorio, lo que interrumpe la compatibilidad con el caché, por lo que no se recomienda. En cambio, la técnica de optimización consiste en organizar los datos en otros trabajos y luego pasarlos al trabajo actual para su uso.
  • Este experimento también demuestra el poder de Burst, y debe activarse si puede activar la compilación de Burst.

Pero no todo es absoluto. Aún puedes usar la función TryGetComponent de Lookup para simplificar la lógica en lugares que no son puntos calientes de rendimiento. Después de todo, organizar datos especiales también implica consumo de memoria y costos de mano de obra.

Cuatro Resumen

Estas son las habilidades de DOTS resumidas hasta ahora. A través de estos consejos, puede ayudar a los principiantes a resolver algunos problemas difíciles y al menos brindarles una forma de pensar. Sin embargo, después de todo, en la etapa de tanteo, si tiene algún malentendido, bienvenido a corregir y comunicar.


Este es el artículo número 1445 de Yuhu Technology, gracias al autor zd304 por la contribución. Bienvenido a volver a publicar y compartir, no reimprimir sin la autorización del autor. Si tiene ideas o descubrimientos únicos, comuníquese con nosotros y analícelos juntos.

Página de inicio del autor: https://www.zhihu.com/people/zhang-dong-13-77

Gracias nuevamente a zd304 por compartir. Si tiene ideas o descubrimientos únicos, contáctenos y discutamos juntos.

Supongo que te gusta

Origin blog.csdn.net/UWA4D/article/details/132320709
Recomendado
Clasificación