【Unity】[入门tips与代码优化] 一些入门概念、技巧和优化程序的原则方法

本文将持续间断更新

本文主要面向初级程序员,为了方便Unity开发,有些快捷键的方式和一些通用性的技巧tips等会在这篇博客内持续更新,欢迎点赞收藏

快捷键

  1. Ctrl + S ; 快捷保存!闲着没事就来两下!
  2. Ctrl+Shift+F ; 在hierarchy窗口中选中任意对象,在scene窗口中按此快捷键,可将对象坐标设置为此时scene窗口坐标。
  3. Alt+Mouse Left ; 在hierarchy窗口中折叠或展开对象时,按住alt再按左侧小三角,可使其所有子对象折叠或展开。

概念和知识

函数运行顺序

脚本自带函数执行顺序如下:
Awake ->OnEable-> Start -> FixedUpdate-> Update -> LateUpdate ->OnGUI ->Reset -> OnDisable ->OnDestroy

Collider的性能顺序

collider的性能和效率大概的顺序是:Sphere Collider > Capsule Collider> Box Collider > Mesh Collider。取决于碰撞的定点数量和计算复杂度。 (https://blog.csdn.net/qiaoquan3/article/details/51320312

tag和layer的区别

在Unity中,Tag和Layer都是用来标记游戏对象的属性。

Tag是用来识别不同的游戏对象,通常用于在脚本中查找特定类型的游戏对象,如在一组敌人对象中查找某个敌人,或在一组道具对象中查找某个特定类型的道具。

而Layer则是用于控制游戏对象的物理属性和渲染顺序,通常用于实现碰撞检测和渲染效果。例如,可以将玩家对象和敌人对象的Layer设置为不同的值,从而实现玩家与敌人之间的碰撞检测。

因此,Tag和Layer的区别在于它们的主要用途和应用场景不同。Tag用于标记游戏对象的类型或特征,用于在脚本中查找和处理特定类型的游戏对象。而Layer则用于控制游戏对象的物理属性和渲染顺序,通常用于实现碰撞检测和渲染效果。
在这里插入图片描述
比如我上图中,player层只需要和Building、其他player进行碰撞(或trigger),就可以在这里只勾选需要进行碰撞交互的选项,可以大大节省与其他层(比如ground层是无时无刻都在碰撞的)的资源消耗。

判断tag、layer、gameobject、name 哪个方法消耗的资源少

  • tag 和 layer:
    在使用 tag 和 layer 进行比较时,Unity 使用的是整数值进行比较,这些整数值存储在内存中,所以它们的资源消耗非常小。
  • gameobject:
    比较 gameobject 通常使用的是对象引用,即将对象的引用进行比较,这通常会比较消耗内存。但是,如果你只是在 Start() 函数或其他初始化代码中使用它们,消耗并不会很大。
  • name:
    name 属性是每个 GameObject 中的字符串变量,比较起来通常比较消耗内存,因为字符串比整数和对象引用占用更多的内存。

总的来说,消耗最小的是 tag 和 layer,而 name 的消耗最大。但是,这些消耗的差异通常很小,只有在大量使用时才会产生明显的影响,因此在选择哪种方式时,应根据具体情况进行判断。

重要、有用,必学的函数

PingPong

Mathf.PingPong 函数可以在指定区间内生成一个来回运动的数值。它的参数有两个:第一个是当前的时间,第二个是运动的总时长。

函数返回值会在区间 [0, t] 上折返

下面是一个简单的示例,使一个物体在指定区间内来回运动:

public class PingPongExample : MonoBehaviour
{
    
    
    public float speed = 1f;
    public float length = 10f;

    private Vector3 startPosition;

    private void Start()
    {
    
    
        startPosition = transform.position;
    }

    private void Update()
    {
    
    
        float offset = Mathf.PingPong(Time.time * speed, length);
        transform.position = startPosition + Vector3.forward * offset;
    }
}

Lerp

使用变量插值:在编写脚本时,为了提高代码的可读性和灵活性,建议使用变量插值,例如 transform.position = Vector3.Lerp(transform.position, targetPosition, speed * Time.deltaTime); 这种方式,能够实现更平滑的过渡效果。
https://docs.unity3d.com/ScriptReference/30_search.html?q=Lerp.
在这里插入图片描述

LateUpdate

LateUpdate() 方法是 Unity 中的一个特殊函数,该函数在 Update() 方法执行完毕后立即调用。通常情况下,我们会将摄像机跟随、物体移动等操作放在 Update() 中进行,但如果涉及到摄像机的位置和旋转,则建议将这些操作放在 LateUpdate() 中。

这是因为,在 Update() 中进行摄像机跟随或物体移动时,可能会由于其它物体的移动或旋转导致摄像机位置和旋转发生不稳定的变化,从而导致画面抖动或者其他视觉问题。而将这些操作放在 LateUpdate() 中,可以确保在其它物体的操作之后才进行摄像机位置和旋转的调整,从而避免这些问题。

另外,需要注意的是,如果 Update() 中的某些操作需要使用 LateUpdate() 中的结果,则需要使用 FixedUpdate() 来进行操作,以确保物理引擎和渲染引擎之间的同步。

总之,LateUpdate() 适用于需要在 Update() 之后调整摄像机位置和旋转的情况,避免出现画面抖动和其它视觉问题。

Clamp

Mathf.Clamp() 函数可以将给定的值限制在指定的范围内,如果该值小于最小值,则返回最小值,如果该值大于最大值,则返回最大值,否则返回该值本身。

public static float Clamp(float value, float min, float max);

其中,value 表示需要限制的值,min 表示最小值,max 表示最大值。

例如,如果我们需要将一个玩家得分限制在 0 到 100 之间,则可以使用以下代码:

score = Mathf.Clamp(score, 0f, 100f);

还可以用于复杂的场景:
假设我们有一个摄像机跟随玩家移动的场景,需要限制摄像机的位置不能超出场景边界。我们可以使用 Mathf.Clamp() 函数来实现这个功能。
假设场景的左下角为 (0, 0),右上角为 (100, 100),摄像机的跟随目标为 player,摄像机距离目标的距离为 distance,摄像机的位置为 cameraPosition。则我们可以使用以下代码来限制摄像机的位置:

// 获取玩家当前位置
Vector3 playerPosition = player.transform.position;
// 计算摄像机应该在的位置
Vector3 cameraPosition = playerPosition - player.transform.forward * distance;
// 限制摄像机的位置不能超出场景边界
cameraPosition.x = Mathf.Clamp(cameraPosition.x, 0f, 100f);
cameraPosition.z = Mathf.Clamp(cameraPosition.z, 0f, 100f);
// 设置摄像机的位置
transform.position = cameraPosition;

Invoke

Invoke() 函数可以在指定的时间之后调用指定的函数,或者以指定的时间间隔重复调用指定的函数。(注意,协程也可以做到,而且我看官方form里,挺多人说协程更适用,(协程在后面))

public void Invoke(string methodName, float time);
public void InvokeRepeating(string methodName, float time, float repeatRate);

其中,methodName 表示需要调用的函数名称,time 表示需要延迟的时间或者需要重复调用的时间间隔,repeatRate 表示需要重复调用的时间间隔。

Coroutine

协程(Coroutine)是Unity中一种用于实现多任务并行处理的机制。协程可以在执行过程中暂停并等待某些条件满足后再继续执行,从而可以实现一些非常有用的功能,例如延时执行、动画效果、处理异步任务等。在谷歌搜Invoke和协程的区别的时候,大家都建议用协程,下面是一个使用协程实现延时执行的例子:

using UnityEngine;
using System.Collections;

public class CoroutineExample : MonoBehaviour
{
    
    
    void Start()
    {
    
    
        StartCoroutine(DelayedFunction(2.0f));
    }

    IEnumerator DelayedFunction(float delay)
    {
    
    
        Debug.Log("Delay start");
        yield return new WaitForSeconds(delay);
        Debug.Log("Delay end");
    }
}

另外开发中经常有生成器这种东西,我一般开个协程,start里调用一下,下面是我开发中的一个例子:

using System.Collections;
using System.Collections.Generic;
using System.Security.Cryptography;
using UnityEngine;


public class ResidentManager : MonoBehaviour
{
    
    

    [SerializeField] GameObject[] residentsPrefabs;

    int totalResidentNumber;

    private void Awake()
    {
    
    
        //变量初始化
    }
    // Start is called before the first frame update
    void Start()
    {
    
    
        //一些在其他脚本内awake中初始化的变量需要在start中初始化,
        //因为Start方法会在所有对象的Awake方法调用完毕后执行
        totalResidentNumber = GameManager.INSTANCE.totalResidentNumber;

        
        StartCoroutine(SpawnResident());
    }

    // Update is called once per frame
    void Update()
    {
    
    
    }

    //协程,生成resident
    IEnumerator SpawnResident()
    {
    
    
        for (int i = 0; i < totalResidentNumber; i++)
        {
    
    
            int randomIndex = Random.Range(0, residentsPrefabs.Length);
            GameObject resident = Instantiate(residentsPrefabs[randomIndex], transform.position, Quaternion.identity);
            resident.transform.parent = transform;
            yield return new WaitForSeconds(0.1f);
        }
    }
}

还可以这么用,在协程函数中,可以使用 yield return 语句来等待一段时间或者等待另一个协程的执行结束:

IEnumerator MyCoroutine() {
    
    
    // 等待 3 秒钟
    yield return new WaitForSeconds(3f);

    // 等待另一个协程的执行结束
    yield return StartCoroutine(AnotherCoroutine());
}

IEnumerator AnotherCoroutine() {
    
    
    // 协程的执行逻辑
}

在协程函数中,可以使用 yield break 语句来中断协程的执行。

IEnumerator MyCoroutine() {
    
    
    while (true) {
    
    
        // 协程的执行逻辑

        // 如果满足某个条件,中断协程的执行
        if (someCondition) {
    
    
            yield break;
        }
    }
}

Raycast

Raycast 是 Unity 中非常常用的一个函数,用于从摄像机或物体发射一条射线,检测射线是否与其他物体相交,并获取相交点、法向量等信息。Raycast 的使用方法如下:

使用 Physics.Raycast 函数发射一条射线。该函数需要传入一个起点、一个方向和一个射线长度,还可以传入一些可选参数,如层级掩码、查询触发器等。

Ray ray = new Ray(transform.position, transform.forward);
float maxDistance = 100f;
int layerMask = LayerMask.GetMask("Default");
bool hitSomething = Physics.Raycast(ray, out RaycastHit hitInfo, maxDistance, layerMask);

如果射线与其他物体相交,Physics.Raycast 函数会返回 true,并将相交点的信息填充到 RaycastHit 结构体中。可以从该结构体中获取相交点、法向量、被相交的物体等信息。

if (hitSomething) {
    
    
    Debug.Log("Hit something at " + hitInfo.point + " with normal " + hitInfo.normal + " on object " + hitInfo.collider.gameObject.name);
}

可以使用 Debug.DrawLine 函数在场景中绘制射线,方便调试。(绘制的射线只在scene窗口中出现,而且比较短暂,可以延长时间比较好观察,我一般用5秒。)

Debug.DrawLine(ray.origin, ray.origin + ray.direction * 100, Color.red,5);

在这里插入图片描述

Raycast 可以用于各种场景中,比如鼠标点击检测、射线武器命中检测、AI 碰撞检测等。
.

Physics.OverlapSphere

Physics.OverlapSphere是Unity中用于检测指定半径内所有物体的函数。该函数接受两个参数:检测半径和检测中心点的坐标。返回一个数组,包含所有在半径范围内的碰撞器组件。
例子:

using UnityEngine;

public class Player : MonoBehaviour
{
    
    
    public float detectionRadius = 10f;

    private void Update()
    {
    
    
        // 获取所有与玩家距离小于detectionRadius的对象
        Collider[] colliders = Physics.OverlapSphere(transform.position, detectionRadius);

        // 遍历所有对象,查找敌人
        foreach (Collider collider in colliders)
        {
    
    
            Enemy enemy = collider.GetComponent<Enemy>();
            if (enemy != null)
            {
    
    
                enemy.Attack();
            }
        }
    }
}

需要注意的是,使用Physics.OverlapSphere函数会比较耗费性能,因此应该尽可能减少使用频率,或者只在需要的时候才使用。另外,对于大量物体的场景,可以考虑使用物理层(Physics Layer)来过滤掉不需要检测的物体,从而提高性能。

Physics.OverlapSphere(transform.position, detectionRadius, enemyLayer);

开发时的原则

尽量减少GameObject的数量

尽可能地合并一些GameObject,可以大大减少Draw Call的次数,从而提高性能。

预制体重用

预制体是非常有用的,可以让我们在场景中快速地实例化对象,并且在多个场景中复用,更改一个prefab,即可应用更改到所有prefab的实例。
还可以有Prefab Variants(https://docs.unity3d.com/Manual/PrefabVariants.html),如同面向对象的继承一样,可以实现更改“树”prefab,应用更改到“苹果树”prefab和“梨子树”prefab这样的功能。

对象池

对象池是一种重复使用已经创建过的对象的技术,这样可以避免在创建对象时的开销,提高性能。比如子弹,如果每颗子弹单独Instantiate和destroy,开销会很大,转用对象池就能很省资源,下面是例子:

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public class ObjectPool : MonoBehaviour {
    
    

    public GameObject bulletPrefab;
    public int poolSize = 20;

    private List<GameObject> bullets = new List<GameObject>();
	//创建了一个Bullet的对象池,通过实例化bulletPrefab来填充池子。
    void Start () {
    
    
        for (int i = 0; i < poolSize; i++) {
    
    
            GameObject bullet = Instantiate (bulletPrefab);
            bullet.SetActive (false);
            bullets.Add (bullet);
        }
    }
	//GetBullet函数用于获取一个Bullet对象,
	//它首先检查池子中是否有空闲的对象,
	//如果有,就返回其中一个;否则,就实例化一个新的Bullet对象。
    public GameObject GetBullet () {
    
    
        for (int i = 0; i < bullets.Count; i++) {
    
    
            if (!bullets[i].activeInHierarchy) {
    
    
                return bullets[i];
            }
        }

        GameObject bullet = Instantiate (bulletPrefab);
        bullet.SetActive (false);
        bullets.Add (bullet);

        return bullet;
    }
	//ReturnBullet函数用于将Bullet对象返回池子。
	//当我们使用完一个Bullet对象时,可以将其传递给ReturnBullet函数
	//,以便将其返回到池子中,而不是销毁它。
    public void ReturnBullet (GameObject bullet) {
    
    
        bullet.SetActive (false);
    }
}

在实际使用中,我们可以在需要创建Bullet对象时,使用ObjectPool的GetBullet函数获取一个对象,使用完毕后,使用ObjectPool的ReturnBullet函数将对象返回到池子中。这样,我们就可以避免频繁地创建和销毁Bullet对象,从而提高应用程序的性能。

使用Profiler进行性能分析

Profiler是Unity的一个内置工具,可以帮助开发者识别应用程序的性能瓶颈,以及优化应用程序的性能。可以通过Profiler判断当前程序的运行瓶颈在哪,我是通过unity官方教程了解到的这个工具,这个确实简单易懂,而且入门简单,能清楚的看到哪行到哪行用了多少ms。可以搜搜使用方法,我觉得非常有用。

条件编译

可以帮助我们根据不同的平台和编译条件编写不同的代码,从而提高应用程序的可移植性。用条件编译就不用换一次平台新建一次工程或者手动根据目标平台注释代码了,直接代码全写里面,区别就是编译的时候,根据build选项里的目标平台会自动确定需要编译哪部分代码,未被编译的代码没有任何性能损失。

一些只有在开发时才会用到的函数可以用UNITY_EDITOR来预编译,下面是我开发的一个VR游戏的例子,只有在调试的时候才会在editor和window下运行,所以可以这样写: 在这里插入图片描述

下面是一个使用条件编译实现平台相关功能的例子:

using UnityEngine;
using System.Collections;

public class PlatformExample : MonoBehaviour
{
    
    
#if UNITY_EDITOR
    void Start()
    {
    
    
        Debug.Log("Unity Editor");
    }
#elif UNITY_ANDROID
    void Start()
    {
    
    
        Debug.Log("Android");
    }
#elif UNITY_IOS
    void Start()
    {
    
    
        Debug.Log("iOS");
    }
#endif
}

一眼就能看明白,最重要的是方便,不用注释掉其他平台的代码,毕竟unity也是个适合跨平台的引擎。
其他的条件编译内容和平台列表参照官网:
https://docs.unity3d.com/Manual/PlatformDependentCompilation.html

使用单例模式

在编写代码时,请考虑使用单例模式,可以使您的代码更加简洁和可读,并使其易于维护。单例模式是一种常见的设计模式,它的目的是确保在整个应用程序中只有一个特定类型的实例存在。
以下是一个简单的例子,说明如何在 C# 中实现单例模式:

public class Singleton
{
    
    
    private static Singleton instance;

    private Singleton() {
    
     }

    public static Singleton Instance
    {
    
    
        get
        {
    
    
            if (instance == null)
            {
    
    
                instance = new Singleton();
            }
            return instance;
        }
    }
}

在这个例子中,我们定义了一个名为 Singleton 的类,并声明了一个静态的私有实例变量 instance。我们还声明了一个私有构造函数,以确保我们无法从外部创建新的 Singleton 实例。

这样,我们就可以通过 Singleton.Instance 来获取 Singleton 类的唯一实例,并确保在整个应用程序中只有一个 Singleton 实例存在。

使用单例模式可以带来很多好处,例如:

  • 确保应用程序中只有一个实例,从而减少资源的浪费。
  • 提供了一个全局的访问点,方便其他代码访问该实例。
  • 可以对实例进行更好的控制,例如通过在构造函数中进行初始化等。

但是,也需要注意单例模式可能带来的一些问题,例如可能会增加代码的复杂性和耦合度等。
单例模式不止一种写法,还有其他的写法,只要没有程序上的bug,并且能实现你想要的功能就可以!

重构你的项目!

脚本中函数位置规划

为了提高脚本的可读性,可以按照以下方式规划各个函数的位置:

  1. 首先,建议将所有的公共成员变量(例如,public变量和public属性)放在脚本的最上面,这样其他开发者就可以很方便地查看脚本中可见的成员变量。

  2. 接下来,建议将脚本的生命周期函数(例如,Start和Update)放在公共成员变量之后。这些函数通常用于初始化脚本和每帧更新脚本的状态,所以将它们放在公共成员变量之后更加合理。

  3. 紧接着,建议将其他重载函数、条件编译函数、公共函数和私有函数按照函数的逻辑顺序进行分组,并将各个函数组之间留出一定的空白行进行分割。这样做可以提高代码的可读性,使得开发者更容易理解代码的逻辑和结构。

  4. 如果有辅助计算函数,建议将它们放在调用它们的函数之后,并将它们命名为较短的名称,以突出它们的作用。

例如,一个可能的函数排列顺序如下:

public class ExampleScript : MonoBehaviour {
    
    
    // 公共成员变量
    public int someValue;

    // 生命周期函数
    void Start() {
    
    
        // ...
    }

    void Update() {
    
    
        // ...
    }

    // 其他重载函数和条件编译函数
    void OnTriggerEnter(Collider other) {
    
    
        // ...
    }

    #if UNITY_EDITOR
    void OnDrawGizmos() {
    
    
        // ...
    }
    #endif

    // 公共函数
    public void DoSomething() {
    
    
        // ...
    }

    // 私有函数
    private void DoAnotherThing() {
    
    
        // ...
    }

    // 辅助计算函数
    private void CalculateSomething() {
    
    
        // ...
    }

    // 调用辅助计算函数的函数
    private void PerformCalculations() {
    
    
        CalculateSomething();
        // ...
    }
}

上述代码中,按照公共成员变量、生命周期函数、其他重载函数和条件编译函数、公共函数、私有函数以及辅助计算函数的顺序进行排列,增加了代码的可读性,使得其他开发者更容易理解代码的逻辑和结构。

Project文件夹目录规划

项目的文件夹目录结构通常应该按照以下方式进行安排:

  • Assets:此文件夹是存放所有游戏资源的根目录,包括场景、材质、预设、脚本、纹理、声音等等。
  • Scenes:此文件夹是存放所有场景文件的地方。
  • Scripts:此文件夹是存放所有脚本文件的地方。
  • Prefabs:此文件夹是存放所有预设文件的地方。预设是可重用的游戏对象。
  • Materials:此文件夹是存放所有材质文件的地方。
  • Textures:此文件夹是存放所有纹理文件的地方。
  • Audio:此文件夹是存放所有声音文件的地方。
  • Plugins:此文件夹是存放所有插件文件的地方,例如第三方库和其他工具。
  • Editor:此文件夹是存放所有编辑器扩展的地方。

猜你喜欢

转载自blog.csdn.net/gongfpp/article/details/129117178