3D model character dressing system three (optimized batch processing, extraction of batch configuration, packaging)

3D model character dressing system three (optimized batch processing, extraction of batch configuration, packaging)

introduce

This article uses tests conducted on 2018.4.4 and 2020.3.26
Let me first talk about the problems that I did not solve in my previous article. The normal map is incorrect. If I follow my previous If you create a normal map using the same method as creating other maps in the article, you will find that the normal map is incorrect.
If you don’t understand the dressing process here, you can refer to my previous
3D model character dressing system 1
3D model character Replacement system 2
Please add image description

Normal map problem

The way to create textures before is as follows
Here you can see that the format when I created all the pictures was RGBA32, but I later found out after checking on the Internet , in fact, the normal map is a picture without A channel, so RGB24 should be used here instead of RGBA32. I will not include the before and after comparison pictures, because I have encapsulated the method here and it is no longer the original way. Create and merge textures.

newAlbedoMapTex = new Texture2D(COMBINE_TEXTURE_MAX, COMBINE_TEXTURE_MAX, TextureFormat.RGBA32, true);
newNormalMapTex = new Texture2D(COMBINE_TEXTURE_MAX, COMBINE_TEXTURE_MAX, TextureFormat.RGBA32, true);
newMaskMapTex = new Texture2D(COMBINE_TEXTURE_MAX, COMBINE_TEXTURE_MAX, TextureFormat.RGBA32, true);

planning and packaging

Here I will briefly talk about the flaws in my previous script design and what I want to change it to. At the end of the article, I will post all the scripts and resources that I have modified. If you encounter problems when using the script later, You can ask me.

defect

  1. It turns out that the merged design is too rigid. The properties of the material ball are directly extracted and not dynamically modified. This means that I can only modify it manually. If I change the material with four textures, I need to modify the following code, so It’s too inconvenient, I need to encapsulate it here
    private const string COMBINE_ALBEDOMAP_TEXTURE = "_AlbedoMap";
    private const string COMBINE_NORMALMAP_TEXTURE = "_NormalMap";
    private const string COMBINE_MASKMAP_TEXTURE = "_MaskMap";
  1. The size of the created texture is also fixed. There is also a flaw here and it cannot be adjusted dynamically.
   /// <summary>
    /// Only for merge materials.
    /// </summary>
	private const int COMBINE_TEXTURE_MAX = 256;
  1. There are more or less flaws in merging, but it is not a problem. If the value passed here is true, then it needs to be merged, but if the merge is unsuccessful, an error will be reported directly.
if (combine)
        {
    
    
            Shader tmpShader = Shader.Find("E3D/Actor/PBR-MaskRG-Normal");
            newMaterial = new Material(tmpShader);
            oldUV = new List<Vector2[]>();

            // merge the texture
            List<Texture2D> AlbedoTextures = new List<Texture2D>();
            List<Texture2D> NormalTextures = new List<Texture2D>();
            List<Texture2D> MaskTextures = new List<Texture2D>();

            for (int i = 0; i < materials.Count; i++)
            {
    
    
                AlbedoTextures.Add(materials[i].GetTexture(COMBINE_ALBEDOMAP_TEXTURE) as Texture2D);
                NormalTextures.Add(materials[i].GetTexture(COMBINE_NORMALMAP_TEXTURE) as Texture2D);
                MaskTextures.Add(materials[i].GetTexture(COMBINE_MASKMAP_TEXTURE) as Texture2D);
            }
            newAlbedoMapTex = new Texture2D(COMBINE_TEXTURE_MAX, COMBINE_TEXTURE_MAX, TextureFormat.RGBA32, true);
            newNormalMapTex = new Texture2D(COMBINE_TEXTURE_MAX, COMBINE_TEXTURE_MAX, TextureFormat.RGBA32, true);
            newMaskMapTex = new Texture2D(COMBINE_TEXTURE_MAX, COMBINE_TEXTURE_MAX, TextureFormat.RGBA32, true);

            Rect[] uvs = newAlbedoMapTex.PackTextures(AlbedoTextures.ToArray(), 0);
            //newNormalMapTex.PackTextures(NormalTextures.ToArray(), 0);
            newMaskMapTex.PackTextures(MaskTextures.ToArray(), 0);

            newMaterial.SetTexture(COMBINE_ALBEDOMAP_TEXTURE, newAlbedoMapTex);
            newMaterial.SetTexture(COMBINE_NORMALMAP_TEXTURE, newNormalMapTex);
            newMaterial.SetTexture(COMBINE_MASKMAP_TEXTURE, newMaskMapTex);

            // reset uv
            Vector2[] uva, uvb;
            for (int i = 0; i < combineInstances.Count; i++)
            {
    
    
                uva = combineInstances[i].mesh.uv;
                uvb = new Vector2[uva.Length];
                for (int k = 0; k < uva.Length; k++)
                {
    
    
                    uvb[k] = new Vector2((uva[k].x * uvs[i].width) + uvs[i].x, (uva[k].y * uvs[i].height) + uvs[i].y);
                }
                oldUV.Add(uva);
                combineInstances[i].mesh.uv = uvb;
            }
        }

		if (combine)
		{
    
    
    		r.material = newMaterial;
        	for (int i = 0; i < combineInstances.Count; i++)
        	{
    
    
            	combineInstances[i].mesh.uv = oldUV[i];
        	}
   		}
  1. Shader is also hard-coded and not dynamic.
 Shader tmpShader = Shader.Find("E3D/Actor/PBR-MaskRG-Normal");

Modify and organize

  1. Create a configuration class and pass in a customized configuration file according to your own needs when creating a model. This way you can dynamically adjust the size of the created texture, dynamically adjust the number of merged textures, and dynamically modify the material Shader you want to merge into.

/// <summary>
/// 贴图属性
/// </summary>
public class CombineClass
{
    
    
    /// <summary>
    /// 新Shader属性名
    /// </summary>
    public string NewShaderPropertiesName = "_AlbedoMap";

    /// <summary>
    /// 原始Shader属性名
    /// </summary>
    public string OriginalPropertiesName = "_AlbedoMap";

    /// <summary>
    /// 图片格式
    /// </summary>
    public TextureFormat Format = TextureFormat.RGBA32;

    /// <summary>
    /// 
    /// </summary>
    public bool MipChain = true;

    /// <summary>
    /// 实例化
    /// </summary>
    /// <param name="propertiesName"></param>
    /// <param name="format"></param>
    /// <param name="mipChain"></param>
    public CombineClass(string newPropertiesName, string orinalPropertiesName, TextureFormat format, bool mipChain)
    {
    
    
        this.NewShaderPropertiesName = newPropertiesName;
        this.OriginalPropertiesName = orinalPropertiesName;
        this.Format = format;
        this.MipChain = mipChain;
    }
}


public class CombineConfig
{
    
    
    /// <summary>
    /// Only for merge materials.
    /// 仅适用于合并材质.
    /// </summary>
    private int COMBINE_TEXTURE_MAX = 256;

    /// <summary>
    /// Shader名称
    /// </summary>
    private string ShaderName = "";

    /// <summary>
    /// 贴图设置集合
    /// </summary>
    private List<CombineClass> combineClasses = new List<CombineClass>();

    /// <summary>
    /// 实例化
    /// </summary>
    /// <param name="COMBINE_TEXTURE_MAX"></param>
    /// <param name="combineClasses"></param>
    public CombineConfig(int COMBINE_TEXTURE_MAX, string ShaderName, List<CombineClass> combineClasses)
    {
    
    
        this.COMBINE_TEXTURE_MAX = COMBINE_TEXTURE_MAX;
        this.ShaderName = ShaderName;
        this.combineClasses = combineClasses;
    }

    /// <summary>
    /// 获取合并贴图大小
    /// </summary>
    /// <returns></returns>
    public int CombineTextureMax() {
    
     return this.COMBINE_TEXTURE_MAX; }

    /// <summary>
    /// 获取Shader名
    /// </summary>
    /// <returns></returns>
    public string GetShaderName() {
    
     return this.ShaderName; }

    /// <summary>
    /// 获取贴图设置集合数量
    /// </summary>
    /// <returns></returns>
    public int GetCombineCount() {
    
     return this.combineClasses.Count; }

    /// <summary>
    /// 获取贴图设置集合
    /// </summary>
    /// <returns></returns>
    public List<CombineClass> GetCombineList() {
    
     return combineClasses; }
}
  1. Get all the Shaders of the model that need to be merged. If there is only one Shader, the batch will be combined when combine is true. If there are too many Shaders, the batch will not be combined even if combine is true.
  2. A material callback has been added so that the material ball can be set when called.

Integrate

CombineClass.cs


/// <summary>
/// 贴图属性
/// </summary>
public class CombineClass
{
    
    
    /// <summary>
    /// 新Shader属性名
    /// </summary>
    public string NewShaderPropertiesName = "_AlbedoMap";

    /// <summary>
    /// 原始Shader属性名
    /// </summary>
    public string OriginalPropertiesName = "_AlbedoMap";

    /// <summary>
    /// 图片格式
    /// </summary>
    public TextureFormat Format = TextureFormat.RGBA32;

    /// <summary>
    /// 
    /// </summary>
    public bool MipChain = true;

    /// <summary>
    /// 实例化
    /// </summary>
    /// <param name="propertiesName"></param>
    /// <param name="format"></param>
    /// <param name="mipChain"></param>
    public CombineClass(string newPropertiesName, string orinalPropertiesName, TextureFormat format, bool mipChain)
    {
    
    
        this.NewShaderPropertiesName = newPropertiesName;
        this.OriginalPropertiesName = orinalPropertiesName;
        this.Format = format;
        this.MipChain = mipChain;
    }
}

CombineConfig.cs


public class CombineConfig
{
    
    
    /// <summary>
    /// Only for merge materials.
    /// 仅适用于合并材质.
    /// </summary>
    private int COMBINE_TEXTURE_MAX = 256;

    /// <summary>
    /// Shader名称
    /// </summary>
    private string ShaderName = "";

    /// <summary>
    /// 贴图设置集合
    /// </summary>
    private List<CombineClass> combineClasses = new List<CombineClass>();

    /// <summary>
    /// 实例化
    /// </summary>
    /// <param name="COMBINE_TEXTURE_MAX"></param>
    /// <param name="combineClasses"></param>
    public CombineConfig(int COMBINE_TEXTURE_MAX, string ShaderName, List<CombineClass> combineClasses)
    {
    
    
        this.COMBINE_TEXTURE_MAX = COMBINE_TEXTURE_MAX;
        this.ShaderName = ShaderName;
        this.combineClasses = combineClasses;
    }

    /// <summary>
    /// 获取合并贴图大小
    /// </summary>
    /// <returns></returns>
    public int CombineTextureMax() {
    
     return this.COMBINE_TEXTURE_MAX; }

    /// <summary>
    /// 获取Shader名
    /// </summary>
    /// <returns></returns>
    public string GetShaderName() {
    
     return this.ShaderName; }

    /// <summary>
    /// 获取贴图设置集合数量
    /// </summary>
    /// <returns></returns>
    public int GetCombineCount() {
    
     return this.combineClasses.Count; }

    /// <summary>
    /// 获取贴图设置集合
    /// </summary>
    /// <returns></returns>
    public List<CombineClass> GetCombineList() {
    
     return combineClasses; }
}

UCombineSkinnedMgr.cs

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

public class UCombineSkinnedMgr
{
    
    
    /// <summary>
    /// Combine SkinnedMeshRenderers together and share one skeleton.
    /// 将蒙皮网格渲染器组合在一起并共享一个骨架.(优化合批报错,如果无法合批但是combine为true,则进行不分批处理)
    /// Merge materials will reduce the drawcalls, but it will increase the size of memory.
    /// 合并材质会减少drawcalls,但会增加内存大小.
    /// </summary>
    /// <param name="skeleton">combine meshes to this skeleton(a gameobject)</param>
    /// <param name="meshes">meshes need to be merged</param>
    /// <param name="combine">merge materials or not</param>
    public void CombineObject(CombineConfig config, GameObject skeleton, SkinnedMeshRenderer[] meshes, bool combine = false, Action<Material> action = null)
    {
    
    
        //Fetch all bones of the skeleton
        //获取所有的骨骼
        List<Transform> transforms = new List<Transform>();
        transforms.AddRange(skeleton.GetComponentsInChildren<Transform>(true));

        //the list of materials
        List<Shader> shaders = new List<Shader>();

        //the list of materials
        //所有材质球
        List<Material> materials = new List<Material>();

        //the list of meshes
        //所有网格
        List<CombineInstance> combineInstances = new List<CombineInstance>();

        //the list of bones
        //所有骨骼节点
        List<Transform> bones = new List<Transform>();

        #region 合批使用

        // Below informations only are used for merge materilas(bool combine = true)
        //以下信息仅用于合并材料(bool-combine=true)原始UV坐标
        List<Vector2[]> oldUV = null;
        //合并之后得新材质球
        Material newMaterial = null;
        //创建新贴图集合
        List<Texture2D> MapTex = new List<Texture2D>();
        for (int i = 0; i < config.GetCombineCount(); i++)
        {
    
    
            MapTex.Add(null);
        }
        #endregion

        // Collect information from meshes and shader
        // 获取网格和shader信息
        for (int i = 0; i < meshes.Length; i++)
        {
    
    
            SkinnedMeshRenderer smr = meshes[i];
            materials.AddRange(smr.materials);
            for (int j = 0; j < smr.materials.Length; j++)
            {
    
    
                if (!shaders.Contains(smr.materials[j].shader))
                {
    
    
                    shaders.Add(smr.materials[j].shader);
                }
            }
            // Collect meshes
            for (int sub = 0; sub < smr.sharedMesh.subMeshCount; sub++)
            {
    
    
                CombineInstance ci = new CombineInstance();
                ci.mesh = smr.sharedMesh;
                ci.subMeshIndex = sub;
                combineInstances.Add(ci);
            }
            // Collect bones
            for (int j = 0; j < smr.bones.Length; j++)
            {
    
    
                int tBase = 0;
                for (tBase = 0; tBase < transforms.Count; tBase++)
                {
    
    
                    if (smr.bones[j].name.Equals(transforms[tBase].name))
                    {
    
    
                        bones.Add(transforms[tBase]);
                        break;
                    }
                }
            }
        }
        // merge materials
        //合并材质
        if (combine && shaders.Count == 1)
        {
    
    
            if (config.GetShaderName() == "") newMaterial = new Material(shaders[0]);
            else newMaterial = new Material(Shader.Find(config.GetShaderName()));

            oldUV = new List<Vector2[]>();

            // merge the texture
            //合并贴图
            List<List<Texture2D>> texture2Ds = new List<List<Texture2D>>();
            for (int i = 0; i < config.GetCombineCount(); i++)
            {
    
    
                texture2Ds.Add(new List<Texture2D>());
            }

            for (int i = 0; i < materials.Count; i++)
            {
    
    
                Material mat = materials[i];
                for (int j = 0; j < config.GetCombineCount(); j++)
                {
    
    
                    texture2Ds[j].Add(mat.GetTexture(config.GetCombineList()[j].OriginalPropertiesName) as Texture2D);
                }
                //int ind = 0;
                //texture2Ds.ForEach((txtLst) =>
                //{
    
    
                //    txtLst.Add(mat.GetTexture(config.GetCombineList()[ind].OriginalPropertiesName) as Texture2D);
                //    ind++;
                //});
            }

            Rect[] uvs = new Rect[config.GetCombineCount()];
            for (int i = 0; i < config.GetCombineCount(); i++)
            {
    
    
                MapTex[i] = new Texture2D(config.CombineTextureMax(), config.CombineTextureMax(), config.GetCombineList()[i].Format, config.GetCombineList()[i].MipChain);
                uvs = MapTex[i].PackTextures(texture2Ds[i].ToArray(), 0);
                newMaterial.SetTexture(config.GetCombineList()[i].NewShaderPropertiesName, MapTex[i]);
            }

            action?.Invoke(newMaterial);

            #region 导出图片

            //WriteIntoPic(TextureToTexture2D(newMaterial.GetTexture(COMBINE_ALBEDOMAP_TEXTURE)), "albedo");
            //WriteIntoPic(TextureToTexture2D(newMaterial.GetTexture(COMBINE_NORMALMAP_TEXTURE)), "normal");
            //WriteIntoPic(TextureToTexture2D(newMaterial.GetTexture(COMBINE_MASKMAP_TEXTURE)), "mask");

            #endregion

            // reset uv
            Vector2[] uva, uvb;
            for (int i = 0; i < combineInstances.Count; i++)
            {
    
    
                uva = combineInstances[i].mesh.uv;
                uvb = new Vector2[uva.Length];
                for (int k = 0; k < uva.Length; k++)
                {
    
    
                    uvb[k] = new Vector2((uva[k].x * uvs[i].width) + uvs[i].x, (uva[k].y * uvs[i].height) + uvs[i].y);
                }
                oldUV.Add(uva);
                combineInstances[i].mesh.uv = uvb;
            }
        }

        // Create a new SkinnedMeshRenderer
        SkinnedMeshRenderer oldSKinned = skeleton.GetComponent<SkinnedMeshRenderer>();
        if (oldSKinned != null)
        {
    
    
            GameObject.DestroyImmediate(oldSKinned);
        }
        SkinnedMeshRenderer r = skeleton.AddComponent<SkinnedMeshRenderer>();
        r.sharedMesh = new Mesh();
        r.sharedMesh.CombineMeshes(combineInstances.ToArray(), combine && shaders.Count == 1, false);// Combine meshes
        r.bones = bones.ToArray();// Use new bones
        if (combine && shaders.Count == 1)
        {
    
    
            r.material = newMaterial;
            for (int i = 0; i < combineInstances.Count; i++)
            {
    
    
                combineInstances[i].mesh.uv = oldUV[i];
            }
        }
        else
        {
    
    
            r.materials = materials.ToArray();
        }
    }

    #region 导出图片

    private Texture2D TextureToTexture2D(Texture texture)
    {
    
    
        Texture2D texture2D = new Texture2D(texture.width, texture.height, TextureFormat.RGBA32, false);
        RenderTexture currentRT = RenderTexture.active;
        RenderTexture renderTexture = RenderTexture.GetTemporary(texture.width, texture.height, 32);
        Graphics.Blit(texture, renderTexture);

        RenderTexture.active = renderTexture;
        texture2D.ReadPixels(new Rect(0, 0, renderTexture.width, renderTexture.height), 0, 0);
        texture2D.Apply();

        RenderTexture.active = currentRT;
        RenderTexture.ReleaseTemporary(renderTexture);

        return texture2D;
    }

    public void WriteIntoPic(Texture2D tex, string name)
    {
    
    
        //编码纹理为PNG格式 
        var bytes = tex.EncodeToPNG();
        File.WriteAllBytes(Application.dataPath + "/" + name + ".png", bytes);
    }

    #endregion
}

UCharacterController.cs

using UnityEngine;

public class UCharacterController
{
    
    
    /// <summary>
    /// GameObject reference
    /// </summary>
	public GameObject Instance = null;

    /// <summary>
    /// 换装总组装数量
    /// </summary>
    public int m_MeshCount = 7;

    public string Role_Skeleton;
    public string Role_Body;
    public string Role_Clothes;
    public string Role_Hair;
    public string Role_Head;
    public string Role_Pants;
    public string Role_Shoes;
    public string Role_Socks;

    /// <summary>
    /// 创建对象
    /// </summary>
    /// <param name="job"></param>
    /// <param name="skeleton"></param>
    /// <param name="body"></param>
    /// <param name="cloak"></param>
    /// <param name="face"></param>
    /// <param name="hair"></param>
    /// <param name="hand"></param>
    /// <param name="leg"></param>
    /// <param name="mainweapon"></param>
    /// <param name="retina"></param>
    /// <param name="subweapon"></param>
    /// <param name="combine"></param>
    public UCharacterController(CombineConfig config, string job, string skeleton, string body, string clothes, string hair, string head, string pants, string shoes, string socks, bool combine = false, System.Action<Material> action = null)
    {
    
    
        Object res = Resources.Load("RoleMesh/" + job + "/" + job + "/" + skeleton);
        this.Instance = GameObject.Instantiate(res) as GameObject;
        this.Role_Skeleton = skeleton;
        this.Role_Body = body;
        this.Role_Clothes = clothes;
        this.Role_Hair = hair;
        this.Role_Head = head;
        this.Role_Pants = pants;
        this.Role_Shoes = shoes;
        this.Role_Socks = socks;

        string[] equipments = new string[m_MeshCount];
        equipments[0] = "Body/" + Role_Body;
        equipments[1] = "Clothes/" + Role_Clothes;
        equipments[2] = "Hair/" + Role_Hair;
        equipments[3] = "Head/" + Role_Head;
        equipments[4] = "Pants/" + Role_Pants;
        equipments[5] = "Shoes/" + Role_Shoes;
        equipments[6] = "Socks/" + Role_Socks;

        SkinnedMeshRenderer[] meshes = new SkinnedMeshRenderer[m_MeshCount];
        GameObject[] objects = new GameObject[m_MeshCount];
        for (int i = 0; i < equipments.Length; i++)
        {
    
    
            res = Resources.Load("RoleMesh/" + job + "/" + equipments[i]);
            objects[i] = GameObject.Instantiate(res) as GameObject;
            meshes[i] = objects[i].GetComponentInChildren<SkinnedMeshRenderer>();
        }

        UCharacterManager.Instance.CombineSkinnedMgr.CombineObject(config, Instance, meshes, combine, action);

        for (int i = 0; i < objects.Length; i++)
        {
    
    
            GameObject.DestroyImmediate(objects[i].gameObject);
        }
    }

    public void Delete()
    {
    
    
        GameObject.Destroy(Instance);
    }
}

Test code
UCharacterManager.cs

using UnityEngine;
using System.Collections.Generic;

/// <summary>
/// 换装管理器
/// </summary>
public class UCharacterManager : MonoBehaviour
{
    
    
    public static UCharacterManager Instance;

    private UCombineSkinnedMgr skinnedMgr = null;
    public UCombineSkinnedMgr CombineSkinnedMgr {
    
     get {
    
     return skinnedMgr; } }

    private int characterIndex = 0;
    private Dictionary<int, UCharacterController> characterDic = new Dictionary<int, UCharacterController>();
    public UCharacterManager()
    {
    
    

        skinnedMgr = new UCombineSkinnedMgr();
    }

    private void Awake()
    {
    
    
        Instance = this;
    }

    public UCharacterController mine;

    private void Start()
    {
    
    
        //mine = Generatecharacter(new CombineConfig(256, "", new List<CombineClass> {
    
    
        //    new CombineClass("_AlbedoMap","_AlbedoMap",TextureFormat.RGBA32,true),
        //    new CombineClass("_NormalMap","_NormalMap",TextureFormat.RGB24,true),
        //    new CombineClass("_MaskMap","_MaskMap", TextureFormat.RGBA32,true),
        //}), "MaTa", "MaTa", "Body1", "Clothes1", "Hair1", "Head1", "Pants1", "Shoes1", "Socks1", true, (mat) =>
        //{
    
    
        //    mat.SetFloat("_SideLightScale", 0);
        //});
        mine = Generatecharacter(new CombineConfig(256, "Standard", new List<CombineClass> {
    
    
            new CombineClass("_MainTex","_AlbedoMap",TextureFormat.RGBA32,true),
            new CombineClass("_BumpMap","_NormalMap",TextureFormat.RGB24,true),
            new CombineClass("_DetailMask","_MaskMap", TextureFormat.RGBA32,true),
        }), "MaTa", "MaTa", "Body1", "Clothes1", "Hair1", "Head1", "Pants1", "Shoes1", "Socks1", true, (mat) =>
        {
    
    
            mat.SetFloat("_SideLightScale", 0);
        });
    }

    private void Update()
    {
    
    
        if (Input.GetKeyDown(KeyCode.Space))
        {
    
    
            ChangeRole();
        }
    }

    public void ChangeRole()
    {
    
    
        //if (mine != null)
        //{
    
    
        //    mine.Delete();
        //}

        //int a = Random.Range(1, 4);

        //mine = Generatecharacter("MaTa", "MaTa", "Body" + a, "Clothes" + a, "Hair" + a, "Head" + a, "Pants" + a, "Shoes" + a, "Socks" + a, true);
    }

    #region 创建人物模型骨骼

    public UCharacterController Generatecharacter(CombineConfig config, string job, string skeleton, string body, string clothes, string hair, string hand, string pants, string shoes, string socks, bool combine = false, System.Action<Material> action = null)
    {
    
    

        UCharacterController instance = new UCharacterController(config, job, skeleton, body, clothes, hair, hand, pants, shoes, socks, combine, action);
        characterDic.Add(characterIndex, instance);
        characterIndex++;

        return instance;
    }

    #endregion
}

Complete resources

Summarize

This article on the optimization of character dressing will probably not be published later. The optimization is expected to be like this. If you have other good ideas, you can send me a private message to share them. I hope this article can be helpful to everyone, thank you for your support and attention, and thank you for your likes.

Guess you like

Origin blog.csdn.net/qq_42194657/article/details/134392776