unity - 对象池(Object Pools)

Catlike Coding

Catlike Coding

Unity C# Tutorials

原文:Object Pools

 

对象池(Object Pools)

 
  • 用物理创造一个喷泉
  • 做一个对象池
  • 给预制体添加功能
  • 按需要生成对象池
  • 再编译和场景转换

这是一个关于对象池的教程。 讲述对象池是什么,如何工作,以及他们的用处。

这个教程接着FPS篇。 我们将以相似的方式产生对象,而FPS计数器会用于测量性能。

生成大量物体

对象池是一个重用对象的工具, 在创建和销毁大量对象时你应当用到它。所以我们应该从产生很多对象开始。为了测量性能,我们可以重复使用上一个教程中的帧率计数器(FPS)。 最简单的做法是打开上一个教程的场景,将计数器的对象转换成预制体,然后在本教程的新场景中使用。

assets  object  scene

我们先来生成物体。为了使它变得更有趣,让我们添加上物理组件。  我们可以从一个非常简单的组件开始,类似于上一个教程的核子。

using UnityEngine;

[RequireComponent(typeof(Rigidbody))]
public class Stuff : MonoBehaviour {

	Rigidbody body;

	void Awake () {
		body = GetComponent<Rigidbody>();
	}
}

创建一个标准的立方体和球体,并将这个组件添加给他们两个。 然后把它们变成预制体。

cube prefab  stuff prefabs

接下来我们需要Stuff的生成器,这很像上一篇教程中核子的生成器

using UnityEngine;

public class StuffSpawner : MonoBehaviour {

	public float timeBetweenSpawns;

	public Stuff[] stuffPrefabs;

	float timeSinceLastSpawn;

	void FixedUpdate () {
		timeSinceLastSpawn += Time.deltaTime;
		if (timeSinceLastSpawn >= timeBetweenSpawns) {
			timeSinceLastSpawn -= timeBetweenSpawns;
			SpawnStuff();
		}
	}

	void SpawnStuff () {
		Stuff prefab = stuffPrefabs[Random.Range(0, stuffPrefabs.Length)];
		Stuff spawn = Instantiate<Stuff>(prefab);
		spawn.transform.localPosition = transform.position;
	}
}

为这个组件创建一个对象。 将time between spawns(产生间隔)调小一些,如0.1 。然后在stuff数组(Stuff Prefabs)中引用cube和sphere这两个预制体。

spawner  spawn生产 stuff 的 stuff  生成器

现在我们有了个生成stuff的生成器。它看起来不多.。因为要给它一个初始的速度,所以把 Stuff.body 变成了一个属性

	public Rigidbody Body { get; private set; }void Awake () {
		Body = GetComponent<Rigidbody>();
	}

让我们给StuffSpawner生成stuff时添加一个可调节的速度,这将让被生成的stuff有一个初始向上的动力

	public float velocity;void SpawnStuff () {
		Stuff prefab = stuffPrefabs[Random.Range(0, stuffPrefabs.Length)];
		Stuff spawn = Instantiate<Stuff>(prefab);
		spawn.transform.localPosition = transform.position;
		spawn.Body.velocity = transform.up * velocity;
	}
velocity  a tower of spawn构建了一个stuff塔,从下到上,再掉下去

现在我们得到一个整齐升起的stuff塔,再迅速倒塌,然后再升起。如果你倾斜生成器, 那么它看起来将会更加自然。事实上,如果我们把多个生成器放在一个环中,就会像一个复杂的喷泉。 让我们再创建一个组件来做到这一点。

using UnityEngine;public class StuffSpawnerRing : MonoBehaviour {public int numberOfSpawners;public float radius, tiltAngle;public StuffSpawner spawnerPrefab;void Awake () {for (int i = 0; i < numberOfSpawners; i++) {CreateSpawner(i);}}}
	void CreateSpawner (int index) {Transform rotater = new GameObject("Rotater").transform;rotater.SetParent(transform, false);rotater.localRotation =Quaternion.Euler(0f, index * 360f / numberOfSpawners, 0f);StuffSpawner spawner = Instantiate<StuffSpawner>(spawnerPrefab);spawner.transform.SetParent(rotater, false);spawner.transform.localPosition = new Vector3(0f, 0f, radius);spawner.transform.localRotation = Quaternion.Euler(tiltAngle, 0f, 0f);}

现在我们将spawner变为预制体,并做一个spawner ring对象

ring  much stuff好多好多stuff.

现在,我们有了一个不断增加的物质的喷泉, 产生了无穷无尽的下落物体。为了防止我们的应用卡死,我们必须在某一时刻把这些物体清除掉。我们可以通过引入一个“杀伤区域”来实现。所有进入这个区域的东西都会被销毁。

创建一个物体并添加box collider,然后把isTrigger勾选上,把它设置成一个很大的尺寸,然后把它放在喷泉下面的某个地方。给它一个Kill Zone标签(Tag),这样我们就知道它的作用了。添加这个标签时,你会发现它并不存在,所以你必须自己添加它,在标签选择器中的选项里( Add Tag)。注意,在导入unitypackage文件时,标签将不包括在内,你必须将它们添加到你的项目中,以便它们正确显示。

kill zone  tag一个kill zone

现在,Stuff可以检查它是否处在kill zone,并决定是否销毁自己

	void OnTriggerEnter (Collider enteredCollider) {if (enteredCollider.CompareTag("Kill Zone")) {Destroy(gameObject);}}
 
unitypackage

增加变化

我们拥有我们的喷泉, 但它很有秩序. 我们可以给它添加更多的随机性. 比如用一些随机的数值代替原本的数值. 

using UnityEngine;[System.Serializable]public struct FloatRange {public float min, max;public float RandomInRange {get {return Random.Range(min, max);}}}

现在我们把生成时间弄乱 StuffSpawner.

	public FloatRange timeBetweenSpawns;

	float currentSpawnDelay;void FixedUpdate () {
		timeSinceLastSpawn += Time.deltaTime;
		if (timeSinceLastSpawn >= currentSpawnDelay) {
			timeSinceLastSpawn -= currentSpawnDelay;
			currentSpawnDelay = timeBetweenSpawns.RandomInRange;
			SpawnStuff();
		}
	}
不规则地生成

为什么不把stuff的大小和旋转弄成随机的呢?

	public FloatRange timeBetweenSpawns, scale;

	void SpawnStuff () {
		Stuff prefab = stuffPrefabs[Random.Range(0, stuffPrefabs.Length)];
		Stuff spawn = Instantiate<Stuff>(prefab);
		
		spawn.transform.localPosition = transform.position;
		spawn.transform.localScale = Vector3.one * scale.RandomInRange;spawn.transform.localRotation = Random.rotation;
		
		spawn.Body.velocity = transform.up * velocity;
	}

我们也可以改变速度, 让我们把它变成一个三维随机数.。基本速度不变, 但在任意的方向上增加一个随机的速度。

	public FloatRange timeBetweenSpawns, scale, randomVelocity;

	void SpawnStuff () {
		…
		
		spawn.Body.velocity = transform.up * velocity +Random.onUnitSphere * randomVelocity.RandomInRange;
	}
随机的额外速度

物理同样也允许有角动量, 再添加一个随机的扭矩. 请记住,物理引擎限制了这个速度,默认最大值为7。

	void SpawnStuff () {
		…

		spawn.Body.velocity = transform.up * velocity +
			Random.onUnitSphere * randomVelocity.RandomInRange;
		spawn.Body.angularVelocity =Random.onUnitSphere * angularVelocity.RandomInRange;
	}
angular velocity
randomized stuff 

我们的喷泉看起来更加活泼了。添加一些颜色会让它变的更有趣。 

	public Material stuffMaterial;void SpawnStuff () {
		…
		
		spawn.GetComponent<MeshRenderer>().material = stuffMaterial;
	}
可设置的材质

当然我们不会只用一种材料. 相反, 我们创建一组并让 StuffSpawnerRing 用它们循环生成stuff.

	public Material[] stuffMaterials;void CreateSpawner (int index) {
		…

		spawner.stuffMaterial = stuffMaterials[index % stuffMaterials.Length];
	}
materials  colored stuff色彩的喷泉

如果,这些东西除了自己和自己对撞,还能跟其他一些东西对撞的话,我们的场景看起来会更加生动。所以,我在场景的中心增加一个大的球体。


更多有趣的物理

限制,物质会花时间在球上反弹,在它不可避免地在kill zone销毁前,你可以往场景里添加更多的障碍, 但stuff也会花费更多的时间到达kill zone, 物理引擎也会做更多的工作。

unitypackage

做一些更重的Stuff

好了, 我们正生成着stuff. 那么问题来了,我们的app的性能如何? 查看一下profiler,看看发生了什么。编译出来, 这样你就可以看到它是如何作为一个独立的应用程序运行的。 因为编译后的运行程序效率比在编辑器中运行要更高, 所以你可以将spawners的数量提升到30甚至更高, 这取决于你的机器。

你将看到持续的内存分配,也许是垃圾回收机制(garbage collection)在运行, 这并不奇怪. 你可以看到,每一帧都有成百上千个字节被分配, 这取决于你的app的随机性和帧率。

这些内存分配是否值得注意? 在台式机上,很可能不用. 渲染和物体使电脑忙碌, 我们的脚本几乎没有在分析图上注册。

我们的脚本,蓝线,在这种情况下不是效率的瓶颈

当然,这张图很可能会在其他设备上改变。 手机和典型控制台的内存通常比台式机和笔记本电脑要慢. 你可能会经常使用垃圾收集,这会对你的应用程序的帧率造成严重破坏. 但你可以在你确定之前去测量它。

对于桌面程序而言, 根据需求简单的创建和销毁对象看起来还不错。我们的程序不需要增加复杂度来解决一个不存在的问题. 但也许我们的对象太简单了?如果我们的东西更复杂怎么办?

我们需要更大的Stuff!比如,一个由立方体做成的十字架。创建三个立方体,使它们成为一个空的游戏对象的子元素,并将它们的尺度设置为(0.5、0.5、2.5)的三种可能的排列。然后在根对象中添加一个Stuff组件,然后把整个东西变成一个预制构件。我把它的质量设为3,因为它是一个比其他东西更大的物体。

cross  previewCross prefab.

Add this new type of stuff to the spawner prefab's array so it will be included in the fountain.

将这种新东西添加到spawner prefab的数组中,这样它就会被包含在喷泉中。

Cubes, spheres, and crosses.

Entering play mode right now will give us trouble when trying to assigning materials. Our assumption that stuff objects always have a MeshRenderer is no longer true. So let's move the responsibility of setting the material to Stuff itself. Internally, it can collect all renderers inside itself when it awakens. Then it can forward material assignments to them all when needed.

	MeshRenderer[] meshRenderers;public void SetMaterial (Material m) {for (int i = 0; i < meshRenderers.Length; i++) {meshRenderers[i].material = m;}}void Awake () {
		Body = GetComponent<Rigidbody>();
		meshRenderers = GetComponentsInChildren<MeshRenderer>();
	}

Now StuffSpawner just has to invoke this method.

	void SpawnStuff () {
		…

		spawn.SetMaterial(stuffMaterial);
	}
More complex stuff in the fountain.

What about we add caltrops as well? A caltrop consists of four legs that point away from each other. You can put capsules inside those legs to visualize them, with a Y offset of 0.5. The first leg points upwards. The second has rotation (70.52878, 180, 180). The other two have the same X and Z rotation, but their Y rotation should be 300 and 60.

caltrop unitypackage caltrops  previewCaltrop prefab.

Add the caltrop to the spawner's stuff prefab array as well. You can increase the probability of an option being chosen by adding it more than once.

prefabs fountainA messy fountain.

How's our app's performance now? We're getting more memory allocations, but it is still not an issue for a desktop app. The physics calculations are much more important. Something drastic is needed to make the creation of stuff matter. For example, add the following line a few times to Stuff.Awake.

		FindObjectsOfType<Stuff>();
This warrants attention.

That did it. Object creation is now significant for our desktop app. Invoking FindObjectsOfType just five times per stuff generates roughly 200KB of memory allocations per frame for me. But this is a ridiculous scenario. Object pooling isn't the solution for this, getting rid of those FindObjectsOfType invocations is. And have a chat with whoever thought it a good idea to put that code there.

unitypackage

Pooling Objects

Suppose that we do want to use object pooling. How would we do that? We reuse objects by not destroying them, but instead deactivating them and placing them in a buffer. That's our pool. Then whenever we need a new object, we can reactivate one from the pool. If none are available, we create a new object as usual.

Let's create a component for these objects. It needs to know which pool it belongs to, so it can return to it when needed. And just in case it ends up without a pool, it should destroy itself instead.

using UnityEngine;public class PooledObject : MonoBehaviour {public ObjectPool Pool { get; set; }public void ReturnToPool () {if (Pool) {Pool.AddObject(this);}else {Destroy(gameObject);}}}

Now we can turn Stuff into a pooled object and have it return to its pool when entering a kill zone.

public class Stuff : PooledObject {
	
	…

	void OnTriggerEnter (Collider enteredCollider) {
		if (enteredCollider.CompareTag("Kill Zone")) {
			ReturnToPool();
		}
	}
}

Of course we now need an ObjectPool component. It should be possible to get an object from it and return objects to it. For now, let's just have it create and destroy objects the old-fashioned way and worry about actual reuse later.

While we are here though, let's use the pool as the parent for everything that it creates, so those objects don't clutter the root of the hierarchy.

using UnityEngine;using System.Collections.Generic;public class ObjectPool : MonoBehaviour {PooledObject prefab;public PooledObject GetObject () {PooledObject obj = Instantiate<PooledObject>(prefab);obj.transform.SetParent(transform, false);obj.Pool = this;return obj;}public void AddObject (PooledObject o) {Object.Destroy(o.gameObject) ;}}

Next, we have to change StuffSpawner so it uses a pool to create stuff, instead of instantiating new stuff all the time. How can we do this? It has to somehow get hold of a pool for each prefab. And we don't want duplicate pools, so all spawners should share them.

It would be very convenient if we could directly get a pooled instance from a prefab, without having to worry about the pools themselves. So let's pretend that we can.

	void SpawnStuff () {
		Stuff prefab = stuffPrefabs[Random.Range(0, stuffPrefabs.Length)];
		Stuff spawn = prefab.GetPooledInstance<Stuff>();

		…
	}

Of course prefabs don't have this functionality, so we have to add it ourselves. What we are actually doing here is simply invoking a method of PooledObject. This object just happens to be a prefab, not an object in a scene.

You can interact with prefabs?

Yes. They are object instances, just like scene objects. But they're not part of the scene hierarchy. They are assets, which means that all changes to them in the editor will be permanent. Just like when you'd adjust a shared material, for example.

It is now up to the PooledObject component to take care of the pools. First, we need to give it the required method, which is a generic method, like Instantiate.

	public T GetPooledInstance<T> () where T : PooledObject {}

How does a generic method work?

All this method needs to do is get an object from some pool instance that belongs to this prefab, cast it to the required type, and return it.

	public T GetPooledInstance<T> () where T : PooledObject {
		return (T)poolInstanceForPrefab.GetObject();
	}

Of course we need to keep track of poolInstanceForPrefab, so it becomes a field of PooledObject. Note that we'll use this field to reference an object in the scene, even though a prefab is an asset and not part of any scene. So we cannot save it as part of the prefab, thus we have to make it non-serializable.

	[System.NonSerialized]ObjectPool poolInstanceForPrefab;

What happens when you serialize it?

Unity will try to save the pool instance as part of the prefab asset, but will fail. If the field were public, it would show up in the inspector as a type mismatch while in play mode, and as missing otherwise.

Note that all PooledObject instances have this field. It's just prefabs that should make use of it.

Finally, how do we actually get a reference to the correct pool? We'll ask the ObjectPool class for one when necessary, via a static method.

	[System.NonSerialized]
	ObjectPool poolInstanceForPrefab;

	public T GetPooledInstance<T> () where T : PooledObject {
		if (!poolInstanceForPrefab) {poolInstanceForPrefab = ObjectPool.GetPool(this);}return (T)poolInstanceForPrefab.GetObject();
	}

So off we go to ObjectPool and add the required method.

	public static ObjectPool GetPool (PooledObject prefab) {}

At this point we still don't have an actual object pool instance. That's all right, because until this method gets invoked we had no need for one anyway. So this is the right place to create a pool instance.

	public static ObjectPool GetPool (PooledObject prefab) {
		GameObject obj = new GameObject(prefab.name + " Pool");ObjectPool pool = obj.AddComponent<ObjectPool>();pool.prefab = prefab;return pool;
	}

Pools should now appear in play mode, one for each prefab, each neatly containing their own stuff.

Tidy pools.

Unfortunately, duplicate pools will be created each time Unity recompiles the scripts while in play mode. While this isn't an issue for builds, it is annoying when working in the editor. This happens because PooledObject.poolInstanceForPrefab doesn't survive a recompile, as we indicated that it should not be serialized. We can work around this by having ObjectPool.GetPool check whether a pool with the same name already exists.

	public static ObjectPool GetPool (PooledObject prefab) {
		GameObject obj;ObjectPool pool;if (Application.isEditor) {obj = GameObject.Find(prefab.name + " Pool");if (obj) {pool = obj.GetComponent<ObjectPool>();if (pool) {return pool;}}}obj = new GameObject(prefab.name + " Pool");
		pool = obj.AddComponent<ObjectPool>();
		pool.prefab = prefab;
		return pool;
	}

With that problem solved, let's make ObjectPool actually reuse objects. We can do this with a simple list. We add objects to this list as they return to the pool, and take the last one out of the list whenever a new object is required.

	List<PooledObject> availableObjects = new List<PooledObject>();public PooledObject GetObject () {
		PooledObject obj;int lastAvailableIndex = availableObjects.Count - 1;if (lastAvailableIndex >= 0) {obj = availableObjects[lastAvailableIndex];availableObjects.RemoveAt(lastAvailableIndex);obj.gameObject.SetActive(true);}else {obj = Instantiate<PooledObject>(prefab);
			obj.transform.SetParent(transform, false);
			obj.Pool = this;
		}return obj;
	}

	public void AddObject (PooledObject obj) {
		obj.gameObject.SetActive(false);availableObjects.Add(obj);
	}

Why take the last object out of the list?

Suddenly, we have no more constant memory allocations! New stuff is only created when a pool is empty, which will only happen occasionally once the initial burst of object creation is over.

So, does this improve performance? For desktop apps it most likely won't make a difference whether you use the pools or not. In my case the performance is identical. In other cases, it might be a big help. It might even result in worse performance. You'll have to try to find out.

unitypackage

Pooling Across Scenes

What would happen to our pools if we were to load a different scene? Everything should be destroyed, right? Let's find out.

A very simple way to change scenes is with a little component that switches between consecutive scenes, wrapping back to the first scene when needed.

using UnityEngine;public class SceneSwitcher : MonoBehaviour {public void SwitchScene () {int nextLevel = (Application.loadedLevel + 1) % Application.levelCount;Application.LoadLevel(nextLevel);}}

Add a button to the canvas and add this component to it, then hook up its method to the On Click event. Also add an event system to the scene via GameObject / UI / Event System, if don't have one already.

scene switcher  buttonScene switcher.

Now we need more scenes. Save and duplicate the current scene. Then change something in the duplicate so you can tell them apart, like replacing the sphere with a cube. Add both these scenes to the build, via File / Build Settings... and using the Add Current button while being inside the scene that you want to add.

Including two scenes in the build.

The scene switcher is now functional. However, when you try it in the editor Unity might screw up the lighting after loading another scene. This is a problem with the editor only, builds are fine. You can work around this problem by disabling Continuous Baking via Window / Lighting and manually baking each scene once, even when you're not using any baked lighting.

Lighting screws up after switching scenes in play mode.

Indeed, switching scenes destroys everything from the old scene, including the pools. As the stuff prefabs have lost their pools, they will simply request new ones and start fresh.

Switching Scenes.

But maybe we can keep the pools alive? They're all about preventing object destruction and creation, and scene transitions do just that. We can keep them alive by instructing Unity to not destroy our ObjectPool instances when a new scene is loaded.

	public static ObjectPool GetPool (PooledObject prefab) {
		…
		
		obj = new GameObject(prefab.name + " Pool");
		DontDestroyOnLoad(obj);
		pool = obj.AddComponent<ObjectPool>();
		pool.prefab = prefab;
		return pool;
	}

Our pools now survive scene transitions, and so do all their child objects. So all our stuff remains exactly where it is, which means that our fountain's flow is no longer interrupted. Of course changes to the scene can cause some funny physics reactions.

Not destroying pools keeps the flow intact.

Ideally, we would return all stuff to their pools when loading a new scene, instead of either destroying them or keeping them active. Fortunately, we can do this by adding the OnLevelWasLoaded event method to Stuff.

	void OnLevelWasLoaded () {ReturnToPool();}

That's it! You have created an object pool system that is easy to use and survives both recompiles and scene transitions. Of course this is not the only way to pool objects. You could tweak it to instantiate a whole bunch of objects when a pool is created, or limit the maximum instances per pool, or make other adjustments that fit your use case. Or use an entirely different approach. The most important thing is that you understand what object pooling is, what it can be used for, and when it might or might not solve your problems.

unitypackage PDF

Enjoying the tutorials? Are they useful? Want more?

Please support me on Patreon!

Become my patron!

Or make a direct donation!

made by Jasper Flick

猜你喜欢

转载自blog.csdn.net/qq_15505341/article/details/79302277
今日推荐