咖啡丶七

自律给我自由

属性

[RequireMatchingQueriesForUpdate]

在Update中使用 Entities.WithAll<>(), Entities.WithAny<>(), Entities.ForEach() 等方法时,Unity 会自动生成一个 EntityQueryIJobEntity也会创建)。系统通过这个 EntityQuery 去查找符合条件的实体。

该属性的作用是确保只有在系统的 EntityQuery 匹配了实际的实体数据时,系统才会执行update方法。


实用接口

1
2
3
4
5
Entity entity = SystemAPI.GetSingletonEntity<PlayerTag>();						 // 获取实体
bool has = SystemAPI.HasComponent<ChampTag>(entity); // 判断entitiy是否拥有某组件
GhostOwner newtworkId = SystemAPI.GetComponent<GhostOwner>(entity).NetwordId; // 直接获取entity上的组件
bool entityExists = EntityManager.Exists(entity); // 判断实体是否存在有效
bool has = SystemAPI.HasSingleton<GamePlayingTag>() // 判断整个场景中是否有组件,几个无所谓

注意事项

returncontinue

虽然是一个很显而易见且简单的问题,但确实很容易被忽视

在遍历Query的时候如果想要跳过这一Entity的时候不要使用return,而是使用continue,例如该Entity数据为空,不需要处理
除非你不再需要遍历后面的其他的Entity,例如想要寻找某个Entity

1
2
3
4
foreach (var damageBuffer in SystemAPI.Query<DynamicBuffer<DamageBufferElement>>())
{
if (damageBuffer.IsEmpty) continue; // 注意:不能使用return,不然会跳过剩余的Entity
}

相信在处理大多数Query的时候都是希望跳过这一个Entity,而不是跳过后续所有的Entity。所以在这里提醒自己注意一下这个问题


单例

保存单例的引用

如果确定整个场景只有确定个数的实体,并且数量也不会改变,那么可以直接在OnStartRunning直接获取到该实体并保存

该方法只适合SystemBase。如果想在Isystem的OnCreate中保存单例,后续使用该组件的时候会报错,提示引用丢失

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 实验组——存储单例
public partial class PlayerMoveSystem_______ : SystemBase
{
private Entity playerEntity;

protected override void OnCreate()
{
RequireForUpdate<PlayerTag>();
}

protected override void OnStartRunning()
{
// 获取一个
playerEntity = SystemAPI.GetSingletonEntity<PlayerTag>();
// 获取多个
// foreach (var (tag, entity) in SystemAPI.Query<PlayerTag>().WithEntityAccess())
// {
// playerEntity = entity;
// break; // 只需要第一个玩家实体
// }
}

protected override void OnUpdate()
{
var deltaTime = SystemAPI.Time.DeltaTime;
var moveInput = SystemAPI.GetComponent<PlayerMoveInput>(playerEntity);
var transform = SystemAPI.GetComponentRW<LocalTransform>(playerEntity);
var speed = SystemAPI.GetComponent<Speed>(playerEntity);

transform.ValueRW.Position.xz += moveInput.Value * speed.Value * deltaTime;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 对照组——直接查找对象
[UpdateBefore(typeof(TransformSystemGroup))]
public partial struct PlayerMoveSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
var deltaTime = SystemAPI.Time.DeltaTime;

/*
JobHandle job =
new PlayerMoveJob()
{
DeltaTime = deltaTime
}.Schedule(state.Dependency);
job.Complete();*/

foreach (var (moveInput, transform, speed) in SystemAPI.Query<PlayerMoveInput, RefRW<LocalTransform>, Speed>())
{
transform.ValueRW.Position.xz += moveInput.Value * speed.Value * deltaTime;
}
}
}

以上两种方式的性能测试结果:

设置单例属性

在确定一个组件为单例时,可以使用SystemAPI.SetSingleton(IComponentData)保存该组件的值

  • 该组件必须没有实现IEnableableComponentEntityQuery.SetSingleton{T}
  • 无法在Entities.ForEachIJobEntityUtility methodsAspects中使用
1
2
3
var gamePropertyEntity = SystemAPI.GetSingletonEntity<GameStartProperties>();
var teamPlayerCounter = SystemAPI.GetComponent<TeamPlayerCounter>(gamePropertyEntity);
SystemAPI.SetSingleton(teamPlayerCounter); // 设置该组件的值

监控数量

使用EntityQuery监控指定类型的数量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public partial class PlayerSpawnerSystem : SystemBase
{
protected override void OnUpdate()
{
EntityQuery playerEntityQuery = EntityManager.CreateEntityQuery(typeof(PlayerTag));

PlayerSpawner playerSpawner = SystemAPI.GetSingleton<PlayerSpawner>();

var entityCommandBuffer = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>().CreateCommandBuffer(World.Unmanaged);

int spawnAmount = 10;
if (playerEntityQuery.CalculateEntityCount() < spawnAmount)
{
var entity = entityCommandBuffer.Instantiate(playerSpawner.playerPrefab);

entityCommandBuffer.SetComponent(entity, new Speed
{
value = DotsHelpers.GetRandomFloat(2, 5)
});
}
}
}

或者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public partial struct ClientRequestGameEntrySystem : ISystem
{
private EntityQuery _pendingNetworkIdQuery;

public void OnCreate(ref SystemState state)
{
// 虽然是在OnCreate中创建的Query,但是并没有销毁掉,所以还是会一直更新里面的实体
var builder = new EntityQueryBuilder(Allocator.Temp).WithAll<NetworkId>().WithNone<NetworkStreamInGame>();
_pendingNetworkIdQuery = state.GetEntityQuery(builder);
state.RequireForUpdate(_pendingNetworkIdQuery);
}
public void OnUpdate(ref SystemState state)
{
var pendingNetworkIds = _pendingNetworkIdQuery.ToEntityArray(Allocator.Temp);
}
}

ISystem的用法,需要使用.Dispose()手动释放资源

1
var query = SystemAPI.QueryBuilder().WithAll<NewEnemyTag>().Build();

移除组件

v1.2.4貌似添加和删除组件都只能使用缓冲器

确认之后不会再使用的组件可以使用缓冲器移除

1
2
3
4
5
var ecb = SystemAPI.GetSingleton<EndInitializationEntityCommandBufferSystem.Singleton>().CreateCommandBuffer(state.WorldUnmanaged);		// 延迟到这一帧初始组结束时移除
foreach (var (_, entity) in SystemAPI.Query<NewEnemyTag>().WithEntityAccess())
{
ecb.RemoveComponent<NewEnemyTag>(entity);
}
1
2
3
4
5
6
7
var ecb = new EntityCommandBuffer(Allocator.Temp);			// 立即移除
foreach (var (_, entity) in SystemAPI.Query<NewEnemyTag>().WithEntityAccess())
{
ecb.RemoveComponent<NewEnemyTag>(entity);
}
ecb.Playback(state.EntityManager); // 手动创建的ecb需要手动触发
ecb.Dispose();

创建实体

1
2
3
4
var ecb = new EntityCommandBuffer(Allocator.Temp);
var swordEntity = ecb.Instantiate(_swordSprite);
ecb.Playback(EntityManager);
ecb.Dispose();

销毁实体

SystemBase

1
EntityManager.DestroyEntity(gamePlayingEntity);

ISystem

1
2
3
var ecb = new EntityCommandBuffer(Allocator.Temp);
ecb.DestroyEntity(requestEntity);
ecb.Playback(EntityManager);

在其他方法中使用SystemAPI接口

1
2
3
4
5
6
7
8
public void OnUpdate(ref SystemState state)
{
SpawnOnEachLane(ref state);
}
private void SpawnOnEachLane(ref SystemState state) // 如果没有传入state,就无法使用SystemAPI的接口
{
var ecbSingleton = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>();
}

一个组件烘焙多次

一个实体上只能拥有一个同名的组件,如果一个组件需要挂载多次,可以使用CreateAdditionalEntity创建一个额外的实体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Vector3[] TopLanePath;
public Vector3[] MidLanePath;
public Vector3[] BotLanePath;

public override void Bake(MinionPathAuthoring authoring)
{
var entity = GetEntity(TransformUsageFlags.None); // 创建本身
// 添加
var topLane = CreateAdditionalEntity(TransformUsageFlags.None, false, "TopLane");
var midLane = CreateAdditionalEntity(TransformUsageFlags.None, false, "MidLane");
var botLane = CreateAdditionalEntity(TransformUsageFlags.None, false, "BotLane");
var topLanePath = AddBuffer<MinionPathPosition>(topLane);
var midLanePath = AddBuffer<MinionPathPosition>(midLane);
var botLanePath = AddBuffer<MinionPathPosition>(botLane);
}

灵活使用关键字WithAny捕获

WithAny只要有一个符合就捕获

1
2
// 捕获拥有碰撞物理,且有队伍的实体					// 既可以是英雄,也可以是小兵
SystemAPI.Query<RefRW<PhysicsMass>, MobaTeam>().WithAny<NewChampTag, NewMinionTag>();

事件

在GameObject中,事件是使用代码写Action来触发

但是在Entity中,事件的触发分为如下步骤

  1. 准备:
    • 创建一个预制体,单独给这个预制体准备一个标签如GameOverTag,并保存该对该预制体的引用。不能直接将该预制体放置在Entities场景中,而是存储其引用。
    • 创建一个监听系统,如GameOverSystem,一直捕获或者使用RequireForUpdate<GameOverTag>();执行逻辑
  2. 在你需要触发该事件的时候,比如在DestroySystem中检测到被摧毁的entity是基地,那么就将创建准备好的entity
  3. GameOverSystem监听到场景中存在GameOverTag标签后,执行GameOverSystem逻辑

创建预制体详细流程:

与[Entities => GameObject](#Entities => GameObject)不同,我们需要创建的是Entity,所以我们要存储的是Entity类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public struct MobaPrefabs : IComponentData		// 是结构体,而不是class
{
public Entity GameOverEntity; // 以Entity的形式保存下来
}
public class MobaPrefabsAuthoring : MonoBehaviour
{
public GameObject GameOverEntity; // 在Inspector窗口上引入预制体
private class Baker : Baker<MobaPrefabsAuthoring>
{
public override void Bake(MobaPrefabsAuthoring authoring)
{
var prefabContainerEntity = GetEntity(TransformUsageFlags.None);

AddComponent(prefabContainerEntity, new MobaPrefabs()
{ // 烘焙该Entity
GameOverEntity = GetEntity(authoring.GameOverEntity, TransformUsageFlags.None),
});
}
}
}

碰撞

球形射线

触发条件:

  1. 被检测的物体必须要有Physics Shape组件
  2. Physics Shape组件的Collision Filter的设置必须与射线设定的Collision Filter成对应关系
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CollisionFilter _collisionFilter = new CollisionFilter()
{
BelongsTo = 1 << 4, // 属于第五层Raycasts
CollidesWith = 1 << 2 // 与第二层Monsters碰撞
};

var collisionWorld = SystemAPI.GetSingleton<PhysicsWorldSingleton>().CollisionWorld; // 获取物理系统
var playerEntity = SystemAPI.GetSingletonEntity<PlayerTag>();
var transform = SystemAPI.GetComponentRO<LocalTransform>(playerEntity); // 获取玩家位置

var hits = new NativeList<DistanceHit>(Allocator.TempJob); // 存放击中的物体
if (collisionWorld.OverlapSphere(transform.ValueRO.Position, 100f, ref hits, _collisionFilter))
{
Debug.Log("hit");
}
hits.Dispose(); // 记得释放

物理碰撞

两种触发方式:

  • ICollisionEventsJob:碰撞事件
  • ITriggerEventsJob:触发事件,就可以理解成普通的包围盒勾选的Trigger

触发条件:

  1. (与射线一样)被检测的物体必须要有Physics Shape组件
  2. (与射线一样)Physics Shape组件的Collision Filter的设置必须与射线设定的Collision Filter成对应关系
  3. 碰撞的两个物体之中必须要有一个物体有Rigidbody(必须是3D的,不支持2D的Rigidbody)
  4. 最后一项也是最重要的一项,必须设置Collision Response,两个物体分别选择什么会触发哪个事件也标明在图二了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
[UpdateInGroup(typeof(PhysicsSystemGroup))]			// 必须
[UpdateAfter(typeof(PhysicsSimulationGroup))] // 必须
public partial struct DamageOnTriggerSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
var simulationSingleton = SystemAPI.GetSingleton<SimulationSingleton>();
// 执行碰撞事件
state.Dependency = new CountNumCollisionEvents
{
// 可以传递参数
}.Schedule(simulationSingleton, state.Dependency);
// 执行触发事件,两种写法是一样的,都可以
var job = new DamageOnTriggerJob
{
// 可以传递参数
};
state.Dependency = job.Schedule(simulationSingleton, state.Dependency);
}
}
// 碰撞事件
public struct CountNumCollisionEvents : ICollisionEventsJob
{
public void Execute(CollisionEvent collisionEvent)
{
Debug.Log($"ICollisionEventsJob");
}
}
// 触发事件
public struct DamageOnTriggerJob : ITriggerEventsJob
{
public void Execute(TriggerEvent triggerEvent)
{
Debug.Log($"ITriggerEventsJob");
}
}

关于Tick和IsFirstTimeFullyPredictingTick

参考Full vs. Partial Ticks

  • Tick是固定一秒60次,并不是update执行的次数。由于服务器没反应过来,服务器update的次数一般都会比Tick次数少,客户端反应比较快一半会比60大。

    • 服务端会将数据以Tick的频率发送给客户端,而客户端会平滑的设置其数值,有如下两种情景

      • 数值:服务端告诉客户端第20TickValue = 10,那么客户端在19Tick(假设Value=0)与20Tick之间会多次执行update,让Value从0平滑的到达10。假如客户端一个Tick可以运行10次,那么value第一次update为0,第二次update为1,第三次update为3….直到完整的到达第20Tick,Value就等于10了。

      • 事件:服务端告诉客户端第20Tick的时候创建一个小兵,那么在19Tick与20Tick之间,每次update都会创建一个小兵

        这时就需要用到IsFirstTimeFullyPredictingTick了,在19到20Tick之间updateIsFirstTimeFullyPredictingTick返回false;当你真正达到20Tick,IsFirstTimeFullyPredictingTick返回True,表示这是第一次完整的预测Tick

      假设有代码如下:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      // [UpdateInGroup(typeof(PredictedSimulationSystemGroup))]	system在模拟组中

      var networkTime = SystemAPI.GetSingleton<NetworkTime>();
      if (networkTime.IsFirstTimeFullyPredictingTick)
      {
      ecb.生成小兵(); // 只有在20Tick的时候才会执行
      }
      Value = 10; // 在18到20Tick之间,客户端每次update都将让Value更接近10
      // 虽然这里写的是直接赋值,但客户端并不会生硬的将Value设置为10

  • 服务端与客户端的Tick并不同步。这应该就是延迟的由来?

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public partial struct TestSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
var isServer = state.WorldUnmanaged.IsServer();
if (isServer)
{
var networkTime = SystemAPI.GetSingleton<NetworkTime>();
Debug.Log($"Server: Tick: {networkTime.ServerTick}, ElapsedTime: {SystemAPI.Time.ElapsedTime}");
Debug.Log("Server: Update time");
}
else
{
var networkTime = SystemAPI.GetSingleton<NetworkTime>();
Debug.Log($"Client: Tick: {networkTime.ServerTick}, ElapsedTime: {SystemAPI.Time.ElapsedTime}");
Debug.Log("Client: Update time");
}
}
}

GameObject与Entities世界交互

GameObject => Entities

系统:

1
2
3
4
// 判断Default World中 PlayerMoveSystem 系统是否存在
ClientStartGameSystem startGameSystem = World.DefaultGameObjectInjectionWorld.GetExistingSystemManaged<ClientStartGameSystem>();
// 直接使用系统中的公开属性
startGameSystem.OnUpdatePlayersRemainingToStart -= UpdatePlayerRemainingText;

实体:

1
2
3
4
// 创建实体
Entity entity = World.DefaultGameObjectInjectionWorld.EntityManager.CreateEntity();
// 添加组件
World.DefaultGameObjectInjectionWorld.EntityManager.AddComponentData(teamRequestEntity, new ClientTeamSelect(){ Value = team });
1
2
3
4
5
6
7
8
9
// 捕获EntityQuery
EntityQuery playerTagEntityQuery = World.DefaultGameObjectInjectionWorld.EntityManager
.CreateEntityQuery(typeof(PlayerTag)); // 只读
EntityQuery networkDriverQuery = World.DefaultGameObjectInjectionWorld.EntityManager
.CreateEntityQuery(ComponentType.ReadWrite<NetworkStreamDriver>()); // 读写
// 保存捕获到的Entities
NativeArray<Entity> entityNativeArray = networkDriverQuery.ToEntityArray(Allocator.Temp);
// 修改组件
networkDriverQuery.GetSingletonRW<NetworkStreamDriver>().ValueRW.Listen(serverEndpoint);
1
2
3
4
5
6
7
8
9
_entityManager = World.DefaultGameObjectInjectionWorld.EntityManager;
playerEntityQuery = _entityManager.CreateEntityQuery(typeof(PlayerTag));
// 保存捕获到的Entity
playerEntity = playerEntityQuery.GetSingletonEntity(); // 只适合确定只有一个Entity的情况
transform.position = _entityManager.GetComponentData<LocalTransform>(playerEntity).Position; // 读取组件
_entityManager.SetComponentData(playerEntity, new MoveSpeed{ Value = 2f }); // 设置组件
_entityManager.AddComponent<MoveSpeed>(playerEntity); // 添加组件
_entityManager.RemoveComponent<MoveSpeed>(playerEntity); // 删除组件

如果使用了NetCode包,在创建Client World时,需要将Default World设置为Cilient World

1
2
3
4
// 创建Client World
var clientWorld = ClientServerBootstrap.CreateClientWorld("Coffee Client World");
// 将Default World 设置为 Cilient World
World.DefaultGameObjectInjectionWorld = clientWorld;

Entities => GameObject

方法一:单例,比较简单,就不说了

方法二:创建GameObject时保存对其的引用,方法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class HealthBarUIReference : ICleanupComponentData		// 注意这里使用的是class而不是结构体
{
public GameObject Value;
}
// 创建GameObject
var newHealthBar = Object.Instantiate(healthBarPrefab, spawnPosition, quaternion.identity);
// 将GameObject的引用保存在组件上
ecb.AddComponent(entity, new HealthBarUIReference() { Value = newHealthBar });
// 获取该组件并使用
foreach(var obj in SystemAPI.Query<HealthBarUIReference>())
{
obj.Value.transform = new Vector3(1f, 0f, 0f); // 改变位置坐标
Slider slider = obj.GetComponentInChildren<Slider>(); // 获取子物体的UI。能获取到组件,那么就很灵活了
}

由于烘焙的时候只能挂载预制体,所以entities无法直接引用到GameObject中的物体


BlobArray<T>

特点:

  • 允许存储大量数据,并且只读
  • 存储在一块连续的内存区域,访问速度快,缓存友好
  • 由于是只读的,所以可以在多个线程中安全地共享和访问

使用方法:

  1. 定义BlobArrayBlobAsset

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public struct SpawnEnemyPoint : IComponentData
    {
    public BlobAssetReference<SpawnEnemyPointBlob> Value;
    }

    public struct SpawnEnemyPointBlob
    {
    public BlobArray<float3> Value;
    }
  2. SpawnEnemyPoint烘焙到任意实体上

  3. 使用BlobBuilder赋值

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 初始化一个临时用的builder
    using (BlobBuilder builder = new BlobBuilder(Allocator.Temp))
    {
    // 确定需要存储的Blob结构体数据类型
    ref SpawnEnemyPointBlob pointBlob = ref builder.ConstructRoot<SpawnEnemyPointBlob>();
    // 分配大小空间
    BlobBuilderArray<float3> arrayBuilder = builder.Allocate(ref pointBlob.Value, 10);
    // 填充数据
    for (int i = 0; i < 10; i++)
    {
    arrayBuilder[i] = new float3{i, 0f, 0f};
    }
    // 创建Blob的引用(类似于指针?)
    BlobAssetReference<SpawnEnemyPointBlob> blobAsset = builder.CreateBlobAssetReference<SpawnEnemyPointBlob>(Allocator.Persistent);
    // 将创建的引用赋值给gameEntity的组件上,供其使用
    ecb.SetComponent(gameEntity, new SpawnEnemyPoint(){Value = blobAsset});
    }
  4. 使用方法与列表一样

    1
    2
    private readonly RefRO<SpawnEnemyPoint> _spawnEnemyPoint;
    var position = _spawnEnemyPoint.ValueRO.Value.Value.Value[0];

为什么不直接将BlobArray当做IComponentData挂载到实体上?

  1. BlobArray直接放在实体上,每个实体都会有独立的BlobArray副本,大大增加了内存使用
    BlobAssetReference 存储的是指向连续内存块的指针
    多个实体可以共享一个BlobArray,而不需要为每个实体分配内存
  2. IComponentData是可更改的,每次修改组件都可能会导致内存重新分配和复制,对于不可变数据来说不必要

System报错

InvalidOperationException: The previously scheduled 'IJobEntity' writes to the ComponentTypeHandle<Unity.Collections.NativeText.ReadOnly>

1
2
3
InvalidOperationException: The previously scheduled job <AJob> writes to the ComponentTypeHandle<Unity.Collections.NativeText.ReadOnly> <AJob>.<Value>.
You are trying to schedule a new job <BJob>, which reads from the same ComponentTypeHandle<Unity.Collections.NativeText.ReadOnly> (via <BJob>.<Value>).
To guarantee safety, you must include PlayerMoveSystem:PlayerMoveJob as a dependency of the newly scheduled job.
  • 两个任务共同访问了同一个IComponentData
原因

两个System.IJobEntityIAspect中以读写的方式定义了同一个实体的组件数据

生成敌人系统敌人移动系统,在Systems窗口中可以看到两个系统的执行先后

根本原因:

  • 没有定义系统之间执行顺序
  • Run()执行完后再才进入下一个system,并且在执行期间会保护数据,其他系统只能读取无法修改Run()所访问的读写组件
错误代码

生成敌人错误代码:

1
2
3
4
5
6
7
8
9
10
11
12
public partial struct SpawnEnemySystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
new AJob().Run(); // 立即在当前线程上执行,完成后再进入下一个System,期间的数据其他system无法修改其访问的组件
}
}

private partial struct AJob : IJobEntity
{
private void Execute(TestAspect aspect) { }
}
1
2
3
4
public readonly partial struct TestAspect : IAspect
{
readonly RefRW<TargetPosition> targetPosition;
}

移动系统错误代码:

1
2
3
4
5
6
7
8
9
10
11
12
public partial struct EnemyMoveSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
new BJob().Schedule(); // 调度任务到其他线程访问
}
}

public partial struct BJob : IJobEntity
{
private void Execute(EnemyMoveAspect enemy) { }
}
1
2
3
4
5
public readonly partial struct EnemyMoveAspect : IAspect
{
readonly RefRO<EnemyTag> enemyTag;
readonly RefRW<TargetPosition> targetPosition;
}

从上面的代码可以看出,仅仅只是引用就会报错

这里生成和移动敌人例子可能不太合理,但只要理解到两个系统的IAspect读写的方法定义了同一个的实体的同一个组件就OK了

注意:为了节约空间简化了不少代码

解决方法

方法一:

使用UpdateAfer属性定义执行顺序

1
2
[UpdateAfter(typeof(SpawnEnemySystem))]
public partial struct EnemyMoveSystem : ISystem

这样,在成功生成敌人后,在执行移动系统,移动系统就不会在敌人还在生成的时候就修改其组件

方法二:

将选择其中Entity较少的任务设置依赖项,并等待其编译完成

1
2
3
4
5
6
7
8
public partial struct EnemyMoveSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
JobHandle job = new BJob().Schedule(state.Dependency); // 设置依赖项
job.Complete(); // 主线程被阻塞,直到该任务完成
}
}

方法三:

使用Query获取Aspect

1
2
3
4
5
6
7
8
9
10
public partial struct EnemyMoveSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
foreach(var aspect in SystemAPI.Quert<RefRW<MoveAspect>>.WithEntityAccess())
{
...
}
}
}

InvalidOperationException: 'EntityCommandBuffer' is not declared [ReadOnly] in a IJobParallelFor job. The container does not support parallel writing. Please use a more suitable container type.

原因

该容器不支持并行写入

错误代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public partial struct EnemyMoveSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
var ecb = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>().CreateCommandBuffer(state.WorldUnmanaged);

new EnemyMoveJob
{
ECB = ecb
}.ScheduleParallel();
}

public partial struct EnemyMoveJob : IJobEntity
{
public EntityCommandBuffer ECB;

private void Execute(EnemyMoveAspect enemy)
{
ECB.RemoveComponent<EnemyTag>(enemy.Entity);
}
}
}
解决方法

修改类型成可并行写入的缓冲器容器

1
var ecb = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>().CreateCommandBuffer(state.WorldUnmanaged).AsParallelWriter();
1
2
3
4
private void Execute(EnemyMoveAspect enemy, [ChunkIndexInQuery]int sortKey)	// 并不是普通的int类型,需要指定类型
{
ECB.RemoveComponent<EnemyTag>(sortKey, enemy.Entity);
}

使用方法:需要指定执行的顺序,如果没有特殊的顺序需要考虑,可以使用ChunkIndexInQuery

ChunkIndexInQuery

在ECS中,EntityQuery 会返回多个 “chunk”(即实体的内存块),而每个 “chunk” 存储一组符合查询条件的实体ChunkIndexInQuery 就是这些 chunk 在整个查询中的唯一索引值。

每个 chunk 在查询的范围内都有一个唯一的索引值,避免多个 chunk 之间产生混淆。

即使调度方式发生变化(例如工作在多个线程之间并行执行),这个索引值也保持不变,保证了在不同的调度执行顺序下,ChunkIndexInQuery 始终一致。

因为索引值是确定且稳定的,所以在使用 EntityCommandBuffer(ECB)时,可以保证对实体的操作是可预测和一致的(例如,记录、重放命令时不受并行或调度的影响)。


'MultiplayerDOTS.NpcAttackSystem' creates a Lookup object (e.g. ComponentLookup) during OnUpdate. Please create this object in OnCreate instead and use type _MyLookup.Update(ref systemState); in OnUpdate to keep it up to date instead. This is significantly faster.

原因

在使用了错误的API

错误代码
1
2
3
4
state.Dependency = new NpcAttackJob()
{
TransformLookup = state.GetComponentLookup<LocalTransform>(true),
}.ScheduleParallel(state.Dependency);
解决方法
1
2
3
4
state.Dependency = new NpcAttackJob()
{
TransformLookup = SystemAPI.GetComponentLookup<LocalTransform>(true),
}.ScheduleParallel(state.Dependency);

UniTask

github原网址:https://github.com/Cysharp/UniTask

简单介绍:十分好用的协程插件,速度快,容错率高

安装方法:

方法一:

通过引用git URL安装

1
https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask

如果提示导入出错,可以查看文章最后的处理方法

方法二:

Releases获取.unitypackage文件后直接导入


R3

github原网址:https://github.com/Cysharp/R3

简单介绍:十分好用的观察者事件监听插件

安装步骤:

  1. 需要先安装**NuGetForUnity** ,通过引用git URL安装

    1
    https://github.com/GlitchEnzo/NuGetForUnity.git?path=/src/NuGetForUnity

    如果提示导入出错,可以查看文章最后的处理方法

    也可以在Releases获取.unitypackage文件后直接导入

    安装好后Asset根目录会多出如下文件,不要移动任何文件的位置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    Assets
    │ NuGet.config // 配置文件
    │ packages.config // 配置文件
    |
    ├─NuGet // 插件功能文件
    │ ├─Editor
    │ └─Resources
    |
    ├─Packages // 安装的NuGet包体位置
  2. 再安装R3,打开NuGetForUnity窗口 NuGet => Manage NuGet Packages 搜索 “R3” 并安装

  1. 最后再安装R3.Unity包,通过引用git URL安装

    1
    https://github.com/Cysharp/R3.git?path=src/R3.Unity/Assets/R3.Unity
  2. 为了获得更好的使用体验,还需要安装配套的**ObservableCollections**


Cinemachine

简介:Unity官方插件,十分强大的相机

通过名称安装

1
com.unity.cinemachine

ProBuilder

简介:Unity官方插件,可以捏一些简单的模型

通过名称安装

1
com.unity.probuilder

Newtonsoft Json

简介:Unity官方插件,十分强大的读写json插件

通过名称安装

1
com.unity.nuget.newtonsoft-json

在序列化的时候可能会有警告如下

1
JsonSerializationException: Self referencing loop detected for property 'normalized' with type 'UnityEngine.Vector3'. Path 'Data.1.pos.normalized.normalized'

这个警告是由于 JsonConvert.SerializeObject 在序列化对象时检测到了循环引用(Self-referencing loop)。具体来说,UnityEngine.Vector3 类型的 normalized 属性会返回一个新的 Vector3 对象,而这个对象又包含 normalized 属性,从而导致无限递归。

可以通过设置 JsonSerializerSettings 来忽略循环引用。

1
2
3
4
5
6
var settings = new JsonSerializerSettings
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore // 忽略循环引用
};
var json = JsonConvert.SerializeObject(monsterUtil, settings);
File.WriteAllText(streamingAssetsPath, json);

LitJson

目前支持全平台编译的json工具库比较有名的有三个,分别是:JsonUtility、newtonjson还有LitJson
其中newtonjson体积过于庞大(250k),而JsonUtility又不支持键值对。所以我们只能选择使用LitJson

LitJson

直接下载Releases的unitypackage,双击安装


其他插件

Platforms

简介:Unity官方插件,Build管理插件,Experimental实验性包体

通过名称安装

1
com.unity.platforms

使用方法:

  1. 创建配置资产,命名为BaseBuild

    Create - Build Confinguration - Enpty Build Configuration

  2. 设置配置

    再创建ClientBuildServerBuild并映射BaseBuild的设置

好吧,看了一下,这个插件能控制的设置少的可怜

但是可以一键打包,不用选择路径,直接放在Assets的同路径下的Builds文件中,并且下方可以显示报错信息


拉取GitHub的package超时解决方案

  1. https://www.ipaddress.com/website/输入github.com获取github的ip地址

  1. C:\Windows\System32\drivers\etc\hosts文件中添加

    1
    140.82.113.3	github.com
  2. 刷新DNS,在cmd中键入ipconfig /flushdns

问题因素排除

  1. 使用git config --global http.proxy查看是否有使用代理

  2. 如果使用了就删除代理git config -- global unset http.proxy

结构

结合图形编辑器认识代码,下面是使用ASE制作的一个很简单的两个纹理相乘的Shader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
Shader "New Amplify Shader"
{
Properties
{
_TextureSample0("Texture Sample 0", 2D) = "white" {} // 定义两个纹理
_TextureSample1("Texture Sample 1", 2D) = "white" {}
}

SubShader // 是Shader的核心,定义了渲染对象的方式
{
Tags { "RenderType"="Opaque" } // 表示这个 Shader 用于渲染不透明对象
LOD 100 // 表示细节层级,较低的值意味着较低的复杂度

CGINCLUDE // 在 CGINCLUDE 和 ENDCG 之间,定义了一些全局指令,用于控制着色器的目标平台
#pragma target 3.0 // 表示支持的着色器模型
ENDCG // 结束定义全局指令
Blend Off // 关闭透明混合
AlphaToMask Off // 不使用 alpha 到掩码转换
Cull Back // 开启背面剔除,只渲染物体正面
ColorMask RGBA // 允许写入颜色的所有通道(红、绿、蓝、alpha)
ZWrite On // 开启深度写入,表示物体会写入深度缓冲区
ZTest LEqual // 使用深度测试,表示只渲染深度值小于或等于当前深度缓冲值的片段
Offset 0 , 0 // 没有深度偏移

Pass // 每个 SubShader 可以有一个或多个渲染通道(Pass),表示渲染时应用不同的处理。
{
Name "Unlit" // 这里使用了一个名字为 "Unlit" 的通道。

CGPROGRAM

#pragma vertex vert // 指定顶点着色器函数是vert
#pragma fragment frag // 指定片段(像素)着色器函数是frag
#pragma multi_compile_instancing // 启用GPU实例化支持,用于优化同一对象的大量渲染
#include "UnityCG.cginc" // 包含 Unity 常用的 Cg 函数库,提供了常见的数学、纹理和其他实用函数

struct appdata // 代表顶点输入数据
{
float4 vertex : POSITION; // 顶点的空间坐标
float4 color : COLOR; // 顶点颜色
float4 ase_texcoord : TEXCOORD0; // 第一个纹理坐标
float4 ase_texcoord1 : TEXCOORD1; // 第二个纹理
UNITY_VERTEX_INPUT_INSTANCE_ID // 用于GPU实例化支持
};

struct v2f // 用于在顶点着色器和片段着色器之间传递数据
{
float4 vertex : SV_POSITION; // 存储顶点的屏幕空间坐标
#ifdef ASE_NEEDS_FRAG_WORLD_POSITION
float3 worldPos : TEXCOORD0;
#endif
float4 ase_texcoord1 : TEXCOORD1; // 存储纹理坐标,TEXCOORD1表示这是该顶点对应的第二组纹理坐标
UNITY_VERTEX_INPUT_INSTANCE_ID // 支持实例化渲染
UNITY_VERTEX_OUTPUT_STEREO // 支持VR渲染
};

uniform sampler2D _TextureSample0;
uniform sampler2D _TextureSample1;

v2f vert ( appdata v )
{
v2f o;
// 这些宏是 Unity 提供的,用于支持实例化和虚拟现实渲染。
UNITY_SETUP_INSTANCE_ID(v);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
UNITY_TRANSFER_INSTANCE_ID(v, o);

o.ase_texcoord1.xy = v.ase_texcoord.xy; // 将输入的第一组纹理坐标赋值给输出
o.ase_texcoord1.zw = v.ase_texcoord1.xy; // 将第二组纹理坐标赋给输出
float3 vertexValue = float3(0, 0, 0);
vertexValue = vertexValue;
v.vertex.xyz += vertexValue;
o.vertex = UnityObjectToClipPos(v.vertex); // 将顶点转换为屏幕坐标系,以便在片段着色器中进行渲染。
return o;
}

fixed4 frag (v2f i ) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(i);
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i);
// 从ase_texcoord1的前两项(xy)获取纹理坐标
fixed4 finalColor;
// 从ase_texcoord1的前两项(xy)获取纹理坐标
float2 texCoord1 = i.ase_texcoord1.xy * float2( 1,1 ) + float2( 0,0 );
// // 从ase_texcoord1的后两项(zw)获取纹理坐标
float2 texCoord3 = i.ase_texcoord1.zw * float2( 1,1 ) + float2( 0,0 );

// 取出颜色值后,将两个纹理的颜色相乘,生成最终的颜色
finalColor = ( tex2D( _TextureSample0, texCoord1 ) * tex2D( _TextureSample1, texCoord3 ) );
return finalColor;
}
ENDCG
}
}
CustomEditor "ASEMaterialInspector"

Fallback Off
}

Properties

1
2
3
4
5
Properties
{
_TextureSample0("Texture Sample 0", 2D) = "white" {} // 定义两个纹理
_TextureSample1("Texture Sample 1", 2D) = "white" {}
}

定义了两个 2D 纹理,可以在Inspector上修改参数

SubShader

1
2
3
4
5
6
7
8
9
10
11
12
13
SubShader
{
Tags { "RenderType"="Opaque" } // 表示这个 Shader 用于渲染不透明对象
LOD 100 // 表示细节层级,较低的值意味着较低的复杂度
...
Pass
{
Name "Unlit"
CGPROGRAM
...
ENDCG
}
}

每个 SubShader 可以有一个或多个渲染通道(Pass),表示渲染时应用不同的处理。这里使用了一个名字为 “Unlit” 的通道。

CGINCLUDE 和 渲染状态设置

1
2
3
4
5
6
7
8
9
10
CGINCLUDE					// 在 CGINCLUDE 和 ENDCG 之间,定义了一些全局指令,用于控制着色器的目标平台
#pragma target 3.0 // 表示支持的着色器模型
ENDCG // 结束定义全局指令
Blend Off // 关闭透明混合
AlphaToMask Off // 不使用 alpha 到掩码转换
Cull Back // 开启背面剔除,只渲染物体正面
ColorMask RGBA // 允许写入颜色的所有通道(红、绿、蓝、alpha)
ZWrite On // 开启深度写入,表示物体会写入深度缓冲区
ZTest LEqual // 使用深度测试,表示只渲染深度值小于或等于当前深度缓冲值的片段
Offset 0 , 0 // 没有深度偏移

Vertex 和 Fragment的定义

1
2
3
4
#pragma vertex vert					// 指定顶点着色器函数是vert
#pragma fragment frag // 指定片段(像素)着色器函数是frag
#pragma multi_compile_instancing // 启用GPU实例化支持,用于优化同一对象的大量渲染
#include "UnityCG.cginc" // 包含 Unity 常用的 Cg 函数库,提供了常见的数学、纹理和其他实用函数

顶点着色器 (Vertex Shader)

1
2
3
4
5
6
7
8
struct appdata							// 代表顶点输入数据
{
float4 vertex : POSITION; // 顶点的空间坐标
float4 color : COLOR; // 顶点颜色
float4 ase_texcoord : TEXCOORD0; // 第一个纹理坐标
float4 ase_texcoord1 : TEXCOORD1; // 第二个纹理
UNITY_VERTEX_INPUT_INSTANCE_ID // 用于GPU实例化支持
};

appdata 结构体代表顶点输入数据,它包含:

  • vertex: 顶点的空间坐标。
  • color: 顶点颜色。
  • ase_texcoord / ase_texcoord1: 两个纹理坐标。
  • UNITY_VERTEX_INPUT_INSTANCE_ID: 用于 GPU 实例化支持。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
v2f vert (appdata v)
{
v2f o;

// 这些宏是 Unity 提供的,用于支持实例化和虚拟现实渲染。
UNITY_SETUP_INSTANCE_ID(v);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
UNITY_TRANSFER_INSTANCE_ID(v, o);

o.ase_texcoord1.xy = v.ase_texcoord.xy; // 将输入的第一组纹理坐标赋值给输出
o.ase_texcoord1.zw = v.ase_texcoord1.xy; // 将第二组纹理坐标赋给输出
float3 vertexValue = float3(0, 0, 0);
vertexValue = vertexValue;
v.vertex.xyz += vertexValue;
o.vertex = UnityObjectToClipPos(v.vertex); // 将顶点转换为屏幕坐标系,以便在片段着色器中进行渲染。
return o;
}

片段着色器 (Fragment Shader)

1
2
3
4
5
6
7
struct v2f								// 用于在顶点着色器和片段着色器之间传递数据
{
float4 vertex : SV_POSITION; // 存储顶点的屏幕空间坐标
float4 ase_texcoord1 : TEXCOORD1; // 存储纹理坐标,TEXCOORD1表示这是该顶点对应的第二组纹理坐标
UNITY_VERTEX_INPUT_INSTANCE_ID // 支持实例化渲染
UNITY_VERTEX_OUTPUT_STEREO // 支持VR渲染
};
  • SV_POSITION :表示该值是顶点的最终位置,经过模型-视图-投影矩阵变换后,在屏幕坐标系中的位置。
  • TEXCOORD1 :表示这是该顶点对应的第二组纹理坐标。
  • UNITY_VERTEX_INPUT_INSTANCE_ID:支持实例化渲染,如果你在场景中绘制多个相同的对象(实例化渲染),这个宏会确保每个实例都有唯一的标识符,使得每个对象在渲染时可以被区分开。
  • UNITY_VERTEX_OUTPUT_STEREO:用于支持虚拟现实(VR)渲染,它确保在 VR 渲染中,输出适应双眼视图(左右眼分别渲染),从而生成立体图像。
1
2
3
4
5
6
7
8
9
10
11
12
fixed4 frag (v2f i ) : SV_Target
{
fixed4 finalColor;
// 从ase_texcoord1的前两项(xy)获取纹理坐标
float2 texCoord1 = i.ase_texcoord1.xy * float2( 1,1 ) + float2( 0,0 );
// // 从ase_texcoord1的后两项(zw)获取纹理坐标
float2 texCoord3 = i.ase_texcoord1.zw * float2( 1,1 ) + float2( 0,0 );

// 取出颜色值后,将两个纹理的颜色相乘,生成最终的颜色
finalColor = ( tex2D( _TextureSample0, texCoord1 ) * tex2D( _TextureSample1, texCoord3 ) );
return finalColor;
}
  • texCoord1texCoord3:分别使用 ase_texcoord1 的前两项(xy)和后两项(zw)来获取两个纹理的纹理坐标。

  • tex2D(_TextureSample0, texCoord1):根据纹理坐标 texCoord1 从纹理 _TextureSample0 中取出颜色值。

  • tex2D(_TextureSample1, texCoord3):根据纹理坐标 texCoord3 从纹理 _TextureSample1 中取出颜色值。

  • finalColor = tex2D( _TextureSample0, texCoord1 ) * tex2D( _TextureSample1, texCoord3 ):将两个纹理的颜色值相乘,生成最终的颜色。

  • 最终返回 finalColor,作为当前像素的颜色值。

源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
// Made with Amplify Shader Editor v1.9.6.3
// Available at the Unity Asset Store - http://u3d.as/y3X
Shader "New Amplify Shader"
{
Properties
{
_TextureSample0("Texture Sample 0", 2D) = "white" {}
_TextureSample1("Texture Sample 1", 2D) = "white" {}

}

SubShader
{


Tags { "RenderType"="Opaque" }
LOD 100

CGINCLUDE
#pragma target 3.0
ENDCG
Blend Off
AlphaToMask Off
Cull Back
ColorMask RGBA
ZWrite On
ZTest LEqual
Offset 0 , 0



Pass
{
Name "Unlit"

CGPROGRAM



#ifndef UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX
//only defining to not throw compilation error over Unity 5.5
#define UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input)
#endif
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_instancing
#include "UnityCG.cginc"


struct appdata
{
float4 vertex : POSITION;
float4 color : COLOR;
float4 ase_texcoord : TEXCOORD0;
float4 ase_texcoord1 : TEXCOORD1;
UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct v2f
{
float4 vertex : SV_POSITION;
#ifdef ASE_NEEDS_FRAG_WORLD_POSITION
float3 worldPos : TEXCOORD0;
#endif
float4 ase_texcoord1 : TEXCOORD1;
UNITY_VERTEX_INPUT_INSTANCE_ID
UNITY_VERTEX_OUTPUT_STEREO
};

uniform sampler2D _TextureSample0;
uniform sampler2D _TextureSample1;


v2f vert ( appdata v )
{
v2f o;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
UNITY_TRANSFER_INSTANCE_ID(v, o);

o.ase_texcoord1.xy = v.ase_texcoord.xy;
o.ase_texcoord1.zw = v.ase_texcoord1.xy;
float3 vertexValue = float3(0, 0, 0);
#if ASE_ABSOLUTE_VERTEX_POS
vertexValue = v.vertex.xyz;
#endif
vertexValue = vertexValue;
#if ASE_ABSOLUTE_VERTEX_POS
v.vertex.xyz = vertexValue;
#else
v.vertex.xyz += vertexValue;
#endif
o.vertex = UnityObjectToClipPos(v.vertex);

#ifdef ASE_NEEDS_FRAG_WORLD_POSITION
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
#endif
return o;
}

fixed4 frag (v2f i ) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(i);
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i);
fixed4 finalColor;
#ifdef ASE_NEEDS_FRAG_WORLD_POSITION
float3 WorldPosition = i.worldPos;
#endif
float2 texCoord1 = i.ase_texcoord1.xy * float2( 1,1 ) + float2( 0,0 );
float2 texCoord3 = i.ase_texcoord1.zw * float2( 1,1 ) + float2( 0,0 );


finalColor = ( tex2D( _TextureSample0, texCoord1 ) * tex2D( _TextureSample1, texCoord3 ) );
return finalColor;
}
ENDCG
}
}
CustomEditor "ASEMaterialInspector"

Fallback Off
}
/*ASEBEGIN
Version=19603
Node;AmplifyShaderEditor.TextureCoordinatesNode;1;-816,-32;Inherit;False;0;-1;2;3;2;SAMPLER2D;;False;0;FLOAT2;1,1;False;1;FLOAT2;0,0;False;5;FLOAT2;0;FLOAT;1;FLOAT;2;FLOAT;3;FLOAT;4
Node;AmplifyShaderEditor.TextureCoordinatesNode;3;-800,208;Inherit;False;1;-1;2;3;2;SAMPLER2D;;False;0;FLOAT2;1,1;False;1;FLOAT2;0,0;False;5;FLOAT2;0;FLOAT;1;FLOAT;2;FLOAT;3;FLOAT;4
Node;AmplifyShaderEditor.SamplerNode;2;-528,-48;Inherit;True;Property;_TextureSample0;Texture Sample 0;0;0;Create;True;0;0;0;False;0;False;-1;None;None;True;0;False;white;Auto;False;Object;-1;Auto;Texture2D;8;0;SAMPLER2D;;False;1;FLOAT2;0,0;False;2;FLOAT;0;False;3;FLOAT2;0,0;False;4;FLOAT2;0,0;False;5;FLOAT;1;False;6;FLOAT;0;False;7;SAMPLERSTATE;;False;6;COLOR;0;FLOAT;1;FLOAT;2;FLOAT;3;FLOAT;4;FLOAT3;5
Node;AmplifyShaderEditor.SamplerNode;4;-528,176;Inherit;True;Property;_TextureSample1;Texture Sample 1;1;0;Create;True;0;0;0;False;0;False;-1;None;None;True;0;False;white;Auto;False;Object;-1;Auto;Texture2D;8;0;SAMPLER2D;;False;1;FLOAT2;0,0;False;2;FLOAT;0;False;3;FLOAT2;0,0;False;4;FLOAT2;0,0;False;5;FLOAT;1;False;6;FLOAT;0;False;7;SAMPLERSTATE;;False;6;COLOR;0;FLOAT;1;FLOAT;2;FLOAT;3;FLOAT;4;FLOAT3;5
Node;AmplifyShaderEditor.SimpleMultiplyOpNode;5;-192,64;Inherit;False;2;2;0;COLOR;0,0,0,0;False;1;COLOR;0,0,0,0;False;1;COLOR;0
Node;AmplifyShaderEditor.TemplateMultiPassMasterNode;0;0,0;Float;False;True;-1;2;ASEMaterialInspector;100;5;New Amplify Shader;0770190933193b94aaa3065e307002fa;True;Unlit;0;0;Unlit;2;False;True;0;1;False;;0;False;;0;1;False;;0;False;;True;0;False;;0;False;;False;False;False;False;False;False;False;False;False;True;0;False;;False;True;0;False;;False;True;True;True;True;True;0;False;;False;False;False;False;False;False;False;True;False;0;False;;255;False;;255;False;;0;False;;0;False;;0;False;;0;False;;0;False;;0;False;;0;False;;0;False;;False;True;1;False;;True;3;False;;True;True;0;False;;0;False;;True;1;RenderType=Opaque=RenderType;True;2;False;0;False;False;False;False;False;False;False;False;False;False;False;False;False;False;False;False;False;False;False;False;False;False;False;False;False;False;False;False;False;False;False;False;0;;0;0;Standard;1;Vertex Position,InvertActionOnDeselection;1;0;0;1;True;False;;False;0
WireConnection;2;1;1;0
WireConnection;4;1;3;0
WireConnection;5;0;2;0
WireConnection;5;1;4;0
WireConnection;0;0;5;0
ASEEND*/
//CHKSM=8216CF2D2690911483A0CC9FD37960BE3735D58B

Shader制作

这里需要注意的是:

  • 粒子系统的参数并不是直接将float类型的数据来当做参数,而是将不同通道的UV当做参数
  • 当做通道的参数需要将UV Set设置为非0

源码(删除掉了注释和预编译):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
Shader "CustomizeInformation_ASE"
{
Properties
{
_MainTexture("MainTexture", 2D) = "white" {}
_NoiseTexture("NoiseTexture", 2D) = "white" {}
_DissolveTexture("DissolveTexture", 2D) = "white" {}
[HideInInspector] _texcoord( "", 2D ) = "white" {}

}

SubShader
{
Tags { "RenderType"="Transparent" "Queue"="Transparent" }
LOD 100

CGINCLUDE
#pragma target 3.0
ENDCG
Blend SrcAlpha OneMinusSrcAlpha
AlphaToMask Off
Cull Back
ColorMask RGBA
ZWrite Off
ZTest LEqual
Offset 0 , 0

Pass
{
Name "Unlit"

CGPROGRAM

#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_instancing
#include "UnityCG.cginc"

struct appdata
{
float4 vertex : POSITION;
float4 color : COLOR;
float4 ase_texcoord : TEXCOORD0;
float4 ase_texcoord1 : TEXCOORD1;
UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct v2f
{
float4 vertex : SV_POSITION;
float4 ase_texcoord1 : TEXCOORD1;
float4 ase_texcoord2 : TEXCOORD2;
UNITY_VERTEX_INPUT_INSTANCE_ID
UNITY_VERTEX_OUTPUT_STEREO
};

uniform sampler2D _MainTexture;
uniform float4 _MainTexture_ST;
uniform sampler2D _NoiseTexture;
uniform float4 _NoiseTexture_ST;
uniform sampler2D _DissolveTexture;
uniform float4 _DissolveTexture_ST;

v2f vert ( appdata v )
{
v2f o;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
UNITY_TRANSFER_INSTANCE_ID(v, o);

o.ase_texcoord1.xy = v.ase_texcoord.xy;
o.ase_texcoord2 = v.ase_texcoord1;

//setting value to unused interpolator channels and avoid initialization warnings
o.ase_texcoord1.zw = 0;
float3 vertexValue = float3(0, 0, 0);
vertexValue = vertexValue;
v.vertex.xyz += vertexValue;
o.vertex = UnityObjectToClipPos(v.vertex);

return o;
}

fixed4 frag (v2f i ) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(i);
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i);
fixed4 finalColor;
float2 uv_MainTexture = i.ase_texcoord1.xy * _MainTexture_ST.xy + _MainTexture_ST.zw;
float4 texCoord5 = i.ase_texcoord2;
texCoord5.xy = i.ase_texcoord2.xy * float2( 1,1 ) + float2( 0,0 );
float2 appendResult6 = (float2(texCoord5.x , texCoord5.y));
float2 uv_NoiseTexture = i.ase_texcoord1.xy * _NoiseTexture_ST.xy + _NoiseTexture_ST.zw;
float2 temp_cast_0 = (tex2D( _NoiseTexture, uv_NoiseTexture ).r).xx;
float2 lerpResult7 = lerp( ( uv_MainTexture + appendResult6 ) , temp_cast_0 , texCoord5.z);
float4 tex2DNode1 = tex2D( _MainTexture, lerpResult7 );
float2 uv_DissolveTexture = i.ase_texcoord1.xy * _DissolveTexture_ST.xy + _DissolveTexture_ST.zw;
float4 appendResult11 = (float4(tex2DNode1.rgb , ( tex2DNode1.a * step( texCoord5.w , tex2D( _DissolveTexture, uv_DissolveTexture ).r ) )));

finalColor = appendResult11;
return finalColor;
}
ENDCG
}
}
Fallback Off
}

代码解析(使用反推法):

  1. 可以通过lerp函数看出来,texCoord5就是我们的UVSet=1

    1
    2
    3
    4
    5
    6
    fixed4 frag (v2f i ) : SV_Target
    {
    ...
    float2 lerpResult7 = lerp( ( uv_MainTexture + appendResult6 ) , temp_cast_0 , texCoord5.z);
    ...
    }
  2. 所以结构体v2f中的ase_texcoord2存储的就是UVSet=1

    1
    2
    3
    4
    5
    6
    7
    8
    fixed4 frag (v2f i ) : SV_Target
    {
    ...
    float4 texCoord5 = i.ase_texcoord2;
    ...
    float2 lerpResult7 = lerp( ( uv_MainTexture + appendResult6 ) , temp_cast_0 , texCoord5.z);
    ...
    }
  3. 结构体v2f中的ase_texcoord2是从appdataase_texcoord1赋值过来的

    1
    2
    3
    4
    5
    6
    v2f vert ( appdata v )
    {
    ...
    o.ase_texcoord2 = v.ase_texcoord1;
    ...
    }
  4. 还需要注意到结构体appdata中的ase_texcoord.zw并没有被赋值(整个代码ase_texcoord只使用过一次)

    1
    2
    3
    4
    5
    6
    v2f vert ( appdata v )
    {
    ...
    o.ase_texcoord1.xy = v.ase_texcoord.xy;
    ...
    }

粒子系统设置

  1. 勾选Custom Vertex Streams并按顺序添加UV2Custom1.xyzw

    • UV2(TEXCOORD0.zw):初始化appdata中的ase_texcoord.zw,对应代码解析的第四步
    • Custom1.xyzw(TEXCOORD1.xyzw):将下一步的Custom1.xyzw对应上appdata中的ase_texcoord1,对应代码解析的第三步
    1
    2
    3
    4
    5
    6
    7
    8
    struct appdata
    {
    float4 vertex : POSITION;
    float4 color : COLOR;
    float4 ase_texcoord : TEXCOORD0;
    float4 ase_texcoord1 : TEXCOORD1;
    UNITY_VERTEX_INPUT_INSTANCE_ID
    };

  1. 启动Custom Data,并添加Vector4

使用自定义参数

测试

  1. 经测试,U、V并不一定是只能控制X、Y方向的移动,可以控制其他数据。换句话说,这个节点已经与普通的UV坐标节点没有任何关系了,可以直接当做Vector4来使用。如下图连接,U控制流动,V控制透明,W、T控制X、Y方向的偏移

  1. 如果需要再添加一个参数,可以再添加一个UVSet=2的节点。并在粒子系统中添加Custom2.xyzw(TEXCOORD2.xyzw)

  1. 也可以只用一个UV通道
    • 只有一个UV通道,U、V当做普通的UV坐标了,所以只能用W和T
    • Custom1.x映射到TEXCOORD.z


参考

【Unity】【Amplify Shader Editor】ASE入门系列教程第十课 CustomData与顶点数据流

记一次unity后处理大坑,耗费了我整整一天的时间碰壁,可以说基本上是把里面能踩的坑全踩了

这一天来踩的坑如下:

  1. ASE插件的Post-Processing Stack Tool生成的后处理脚本不支持URP(客服说预计年底能支持URP)

  1. Post Processing插件不支持URP,URP只能使用Volume来实现后处理效果(这条是坑1的根本原因)

  1. PostProcessing插件的组件应该挂载在相机上才能正常工作

  2. 和PostProcessing一样,Volume同样也给了用户自定义的方法,但是比PostProcessing麻烦太多(这个坑花费了我最多时间)

  3. 在配置管线(Universal Render Pipeline Asset)的时候不能简单的新创建一个URP Asset_Render直接添加到Renderer List的后面,这样设置并不起效。如果不知道该如何添加Renderer List,最简单的方法就是使用默认的URP Asset_Render

  4. 在自定义Volume的时候VolumeComponent 得放在一个单独的文件下,不然会警告找不到这个类

  5. 在自定义Volume的时候使用的RenderTargetHandleBlit()时Unity会警告提示过时,应该使用RTHandleBlitter.BlitCameraTexture()。但是如果你只是简单的更换这些属性和方法,最终游戏中并不会实现你编写的Shader效果,因为Blitter.BlitCameraTexture()不支持Core.hlsl,得使用Blit.hlsl

诶,一天下来能碰到这么多坑也是没谁了。


参考

还是感谢网络上各位大佬的无私分享,参考:

Fade In Out Post Process Shader

《Unity Shader 入门精要》从Bulit-in 到URP (HLSL)之后处理(Post-processing : RenderFeature + VolumeComponent)

Unity URP 自定义RendererFeature笔记

Create A Custom URP Post Effect In Unity

Custom Post Processing In Urp

既存のRendererFeatureをURP14のBlitに対応させる

(URP 13.1.8) Proper RTHandle usage in a Renderer Feature


URP实现PostProcessing

最终还是《Unity Shader 入门精要》从Bulit-in 到URP (HLSL)之后处理(Post-processing : RenderFeature + VolumeComponent)扒取了这篇文章的代码实现了简单的效果。其根据我自己的理解简化了代码,使代码更清晰

VolumeComponent

首先是VolumenComponent,继承这个类之后就能在Volume组件上AddOverride

1
2
3
4
5
6
7
8
9
10
11
12
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

[System.Serializable, VolumeComponentMenu("Ding Post-processing/Test")]
public class CustomVolumeComponent : VolumeComponent, IPostProcessComponent
{
public MinFloatParameter Brightness = new MinFloatParameter(1f, 0);//只有使用特定的数值类才能显示到Inspector上
public ClampedFloatParameter Saturation = new ClampedFloatParameter(1f, 0, 1f);
public ClampedFloatParameter Contrast = new ClampedFloatParameter(1f, 0, 1f);
public bool IsActive() => true;
public bool IsTileCompatible() => false;
}

DingRenderPassFeature

这里的两个类RenderPassFeatureRenderPass可以分开写,但是他们两是依赖关系。

并且如果将RenderPass放在外面的话,还要重命名,所以我个人倾向不分开

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

public class DingRenderPassFeature : ScriptableRendererFeature
{
class CustomRenderPass : ScriptableRenderPass
{
private Material material;
private RTHandle source;
private RenderTargetHandle tempTexture;

string m_ProfilerTag = nameof(DingRenderPassFeature);
private CustomVolumeComponent volume;

public CustomRenderPass(Shader shader, RenderPassEvent renderPassEvent)
{
if(shader == null) return;

material = CoreUtils.CreateEngineMaterial(shader);

this.renderPassEvent = renderPassEvent;

var stack = VolumeManager.instance.stack;
volume = stack.GetComponent<CustomVolumeComponent>();
}

public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
{
source = renderingData.cameraData.renderer.cameraColorTargetHandle;
}

public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
if (material is null) return;

// 设置m_ProfilerTag之后,在FrameDebugger上就可以更具这个名称来搜索(设置成其他名字也可以,但是为了对应上管线设置,还是设置成PassFeature方便观察)
CommandBuffer cmd = CommandBufferPool.Get(m_ProfilerTag);

material.SetFloat("_Brightness", volume.Brightness.value);
material.SetFloat("_Saturation", volume.Saturation.value);
material.SetFloat("_Contrast", volume.Contrast.value);

RenderTextureDescriptor desc = renderingData.cameraData.cameraTargetDescriptor;
desc.depthBufferBits = 0;
desc.msaaSamples = 1;

cmd.GetTemporaryRT(tempTexture.id, desc);
Blit(cmd, source, tempTexture.Identifier(), material, 0);
Blit(cmd, tempTexture.Identifier(), source);
cmd.ReleaseTemporaryRT(tempTexture.id);

// 如果不希望看到过时警告就使用这个,但是对应的Shader也要改成支持Blit.hlsl的
// Blitter.BlitCameraTexture(cmd, source, source, material, 0);

context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}

public override void OnCameraCleanup(CommandBuffer cmd)
{

}
}

[System.Serializable]
public class Settings{
public RenderPassEvent Event = RenderPassEvent.AfterRenderingTransparents;
public Shader shader;
}

public Settings settings = new Settings();
CustomRenderPass m_ScriptablePass;

public override void Create()
{
m_ScriptablePass = new CustomRenderPass(settings.shader, settings.Event);
}

public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
if (settings.shader is null) return;

renderer.EnqueuePass(m_ScriptablePass);
}
}

Shader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
Shader "Unlit/Chapter12-BrightnessSaturationAndContrast"
{
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_Brightness ("Brightness", Float) = 1.5
_Saturation("Saturation", Float) = 1.5
_Contrast("Contrast", Float) = 1.5
}
SubShader
{
Tags { "RenderPipeline" = "UniversalPipeline" }
ZTest Always Cull Off ZWrite Off
//基本是后处理shader的必备设置,放置场景中的透明物体渲染错误
//注意进行该设置后,shader将在完成透明物体的渲染后起作用,即RenderPassEvent.AfterRenderingTransparents后

Pass
{
HLSLPROGRAM
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
#include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"

#pragma vertex vert
#pragma fragment frag

CBUFFER_START(UnityPerMaterial)
half _Brightness;
half _Saturation;
half _Contrast;
CBUFFER_END

TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex);

struct a2v{
float4 vertex : POSITION;
float4 texcoord : TEXCOORD0;
};

struct v2f {
float4 pos : SV_POSITION;
half2 uv: TEXCOORD0;
};

v2f vert(a2v v) {
//appdata_img在URP下不能使用,保险起见自己定义输入结构体
v2f o;

o.pos = TransformObjectToHClip(v.vertex);

o.uv = v.texcoord;

return o;
}

half4 frag(v2f i) : SV_Target {
half4 renderTex = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);

// Apply brightness
half3 finalColor = renderTex.rgb * _Brightness;
//亮度的调整非常简单,只需要把原颜色乘以亮度系数_Brightness即可

// Apply saturation
half luminance = 0.2125 * renderTex.r + 0.7154 * renderTex.g + 0.0721 * renderTex.b;
half3 luminanceColor = half3(luminance, luminance, luminance);
//通过对每个颜色分量乘以一个特定的系数再相加得到一个饱和度为0的颜色值
finalColor = lerp(luminanceColor, finalColor, _Saturation);
//用_Saturation属性和上一步得到的颜色之间进行插值

// Apply contrast
half3 avgColor = half3(0.5, 0.5, 0.5);
//创建一个对比度为0的颜色值(各分量均为0.5)
finalColor = lerp(avgColor, finalColor, _Contrast);
//使用_Contrast属性和上一步得到的颜色之间进行插值

return half4(finalColor, renderTex.a);
}

ENDHLSL
}
}
FallBack "Packages/com.unity.render-pipelines.universal/FallbackError"
}

设置

代码都准备好后就可以设置了

  1. 找到当前项目正在使用的管线设置

  2. 然后添加我们自定义的渲染规则(名称就是RenderPassFeature的类名)

  3. 添加shader

  1. 添加Volume组件,并创建一个预设
  2. 按照VolumeComponent添加volume


总结

最后实现起来其实并不难,步骤也不是很多。但其中的坑实在是太多了,耽搁了很多时间。

遗憾的是现在的我对Shader的操作现在还暂时停留在图形编辑,Shader代码还不怎么会。ASE生成的Shader也不支持URP的后期处理,所以暂时还无法消除代码中的过期警告。后续学习了如何编写Shader代码之后再来解决这个问题吧。

1
Assets\Volume\Scripts\SourceCode1\DingRenderPassFeature.cs(11,17): warning CS0618: 'RenderTargetHandle' is obsolete: 'Deprecated in favor of RTHandle'

如何改Shader,参考这篇文章警告を消す

需要多一条引用

1
#include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"

后记:

突然记起来,之前在unity官方教程中学习过后处理的一点方法,如果只是简单的想要将Shader应用到场景中的物体上,可以按照Shader Graph 遮挡剔除的方法不使用代码直接将Shader应用到物体上。

具体做法:

在URP_Asset_Renderer上添加Render Objects(Experimental),然后设置相应的名称、渲染时机、应用到物体的Layer、需要使用的材质。其他的具体设置可以点击上方的帮助查看官方文档

自己总结的小规律

数据类型

在Shader中,共有4种数据类型:

  • 数学数据类型:floatintbool;向量:Vector2Vector3Vector4;矩阵:float2×2float3×3float4×4
  • UV坐标
  • Texture纹理:人物(物体)蒙皮、法线、Mask遮罩(黑白分明有规则的图案)、Noise噪声(黑白混乱的图案)
  • 像素点的世界坐标

尤其需要注意UV坐标和纹理这两类:

数学数据类型、世界坐标自不用说,在学习的过程中经常容易把UV坐标、蒙皮、Mask遮罩搞混。
尤其是UV坐标,UV坐标在ShaderGraph中是以图片展示其每个像素的数值,但其实UV坐标与Texture完全不一样。

  • UV坐标是用于映射2D纹理到3D模型的坐标,举例来说:UV坐标是中介,类似地图,2D纹理需要根据UV坐标找到在3D模型上自己应该对号入座的位置
  • 物体蒙皮:草地、金属棒球棒、墙面等纹理与形状关系不大的纹理可以随意复用;但是人体蒙皮的纹理与形状强关联,就不能随意复用
  • 法线:一般也是人体这种与形状关联性强的纹理需要使用,用来计算光照
  • Mask遮罩:黑白分明且有规则的图案,通常用来裁剪其他纹理,因为是黑白分明的图案,所以在计算的时候能发酵出很奇妙的结果
  • Noise噪声:黑白混乱的图案,通常与其他纹理组合,无规则的黑白图案能实现随机的效果

每个作用不一样,在计算的时候要各司其职,不要混淆

总之

  • UV坐标是一个正经的二维正方形,初始状态左下角为(0, 0),右上角为(1, 1)。其主要作用是告诉纹理该如何对号入座到3D模型上
  • 纹理存储着纹理数据,每个像素点上存储着RGBA

用类来区分UV坐标和纹理:

1
2
3
4
5
6
7
8
9
10
11
12
class UV
{
int u;
int v;
}

class Texture
{
int u;
int v;
Vector4 color; // `RGBA`颜色数据
}

显隐/透明

显隐

该状态为默认状态。只有两个选择要么显示,要么隐藏。

  • Alpha(float):与阈值比较,控制物体显隐
  • Alpha Clip Threshold(float):显影阈值,Alpha大于这个数就显示,Alpha小于这个数就隐藏

除了需要设置Alpha外,还需要设置阈值。阈值没有默认值,如果不设置阈值,就无法控制显隐。

透明

需要将Surface设置为Transparent

  • Alpha(float):范围为[0, 1],从0完全透明,到1完全显示。如果设置了阈值需单独与阈值比较,比较方式与显隐一样
  • Alpha Clip Threshold(float):显影阈值,Alpha大于这个数就显示,Alpha小于这个数就隐藏

只需要设置Alpha。如果设置了阈值,则还需与阈值相比较,控制显隐。


UV坐标

UV坐标是用于映射2D纹理到3D模型的坐标。在3D模型上的每个顶点都有一个对应的UV坐标,它告诉引擎在纹理上的哪个位置找到该顶点的颜色。

  • UV坐标的范围通常是从(0,0)到(1,1)。左下角是原点(0,0),右上角是(1,1)。

  • 在三角形上,UV坐标会在三个顶点之间插值,以确保纹理正确地贴在整个三角形表面上。


节点

运算本身并不难,难的是需要将运算与图形相结合,并且不要把关键性的几个概念搞混

========数学运算符========

——加乘减除——

Add(相加):叠加两个纹理

将两个Texture相加,通常是将一个纹理与遮罩(Mask)相加:

  • 白色RGB为1:同理,使颜色更亮

  • 黑色RGB为0:任何数加0等于本身,所以黑色部分显示内容不变

Multiply(乘法)

  • 数值乘法:如下图

  • 纹理乘法:与遮罩相乘可以裁剪图片;与颜色相乘可以叠加颜色

NOTE:单数值乘法可交换位置,但是矩阵乘法不可交换位置

Subtract(减法)

更改SurfaceTransparent可以设置透明度百分比,而不是单纯的显示/透明

这里只是一个扩展,继续我们的学习,将Surface改回Opaque

设置透明通道阈值Alpha Clip Threshold = 0,意味着当Alpha小于阈值使就会隐藏

如下图所示,物体的每个像素都会计算一遍这个减法,如果该像素的Y坐标减去1.75小于0,就会变透明

Divide(除法):设置锐利度

红框①的内容:

  1. 原本U输出的内容是:从左到右逐渐远离0到达1
  2. Float1 = 0.5f相减:
    • 左边:0 - 0.5 = 0.5,依然是黑色
    • 中间:0.5 - 0.5 = 0,变为黑色
    • 右边:1 - 0.5 = 0.5,变为灰色

绿框②的内容:

  1. Float2 = 0相除:
    • 左边:0 / 0 = 0,依然是黑色
    • 中间:0 / 0 = 0,依然是黑色
    • 中间偏右:0.00001 / 0 = 1,变为白色
    • 右边:0.5 / 0 = 1,变为白色

除以0时,若分子小于等于0 => 商为负无限大;若分子大于0 => 商为无限大。不建议除以0

从下图可以看出除法一般是用来设置锐利度的

从下图可看出除法在某些情况下还与光照有关

——比较——

Abs(绝对值)

  • input < 0 => output = -input
  • input = 0 => output = 0
  • input > 1 => output = input

Tips:

Relay节点没有任何操作,只是为了Debug出图形

Clamp(限制):控制显示的区域

  • input < Min => output = Min
  • Min < input < Max => output = input
  • input > Max => output = Max

将输入的值,控制在范围内

如果与纹理坐标配合,就能控制显示的区域

Tips:

需要与Scale节点区分开

  • Scale:裁剪掉其他区域
  • Clamp:其他区域依然显示,只是显示的不是原来的纹理,而是单色

Saturate(饱和):归一化

  • input < 0 => output = 0
  • 0 < input < 1 => output = input
  • input > 1 => output = 1

常于Lerp节点配合

从下图可以看出,在使用Saturate节点之后,Y坐标大于1的值也是按1来计算的

Sign(符号):硬化

  • input < 0 => output = -1
  • input = 0 => output = 0
  • input > 0 => output = 1

Step(比较):硬化

  • A<B => output = 1
  • A>B => output = 0

Floor(去尾):向下取整

Fract(取小数)

内部所作的事类似于output = input - Floor(input)

——限制范围——

Remap(映射):归一化

数据区间映射,将[Omin, Omax]上每个数映射到区间[Nmin, Nmax]上
$$
N_{xy} = \frac{N_{max} - N_{min}}{O_{max} - O_{min}} \times (O_{xy} - O_{min}) + N_{min}
$$

上述公式的Latex表达式:

1
N_{xy} = \frac{N_{max} - N_{min}}{O_{max} - O_{min}} \times (O_{xy} - O_{min}) + N_{min}

为方便理解,这里我们使用浮点数代替二维数,运作方式和结果是一样的
$$
\begin{split}
output &= \frac{1 - 0}{2 - 1} \times (1.5 - 1) + 0 \
&= 1 \times 0.5 \
&= 0.5
\end{split}
$$
最后输出的结果是0.5,与右边的颜色一样。

其实这里可以看出来,这里的数据很友好。Float1的值正好将[1, 2]映射到[0, 1]上了

  • 1 对应 0
  • 1.5 对应 0.5
  • 2 对应 1

小技巧:

在控制数值范围的时候,只用考虑

  • 最小值如何才能达到目标最大值
  • 最大值如何才能达到目标最小值

因为我们已经把最极端的两个例子给列出来了,其他的自然会满足需求

Lerp(线性插值):融合两个纹理

融合两个纹理

—-反转—-

One Minus(1-):1-input

out = 1 - input,对与UV坐标和遮罩很有用,可以将Texture纹理变为负片

  • UV坐标:翻转两次,右上角翻转到左下角,左上角翻转到右下角
  • 遮罩Mash:黑白颜色翻转

  • 纹理:变为负片

Negate(相反):相反数

将输入的值变为相反数

Tips:

  • 世界坐标输出的Global Preview为球形

  • UV坐标和纹理是方形

——三角函数——

Tan(正切)

ATan(反正切)

——其他——

Scale(缩放):裁剪区域

这个节点的Global Preview显示有问题,改变缩放数值后需要重新连接输入接口才能正常显示

  • UV坐标:改变纹理缩放大小(与Tilling作用一样,再与offset可以选择展示的位置)
  • 纹理:改变颜色的亮度

========UV坐标========

Texture Coordinates(UV坐标)

纹理坐标:定义纹理如何映射到3D资源上

  • Tiling:纹理填充
  • Offset:偏移参数

从下图可看出,设置Tiling为(2,2)之后,图片纹理在水平和垂直方向上各增加了一个纹理(前提是需要将Wrap Mode设置为Repeat

输出完整的纹理坐标

  • 左下角为(0, 0),为黑色
  • 左上角为(0, 1),为绿色
  • 右下角为(1, 0),为红色
  • 右上角为(1, 1),为黄色

这里的颜色只是为了方便表述描绘二维数值的大小,没有什么特殊的含义,不要被颜色给迷惑了(顺带一提颜色正好对应着RGBA中的(R, G)

输出纹理的横坐标

  • 左边为0,右边为1
  • 从左到右逐渐远离0,到达1

输出纹理的纵坐标

  • 下边为0,上边为1
  • 从下到上逐渐远离0,到达1

Panner(平移):平移UV坐标

平移节点:根据Time按指定Speed移动UV坐标。若没指定速度,则使用默认速度

NOTE:使用的Texture必须将Wrap Mode设置为Repeat

========顶点========

Vertex Position

顶点位置坐标节点,输出每个顶点相对物体原点的坐标

  • 对于基础形状,如球形的最上方为(0, 0.5, 0)
  • 对于导入的物体,如人物,最上方为(0, 人物高度, 0)(并不是所有物体都是归一化的)
  • 每个像素点的值不会随着物体的移动、旋转改变,但是缩放会改变

========Time========

Time Parameters(时间)

时间参数节点:输出Unity内部经过的时间(以秒为单位)

本章将详细介绍StateMachine<TState>,官方的轻量免费版也提供了源代码,路径:Assets/Plugins/Animancer/Utilities/FSM

该类由partial修饰,在四个文件中存在

  • StateMachine1.cs
  • StateMachine1.InputBuffer.cs
  • StateMachine1.StateSelector.cs
  • StateMachine1.WithDefault.cs

StateMachine1.cs

1
2
3
4
[Serializable]
public partial class StateMachine<TState> : IStateMachine
where TState : class, IState
{

实现接口、限制泛型类

1
2
3
[SerializeField]
private TState _CurrentState;
public TState CurrentState => _CurrentState;

序列化当前状态

1
2
public TState PreviousState => StateChange<TState>.PreviousState;
public TState NextState => StateChange<TState>.NextState;

引用静态访问点存储的上一个状态和正在进入的状态

1
2
3
4
5
6
7
8
9
public StateMachine() { }				// 因为是基类,这个方法只是起到一个占位的作用
public StateMachine(TState state)
{ // 创建一个新的结构体,三个参数分别是(状态机,上一个状态,下一个状态)
using (new StateChange<TState>(this, null, state)) // 因为这个结构体是静态访问点,所以赋完值后可立即销毁
{
_CurrentState = state; // 设置当前状态为下一个状态
state.OnEnterState(); // 并立即进入这个状态,还记得OnEnterState是干嘛的吗——启动脚本enable = true
}
}

构造函数,创建一个新的状态机,初始化_CurrentState的值,并立即进入这个状态

因为后续会频繁的使用using(new StateChange<TState>()){},这里解释一下:只有发起改变状态请求的时候才会使用到这个结构体,其他时候StateChange<TState>()._Current都是只有起到一个存储状态的作用

1
2
3
4
5
6
public virtual void InitializeAfterDeserialize()
{
if (_CurrentState != null)
using (new StateChange<TState>(this, null, _CurrentState))
_CurrentState.OnEnterState();
}

在序列化之后,尽快调用此方法,以正确的初始化_CurrentState。可以看到和上面的构造函数类似,因为序列化在Inspector上引用了CurrentState所以这里可以直接将其传递进来并初始化

拓展:

UnityEngine.ISerializationCallbackReceiver接口无法实现自动初始化,这个接口的回调有很多unity的方法用不了,如.OnEnterState()里的Behaviour.enabled

1
2
3
4
5
6
7
8
9
10
11
12
13
public bool CanSetState(TState state)		// 判断当前状态机是否可进入指定状态
{
using (new StateChange<TState>(this, _CurrentState, state))
{
if (_CurrentState != null && !_CurrentState.CanExitState) // 如果当前状态不能退出,返回false
return false;

if (state != null && !state.CanEnterState) // 如果目标状态不能进入,返回false
return false;

return true;
} // 在using结束的时候调用StateChange<TState>.Dispose(),确保线程静态成员_CUrrent状态正确
}

提一嘴:这里很容易将状态和状态机搞混。CanSetState是针对状态机的,而.CanExitState.CanEnterState是针对状态的。

如果对状态机状态的方法还有什么不清楚的,可以查看IStateMachineIState接口

1
public TState CanSetState(IList<TState> states) { }	// 这个方法就是遍历并调用了上面的CanSetState方法,就不贴代码了
1
2
3
4
5
6
7
8
9
10
11
public void ForceSetState(TState state)			// 强制转换状态
{
using (new StateChange<TState>(this, _CurrentState, state))
{
_CurrentState?.OnExitState(); // 立即执行当前状态的退出方法(disable组件)

_CurrentState = state; // 更新当前状态

state?.OnEnterState(); // 立即执行新状态的进入方法(enable组件)
}
}
1
2
3
4
public bool TrySetState(TState state) {}
public bool TrySetState(IList<TState> states) {}
public bool TryResetState(TState state) {}
public bool TryResetState(IList<TState> states) {}

这些方法都是在CanSetStateForceSetState之上扩展,就不分析代码了。总的来说,一个判断,三种转换

1
2
3
4
5
public bool CanSetState(TState state) {}   // 判断当前状态是否能退出,目标状态是否能进入,并返回bool值结果

public bool TrySetState(TState state) {} // 若当前状态能退出,且目标状态能进入,则进入目标状态(目标状态为非当前状态)
public bool TryResetState(TState state) {} // 同上,但不限制目标状态,即可以重复当前状态
public void ForceSetState(TState state) {} // 强制转换,无论当前状态、目标状态是否满足条件

最后是一个Unity.Editor在Inspector上显示的方法,暂时没看到效果,后续再补


StateMachine1.InputBuffer.cs

这个文件夹中存放的是InputBuffers,即输入缓冲器。

作用:不是简单的改变状态失败就直接放弃了,而是还会尝试一小段时间。

类比:连续跳跃的时候,快要落地但实际上还没有落地的时候按下跳跃键,也能进入跳跃状态。

InputBuffer<TStateMachine>

包含在StateMachine<TState>类中的一个泛型类(并不是子类,只是包含关系,两个甚至可以说没有任何关系)

缓存一个状态,每当Update(float)的时候尝试进入这个状态,直到TimeOut超时

1
2
public class InputBuffer<TStateMachine> where TStateMachine : StateMachine<TState>
{

限制传进来的类

1
2
3
4
5
6
7
8
9
10
11
private TStateMachine _StateMachine;		// 缓存目标状态机

public TStateMachine StateMachine
{
get => _StateMachine;
set
{
_StateMachine = value; // 设置目标状态机,并清除之前的信息
Clear();
}
}
1
2
3
public TState State { get; set; }			// 需要进入的目标状态
public float TimeOut { get; set; } // 倒计时,小于0就超时
public bool IsActive => State != null; // 缓冲器状态,当目标状态为空时,就停止转换
1
2
public InputBuffer() { }				// 构造函数,占位用
public InputBuffer(TStateMachine stateMachine) => _StateMachine = stateMachine; // 构造函数,指定状态机
1
2
3
4
5
public void Buffer(TState state, float timeOut)
{
State = state; // 设置目标状态和缓冲时间
TimeOut = timeOut;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protected virtual bool TryEnterState() => StateMachine.TryResetState(State);	// 虚函数,可重构添加其他条件
public bool Update() => Update(Time.deltaTime); // 在设置目标状态和缓冲时间之后再调用该方法
public bool Update(float deltaTime) // 尝试进入`state`状态,如果超时,就会调用Clear()方法
{
if (IsActive)
{
if (TryEnterState())
{
Clear();
return true;
}
else
{
TimeOut -= deltaTime;
if (TimeOut < 0)
Clear();
}
}
return false;
}
1
2
3
4
5
public virtual void Clear()			// 清除任务
{
State = null;
TimeOut = default;
}

InputBuffer

1
2
3
4
5
public class InputBuffer : InputBuffer<StateMachine<TState>>
{
public InputBuffer() { }
public InputBuffer(StateMachine<TState> stateMachine) : base(stateMachine) { }
}

继承自InputBuffer<TStateMachine>,并确定了泛型参数为StateMachine<TState>,这个没好说的,就是指定了状态机

使用方法:

  1. 需要在update()中调用输入缓冲器的_InputBuffer.Update()
  2. 在需要改变状态的时候使用_InputBuffer.Buffer(_Equip, _InputTimeOut)

StateMachine1.StateSelector.cs

这个文件夹中放置的是StateSelector, 即状态选择器

该类提供了一种简单的方法来管理潜在状态的优先级列表

ReverseComparer<T>

一个泛型类(并不是子类,只是包含关系,两个甚至可以说没有任何关系)

1
2
3
4
5
6
public class ReverseComparer<T> : IComparer<T>
{
public static readonly ReverseComparer<T> Instance = new ReverseComparer<T>(); // 饿汉单例
private ReverseComparer() { } // 私有构造函数,不需要用户创建实例
public int Compare(T x, T y) => Comparer<T>.Default.Compare(y, x); // 实现接口,定义比较方法,参数换位置了
}

这里需要注意的是

  • 不需要用户实例这个类,所以将构造函数私有化了

  • 比较的方法实用的是Compare(),在传参的时候,将两个参数的位置换了(第一个参数y小于x时返回-1)

    也就是说最终的效果是返回-1,y小x大;返回-1时,x小y大

StateSelector

包含在StateMachine<TState>类中的类

1
2
3
4
5
6
7
public class StateSelector : SortedList<float, TState>				// 继承`SortedList<float, TState>`类
{
public StateSelector() : base(ReverseComparer<float>.Instance) { } // 构造函数,并且将参数传递给父类的构造
public void Add<TPrioritizable>(TPrioritizable state) // 定义一个泛型方法
where TPrioritizable : TState, IPrioritizable // TPrioritizable必须满足TState和IPrioritizable
=> Add(state.Priority, state); // 调用了基类的Add方法
}
  • 继承SortedList<float, TState>类,所以拥有这个基类的所有属性,如Add
1
public virtual void Add(object key, object value);		// 基类SortedList的Add方法

实际上在使用的时候可以使用简单的枚举来配分动作的优先级,没必要用这种


StateMachine1.WithDefault.cs

默认状态机,其实就是添加了一个默认状态,然后针对这个默认状态写了初始化和转换成默认状态方法

就是方便用户手册介绍产品使用的,如果自己使用的话完全可以重新写一个

1
2
3
4
5
6
7
8
9
10
11
12
[SerializeField]
private TState _DefaultState; // 默认状态
public TState DefaultState
{
get => _DefaultState;
set
{
_DefaultState = value;
if (_CurrentState == null && value != null)
ForceSetState(value);
}
}

StateChange<TState>结构体

作用:查看状态变化细节的静态访问点

要看懂这个结构体,得先搞清楚以下几点:

  1. 他的核心是_Current这个线程静态属性,所有其他属性都是围绕他而展开的
  2. 这个结构体的用法:只有在StateMachine使用IState方法(即正在改变状态)的时候才需要创建这个结构体,结束后就会弃用掉这个临时的结构体(但由于_Current是静态的,所以_Current是还存在的)
1
2
public struct StateChange<TState> : IDisposable where TState : class, IState
{

限制类型参数,继承IDisposable接口

1
2
[ThreadStatic]
private static StateChange<TState> _Current;

当前状态变化,设置成了线程静态,所以每个线程都有自己的副本,使得整个系统是线程安全的

线程静态成员特点:

  1. 多个线程访问并改变 _Current 的值时,每个线程看到的是它自己的 _Current 副本,因此一个线程对 _Current 的修改不会影响其他线程。
  2. 每个线程在其生命周期内对 _Current 的任何修改只对其自身有效。当线程执行完毕后,该线程的 _Current 副本就会被销毁。
  3. 当所有线程都执行完毕后 _Current 的最终值取决于最后一个修改它的线程的状态,如果没有任何线程正在进行状态更改 _Current 将保持其默认值(通常是 null 或者初始状态)。
1
2
3
private StateMachine<TState> _StateMachine;		// 当前发生状态变化的状态机实例
private TState _PreviousState; // 正在被改变出去的状态
private TState _NextState; // 正在进入的状态
1
2
3
4
public static bool IsActive => _Current._StateMachine != null;					// 是否正在发生变化
public static StateMachine<TState> StateMachine => _Current._StateMachine; // 设置上面字段的访问器
public static TState PreviousState => _Current._PreviousState;
public static TState NextState => _Current._NextState;

这里可以看到只提供了PreviousStateNextState两个状态供外界访问,外界没有访问_Current的方法,因为没有必要。

通过在CanExitState的打印这三个可以看出,_Current就是PreviousState。如Idle->Jump

  1. 在按下空格的一瞬间:PreviousStateIdleNextStateJump

  2. 起跳后系统每帧都在判断是否能从Jump->Idle,这段期间PreviousState一直是JumpNextState一直是Idle

  3. 如果在空中的时候又按了一下空格键,系统会判断能否Jump->Jump,在你按下的这一帧PreviousStateNextState都是Jump

可以看出来这里的状态是相对于帧的状态,并不是指上一个状态块

1
2
3
4
5
6
7
8
internal StateChange(StateMachine<TState> stateMachine, TState previousState, TState nextState)
{
this = _Current;

_Current._StateMachine = stateMachine;
_Current._PreviousState = previousState;
_Current._NextState = nextState;
}

internal,只允许在Animancer.FSM内访问

构造函数用于设置当前状态变化的信息。它首先复制当前的StateChange<TState>到this,然后更新_Current以反映新的状态变化。

1
2
3
4
public void Dispose()
{
_Current = this;
}

实现IDisposable接口,在StateMachine<TState>会常使用using来创建结构体,在using结束时将自己再存储在线程静态中。

其实整个的作用就是为了保证有且只有一个静态_Current并且其状态是最新的

1
2
3
4
5
using (new StateChange<TState>(this, null, state))
{
_CurrentState = state;
state.OnEnterState();
}

拓展:IDsposable的作用:using 块结束或其中的代码抛出异常 ,Dispose 方法将被自动调用


IStateMachine接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public interface IStateMachine
{
object CurrentState { get; } // 当前激活的状态
object PreviousState { get; } // 上一个状态
object NextState { get; } // 下一个状态
bool CanSetState(object state); // 当前是否可以进入指定的状态
object CanSetState(IList states); // 返回列表中第一个能进入的状态
bool TrySetState(object state); // 尝试设置某个状态,成功返回true。如果传进来的是当前状态,会立即返回true。想要再次播放当前状态可以使用`TryResetState(object)`
bool TrySetState(IList states); // 如果当前状态在实参中,则不做任何事直接返回true。想再次播放,方法同上
bool TryResetState(object state); // 尝试进入指定状态,此方法不会判断实参是否已经是当前状态
bool TryResetState(IList states); // 同上
void ForceSetState(object state);
#if UNITY_ASSERTIONS // 打包时不会编译if内的代码
bool AllowNullStates { get; } // 当前状态是否可为空
#endif
void SetAllowNullStates(bool allow = true);
#if UNITY_EDITOR
int GUILineCount { get; }
void DoGUI();
void DoGUI(ref Rect area);
#endif
}

void ForceSetState(object state):强制改变状态

调用CurrentStateIState.OnExitState,然后将CurrentState改变成参数状态,并调用其IState.OnEnterState

IPrioritizable接口

状态选择器使用的

1
2
3
4
public interface IPrioritizable : IState
{
float Priority { get; }
}

总结:

​ 第一次研究源码,开始的时候确实会被吓着,觉得有点困难什么的。但实际看完下来发现和之前学的状态机核心工作原理是差不多的,只是在这基础上完善了很多方法,如:设定进入、离开状态的方法;使用接口规范代码。看完之后发现其实理解的还是很通透的。

​ 这次的奇妙之旅最大的搜获可能就是理解了一个完整的项目应该是怎么样的框架结构。要尽可能的使用接口和继承,达到解耦的效果,使代码更容易维护。

​ 学习的路还很长,这次状态机的源码并不是Animancer的核心源码,只是其中的一个小部分而已,并且有限状态机也并不是很难的一个模型。后续还需要继续研究源码,了解更多的编程技巧和模型框架。


示例补充

除了 StateMachine<TState>外,还提供了一个StateMachine<TKey, TState>
后者得花费更多时间和精力维护,但他的优势在于可以抽象和需要序列化当前状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Character : MonoBehaviour
{
[SerializeField] private State _Idle;

public State Idle => _Idle;
public StateMachine<State> FSM { get; private set; }
protected virtual void Awake()
{
FSM = new StateMachine<State>(_Idle); // 初始化
}
}

public class SomethingElse
{
public void EnterIdle(Character character)
{
character.FSM.TryEnterState(character.Idle); // 改变状态
}
}

确实比较鸡肋

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Character : MonoBehaviour
{
[SerializeField] private State _Idle;
[SerializeField] private State _Walk;

public enum Key { Idle, Walk }

public StateMachine<Key, State> FSM { get; private set; }

protected virtual void Awake()
{
FSM = new StateMachine<Key, State>();
FSM.Add(Key.Idle, _Idle); // 需要注册状态
FSM.Add(Key.Walk, _Walk);
FSM.ForceSetState(Key.Idle, _Idle); // 进入默认状态
}
}

public class SomethingElse
{
public void EnterIdle(Character character)
{
character.FSM.TryEnterState(Key.Idle); // 只用访问FSM,直接使用枚举选择状态
}
}

官方的轻量免费版也提供了源代码,路径:Assets/Plugins/Animancer/Utilities/FSM

在本文中,标题将严格按照父类——子类的顺序排布

先列代码,后面是讲解。了减少文本量,不让文章看起来臃肿,有些代码不会贴

设计思路

组件布局

Character组件是任何角色的核心,无论是玩家、敌人、NPC、人、动物、怪物还是机器人。
游戏中的所有角色应共享相同的标准脚本,该脚本包含对其每个其他组件的引用(通常很少有自己的逻辑)。

引用以下内容:

  • 动画系统:AnimancerComponentAnimator
  • 状态机:有限状态机、行为树
  • 常见功能:Rigidbody,角色 属性、背包、生命值等

拥有这个中心脚本,意味着其他脚本可以简单地拥有对Character的一个引用,并通过它访问所有其他组件。

动画播放方式

一句话来说就是,将组件当做一个状态,OnEnable的时候播放动画,在动画播放完的时候退出动画

Animancer的有限状态机采用在MonoBehaviourOnEnable中播放动画,这样在控制组件的开启与关闭enable就可以控制这个动画的播放


StateMachine<TState>

泛型类,需要传递StateBehaviour类,partial,在多个文件夹中存在

默认状态机

StateMachine<TState>中有一个默认的状态机StateMachine<TState>.WithDefault
如果没有自定义状态机的需求可以直接使用这个

特点:

  • 不会跟踪除CurrentState以外的任何状态,PreviousStateNextState没有作用
  • 被序列化之后可以引用CurrentStateDefaultState,如果CurrentState没有被设置值,那么状态机将立即进入DefaultState所以也可以理解成,如果设置了CurrentState,那么状态机会立刻进入这个状态
  • 还具有.ForceSetDefaultState(TState)方法,通常用在动画结束时使用,时动画回到默认状态

这里先简单介绍一下默认状态机,StateMachine<TState>的详细源码将在下一章节展开


Character

1
2
3
[DefaultExecutionOrder(-10000)]// Initialize the StateMachine before anything uses it.
public sealed class Character : MonoBehaviour
{

启动时机很早,不会再衍生类,能挂载到物体上

序列化:引用AnimancerComponent、状态机、血量、背包、武器等

1
2
3
4
private void Awake()
{
StateMachine.InitializeAfterDeserialize(); // 反序列化之后,初始化状态机
}

该类主要是当做所有控制的中心脚本,起到牵线搭桥的作用,本身并没有多少逻辑,只有一个初始化方法


StateBehaviour

1
2
public abstract class StateBehaviour : MonoBehaviour, IState
{

继承自Monobehaviour但本身是abstract类,所以无法被挂载到物体上

1
2
3
4
5
6
7
8
9
10
public virtual void OnEnterState()
{
enabled = true;
}
public virtual void OnExitState()
{
if (this == null)
return;
enabled = false;
}

该类主要的作用是在进入时启动脚本,在退出时关闭脚本:

除了关闭开启脚本外,该脚本还实现了接口的CanEnterStateCanExitState属性,默认永远都是true,后续有需求可自定义

1
2
3
4
5
6
7
8
9
#if UNITY_EDITOR
protected virtual void OnValidate()
{
if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode)
return;

enabled = false;
}
#endif

挂载脚本的时候将脚本的设置为非启用。这么做是因为要将该组件设置成一个状态,这个状态是否启用就是通过enable管理并体现的的

CharacterState

1
2
public abstract class CharacterState : StateBehaviour
{

与父类一样,无法挂载到物体上

1
2
[System.Serializable]
public class StateMachine : StateMachine<CharacterState>.WithDefault { }

定义了一个状态机类,注意:这里只是定义,并没有实例化,在Character中实例化了

那为什么不直接在Character中定义并实例化呢:

  1. StateMachine<>的泛型参数是Character,写在这里方便观看
  2. Character里需要定义一个名称为StateMachine的状态机,如果定义在Character中的话,就只能改一个名字了,如CharacterStateMachine
  3. 这样处理能让Character的代码更加简洁

当然这些原因影响并不大,如果非得写在Character中也不是不行,取决于个人喜好

引用Character(就两行,不贴代码了)

1
2
public virtual CharacterStatePriority Priority => CharacterStatePriority.Low;		// 设定动画的优先级
public virtual bool CanInterruptSelf => false; // 设定动画是否可以被打断
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public override bool CanExitState
{
get
{
// There are several different ways of accessing the state change details:
// var nextState = StateChange<CharacterState>.NextState;
// var nextState = this.GetNextState();
var nextState = _Character.StateMachine.NextState;
if (nextState == this) // 如果下一状态还是自己(即动画还没结束),就返回CanInterruptSelf
return CanInterruptSelf;
else if (Priority == CharacterStatePriority.Low)
return true; // 如果下个状态不是自己(即动画结束了),并且优先级是Low,就返回true
else // 如果下个状态不是自己(即动画结束了),并且就比较下一个状态的优先级
return nextState.Priority > Priority;
}
}

重写了CanExitState方法,重新设定状态的退出方法,

1
2
3
4
5
6
7
8
#if UNITY_EDITOR
protected override void OnValidate()
{
base.OnValidate();

gameObject.GetComponentInParentOrChildren(ref _Character);
}
#endif
  • 因为父类也写在if UNITY_EDITOR内,所以同样也得写在里面
  • 挂载脚本的时候获取Character并赋值给_Character,这样就不用每次手动拖拽了
IdleState
1
2
3
4
5
[SerializeField] private ClipTransition _Animation;		// 引用动画
private void OnEnable()
{
Character.Animancer.Play(_Animation);
}

OnEnable的时候播放引用的动画。

注意:

  • 在游戏启动的时候OnEnable并不会触发,因为该组件的基类会将脚本设置为非启动,所以这里的.Play()并实际上并不会播放动画
  • 将动画设置在OnEnable内还有另一个好处:当状态机启动该脚本时,就会播放动画
ActionState

引用ClipTransition动画

1
2
3
4
5
6
7
8
9
private void Awake()
{
// 绑定事件,当动画播放结束时,将状态设置为默认状态。
_Animation.Events.OnEnd = Character.StateMachine.ForceSetDefaultState;
}
private void OnEnable()
{
Character.Animancer.Play(_Animation); // 绑定事件,当动画播放结束时,将状态设置为默认状态。
}
1
2
3
4
5
// 重新设定优先级,使这个状态为Medium优先级,可被High打断,不可被Low打断。
public override CharacterStatePriority Priority => CharacterStatePriority.Medium;

// 重新设定,使这个状态可以被自身打断。
public override bool CanInterruptSelf => true;

BasicCharacterBrain

用来监控玩家输入

1
2
3
[SerializeField] private Character _Character;		// 引用中心脚本
[SerializeField] private CharacterState _Move; // 引用移动状态
[SerializeField] private CharacterState _Action; // 引用攻击状态
1
2
3
4
5
6
7
8
9
10
private void Update()
{
float forward = ExampleInput.WASD.y; // 是否有按下W
if (forward > 0)
_Character.StateMachine.TrySetState(_Move); // 按下了就TrySetState:当前状态不是目标状态,且目状态满足进入条件
else
_Character.StateMachine.TrySetDefaultState(); // 没按下就设置成默认状态
if (ExampleInput.LeftMouseUp)
_Character.StateMachine.TryResetState(_Action); // 按下左键就转换到攻击状态
}

ExampleInputAnimancer.Examples中的一个数据监测,从命名名称就可以看出作用,源代码也很简单,就不贴了


IState接口

1
2
3
4
5
6
7
public interface IState
{
bool CanEnterState { get; }
bool CanExitState { get; }
void OnEnterState(); // 在进入当前状态的时候会调用
void OnExitState(); // 在退出当前状态的时候会调用
}

bool CanEnterState:当前状态是否能进入

  • StateMachine<TState>.CanSetState.TrySetState.TryResetState检查
  • StateMachine<TState>.ForceSetState不判断,直接进入

bool CanExitState:当前状态是否能退出

  • 判断条件同上

Animancer还提供了DelegateState类:

1
2
3
4
5
6
7
8
9
10
11
public class DelegateState : IState
{
public Func<bool> canEnter;
public virtual bool CanEnterState => canEnter == null || canEnter();
public Func<bool> canExit;
public virtual bool CanExitState => canExit == null || canExit();
public Action onEnter;
public virtual void OnEnterState() => onEnter?.Invoke();
public Action onExit;
public virtual void OnExitState() => onExit?.Invoke();
}

本身并没有实现任何功能,而是简单地为该接口的每个成员提供一个委托,以便在创建状态时分配它们。

CharacterStatePriority枚举

1
2
3
4
5
6
public enum CharacterStatePriority		// 动画优先级
{
Low,// Could specify "Low = 0," if we want to be explicit or change the order.
Medium,// Medium = 1,
High,// High = 2,
}

小结:

  1. 第一次看源码,深刻体会到了一个优秀的架构能让程序更灵活,更有层次能让阅读者更容易理解,但是每个脚本太碎片化了,不容易维护。这个时候,接口的一个作用就体现了,能规定方法。并且充分使用sealedabstract修饰符,能体现每个类的作用

  2. 相比之前写的有限状态机有什么不同:

  • 最让人眼前一亮的还是通过控制脚本组件的启用来完成进入和退出状态的操作,不仅使逻辑代码融入了UnityBehaviour生命周期,还能让开发者直观的看到每个状态的当前情况。有想过如果使用组件开关来实现状态机的进出,会不会影响性能问题。但是不管怎样,每个状态类都需要引用Clip你不得不序列化这个状态类
  • 之前并没有使用接口和父类继承,代码框架不够规范,做了很多重复的工作。每个状态之间太过独立,状态与状态之间的转换变得十分麻烦。如果想要扩展其他状态的话,几乎每个状态都得针对新的状态做出改变,可扩展性不强
  • 引用了一个新的Brain脚本,用来控制每个状态之间的转换,而不是在状态内部控制跳到哪一个状态。每个状态内部只用管理自己是否能进入和退出,不用管什么时候进入。
  • 有三种方式切换状态:TrySetStateTryResetStateForceSetState,与CanEnterStateCanExitState配合能使动作之间的转变更加灵活。之前写的状态机只有类似ForceSetState这一种方式
  1. 关于[System.Serializable]

​ 之前只是简单的知道被这个属性修饰的类可以序列化展示在Inspector窗口上,现在了解到了一些其他的细节:

1
2
3
4
5
6
7
8
9
10
[System.Serializable]
public class Human
{
public CharacterState a;
}

public class Test : MonoBehaviour
{
public Human human;
}

通常我们实例化一个类需要使用 = new()关键字。但是当这个类被[System.Serializable]修饰后,不需要使用new关键字,只要你在MonoBehaviour中声明了Human human,那么你就实例化它了(在Editor Mode的时候就已经被实例化了)

使用注意点

  1. 在初始化阶段暂停了动画,可以使用AnimancerComponent.Evaluate(),将动画立即应用到物体上,否则可能会出现人物还是T-Post状态
  2. 非循环动画在播放完动作后,且后续没有其他动画时,AnimancerState.Time依然在计算,为了避免不必要的性能开销,应该使用AnimancerPlayable.UnpauseGraph暂停动画
  3. 当物体被OnDisable的时候,应该将使用AnimancerPlayable.UnpauseGraph取消动画的暂停。如果不取消,再次启用时系统将会再次初始化一遍。if (_Animancer != null && _Animancer.IsPlayableInitialized) _Ainmancer.Playable.UnpauseGraph();
  4. 在初始化的时候可以使用AnimancerComponent.GetOrCreate(ITransition)来检查AnimancerState是否存在
    • 万向走(且同一方向上即有走又有跑)使用2D Freeform Directional
    • 四向走(多方向)使用2D Freeform Cartesian

基础内容

Clip动画片段

  • AnimationClip:Unity源生的一个类,引用指定的动画Clip。

  • ClipTransition:与AnimationClip类似,不仅能引用指定的Clip,还能使用内置方法管理如何播放这个AnimationClip

  • ITransition:是一个接口,通常unity不允许将接口序列化的展示到Inspector窗口上,但是ITransition已经使用PropertyDrawer做到能耐序列化接口了,只需在字段前面添加[SerializeReference]属性即可

AnimancerState

AnimancerComponent.Play()返回的值——Clip控制器,可以用这个来控制Clip的属性:

  • .NormalizedTime:归一化时间,该Clip的播放的进度
  • .Speed:播放速度,负数可倒放

组件

这里的组件都是Animancer帮我们做的好组件,是代替Unity.AnimatorController的。所以在引用这些组件之后,就不需要配置Controller

AnimancerComponent.Play()

动画控制器,三个参数,播放的动画引用AnimationClipClipTransition(后面将这两个统称为Clip)、过渡时间、播放方式

NamedAnimancerComponent.TryPlay()

  • 可以使用使用名称字符串的形式播放动画,但是在这之前得先使用NamedAnimancerComponent.State.Create(Clip)创建一个状态

  • 也可以直接使用引用NamedAnimancerComponent.Play(Clip)播放动画

  • 将Clip直接放到NamedAnimancerComponent.Animations中时,并不会自动播放,除非勾选自动播放

总结一下:

可以使用.TryPlay()直接使用字符串名称播放指定动画,但是这个动画需要先存入States

SoloAnimation

性能不一定最好,同样的事,传统的Animation同样能做到,并且性能稍微好一点点。

他的用途仅限于需要使用人形或Generic Rig,并且只想播放一个动画的情况


动画速度与时间精度控制

倒放

直接向后播放:Speed设置为-1

从结尾向后播放:NormalizedTime设置为1

暂停

  • 调用AnimancerComponent.Playable.PauseGraph()停止动画,并不是时停,而是会将动画播放到结尾的位置

  • 需要使用.UnpauseGraph()手动退出暂停状态

  • 非循环动画在播放完动作后,AnimancerState.Time依然在计算,照成不必要的性能消耗,应该使用AnimancerComponent.Playable.PauseGraph()停止动画,如果有必要的话还可以使用AnimancerComponent.Evaluate()将动画立即应用到对象上,例如在初始化的时候暂停动画。

  • 从上面这点可以看出,如果暂停了动画并且后续没有动画变化,可以使用暂停,避免性能消耗

时间

如果在动画最开始的时候调用了AnimancerComponent.Playable.PauseGraph(),动画处于暂停状态,并没有应用到人物上,这时就需要使用AnimancerComponent.Evaluate(),将当前的所有动画的状态应用到对象上了

抽帧

AnimancerComponent.Evaluate(value):将时间提前value秒,并立即应用所有的当前状态

1
2
3
4
5
6
7
8
9
10
private void Update()
{
var time = Time.time;
var timeSinceLastUpdate = time - _LastUpdateTime;
if (timeSinceLastUpdate > 1 / _UpdatesPerSecond)
{
_Animancer.Evaluate(timeSinceLastUpdate);
_LastUpdateTime = time;
}
}

拓展:

  1. 使用另外一个脚本,通过距离判断是否执行这个Update方法或这个Component,就可以降低原理玩家的怪物的动画。值得注意的是,Unity调用MonoBehaviour事件方法(如OnEnable或Update)都比在C#中正常调用方法开销更大。所以如果能使用一个单例脚本来管理怪物的动画抽帧,更能提高性能,但是这个方法也更复杂。可以不用每帧都判断,可以每隔10帧判断一下。Renderer.isVisible也是不错的方法
  2. 使用这种方法可能会影响Animation EvnetsAnimancer Events

Root Motion

添加Apply Root Motion属性

开启Root Motion,并不一定是很好的选择,很多游戏并没有开启Root Motion功能

Animancer并没有给我们添加默认的是否运用Root Motion选项,需要我们自己绑定一个变量:

创建一个实现ITransition或继承Transition Type的类,就可以将额外的数据绑定到动画上

1
2
3
4
5
6
7
8
9
10
11
[Serializable]
public class MotionTransition : ClipTransition
{
[SerializeField, Tooltip("Should Root Motion be enabled when this animation plays?")]
private bool _ApplyRootMotion;
public override void Apply(AnimancerState state)
{
base.Apply(state);
state.Root.Component.Animator.applyRootMotion = _ApplyRootMotion;
}
}

  • 使用OnAnimatorMove方法,将Root Motion动画的移动效果让人为可控
  • 使用Root Motion后人物的移动与物理相关,如果人物使用的是Rigidbody控制移动的话,应该将AnimatorUpdateMode设置为AnimatePhysics。这样他就只会被物理更新移动,也就是在FixedUpdate更新而不是Update中

Root Motion重定向

通常我们会将角色的逻辑Component与模型分开,如下

这时就需要使用重定向,将Animator的动作映射到父节点上。不然父节点会留在原地,只有模型会前进

Animancer已经为我们封装好了,使用RedirectRootMotionToCharacterControllerRedirectRootMotionToRigidbodyRedirectRootMotionToTransform脚本,将其放置在模型上并设置相应的AnimatorTarget属性就好了。源码很简单


线性混合

通常需要根据人物的移动速度播放idlewalkrun,Unity源生的解决办法是Blend TreeAnimancer也可以使用并控制动画),Animancer提供了叫做Mixer State(类似于Blen Tree)的方法

使用Blend Tree

引用Bled Tree

ControllerTransition类似ClipTransition可以引用Unity.RuntimeAnimatorController,这里面可以配置Blend Tree

参数控制

引用到Unity.Blend Tree之后,就可以参数来控制动画的播放状态了。Animacer也为我们准备了一个控制器来做这件事。

Float1ControllerTransition一个ScriptableObject类。可以引用指定的Unity.RuntimeAnimatorController,还能控制里面Blend Tree的单浮点混合树的参数

v8.0版本不一样了,详细参考下文的Mixer参数部分。需要注意的是,创建的ScriptableObject的名称需要与Unity.Animator中的参数名称一样

使用方法

创建一个脚本引用AnimancerComponent组件和Float1ControllerTransition使用_animancer.Play(Comtroller)就可以播放里面的Blend Tree了。然后改变Blend Tree的参数,就可以改变动画状态了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public sealed class LinearBlendTreeLocomotion : MonoBehaviour
{
[SerializeField] private AnimancerComponent _Animancer;
[SerializeField] private Float1ControllerTransitionAsset.UnShared _Controller;

private void OnEnable()
{
_Animancer.Play(_Controller);
}
/// <summary>Controlled by a <see cref="UnityEngine.UI.Slider"/>.</summary>
public float Speed
{
get => _Controller.State.Parameter;
set => _Controller.State.Parameter = value;
}
}

使用Mixer

官方介绍原文:

Mixer States are essentially just Blend Trees which are constructed using code at runtime instead of being configured in the Unity Editor as part of an Animator Controller. Specifically, they can be constructed using code and you can access their internal details even though in this example we are just using a Transition.

翻译:

混合器状态本质上只是混合树,它们是在运行时使用代码构建的,而不是在 Unity 编辑器中配置为动画控制器的一部分。具体来说,它们可以使用代码构建,并且您可以访问它们的内部详细信息,即使在本示例中我们仅使用Transition

旧版本:

与使用Bled Tree的方法类似,也需要用到参数控制器。只需将上面代码的Float1ControllerTransitionAsset.UnShared替换成LinearMixerTransitionAsset.UnShared就可以了

使用方法

v8.0版本:学着学着官方来了波大的,直接更新了一个大版本

使用Assets/Create/Animancer/Transition Asset创建一个Transition Assets将其改成LinearMixerTransition类型,然后添加动画、设置动画参数就可以使用了v8.0引入了一个新的功能,String Asset

Assets/Create/Animancer/String Asset创建一个String Asset

MixerTransition指定这个参数

再使用另一个脚本控制这个参数的数值,就能控制角色的混合状态了

1
2
3
4
5
6
7
8
9
[SerializeField] private Slider _Slider;
[SerializeField] private AnimancerComponent _Animancer;
[SerializeField] private StringAsset _ParameterName;

protected virtual void Awake()
{
_Parameter = _Animancer.Parameters.GetOrCreate<float>(_ParameterName);
_Slider.onValueChanged.AddListener(_Parameter.SetValue);// 这里是监听slider来控制数值,实际使用时可以使用输入系统
}
Time Synchronization时间同步

从下图可看出walk和run动画帧数差的很多

这样会导致,他们相应的姿势没有对齐

动画不正确的话,需要确认的有两件事

  1. 需要两个融合的动作是否相似,确保每个动画以相同的姿势开始这里的示例动画全部设置为在角色左脚接触地面时开始
  2. 选择Sync开关启用时间同步,开启后当Mixer混合这些动画的时候就能确保两个动画以相同的速度播放即使帧率不一致

将run动画的Cycle Offset设置为0.5,这样run动画和walk一样在第一帧是左脚接触地面的了

勾选walk和run的Sync。因为idle对角的影响不大,所以不用勾选;反而如果勾选了,由于idle足足有176帧,会让动画移动的很慢

最终效果:

速度推测

动画速度参数只设置了从0到1,如果超过1,参数依然按1计算

勾选ExtrapolateSpeed之后,Mixer能根据速度参数,来加速动画


Editor

想要使用某个值,将他应用到动画设置里,但是又嫌进入PlayMode麻烦,可以使用写一个void OnValidate()方法:

1
2
3
4
5
6
7
8
9
10
[SerializeField] private AnimationClip _Open;
[SerializeField, Range(0, 1)] private float _Openness;

#if UNITY_EDITOR
private void OnValidate()
{
if (_Open != null)
AnimancerUtilities.EditModeSampleAnimation(_Open, this, _Openness * _Open.length);
}
#endif

当Inspector窗口的_Open被实例化并且_Openness的值被改变了,就会通过MonoBehaviour Message传递信息,实时反馈到场景中


属性

数据单位

  • Meters:在Inspector窗口中显示一个m,表达这个数据的单位是米
  • DegreesPerSecond°/s,旋转角度,度每秒
  • MetersPerSecondm/s,移动速度,米每秒
  • Multiplierx,倍率

Animancer官方网站

在unity中,经常需要使用到单例,尤其是MonoBehaviour的单例。

MonoBehaviour的单例模式需要使用饿汉模式的单例来初始化,要不然可能会自动创建多出的单例。

MonoBehaviour单例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
using UnityEngine;

/// <summary>
/// 继承自这个类的单例同样也是MonoBehaviour,可以挂载到物体上,并且会将该物体放置DontDestroyOnLoad内
/// 如果项目本身就使用多场景控制物件,可以不用调用DontDestroyOnLoad方法
/// </summary>
/// <typeparam name="T"></typeparam>
public class MonoSingleton<T> : MonoBehaviour where T : Component
{
private static T _instance = null;

public static T Instance
{
get
{
if (_instance == null)
{
_instance = FindObjectOfType<T>();
if (_instance == null)
{
GameObject obj = new GameObject(typeof(T).Name, new[] {typeof(T)});
DontDestroyOnLoad(obj);
_instance = obj.GetComponent<T>();
(_instance as IInitable)?.Init();
}
else
{
Debug.LogWarning("Instance is already exist!");
}
}

return _instance;
}
}

/// <summary>
/// 继承Mono单例的类如果写了Awake方法,需要在Awake方法最开始的地方调用一次base.Awake(),来给_instance赋值
/// </summary>
protected void Awake()
{
_instance = this as T;
DontDestroyOnLoad(this.transform.root);
}
}

非MonoBehaviour单例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/// <summary>
/// 继承自这个类的单例不是MonoBehaviour,无法怪载到物体上
/// </summary>
/// <typeparam name="T"></typeparam>
public class Singleton<T> where T : new()
{
private static T _instance;

public static T Instance
{
get
{
if (_instance == null)
{
_instance = new T();
(_instance as IInitable)?.Init();
}

return _instance;
}
}
}