Shader变体裁剪(已经商业化项目实践)

前言 

        笔者先前在开发某个重度SLG项目时,由于项目中的大地图使用了实时光照,Lightmap,雾效等,导致光照相关的Shader变体很多,在打包时光是编译Shader变体就要几个小时,而且发现移动平台在运行时占用内存很大,光是Shader资源就占了1G多的内存,因此Shader变体裁剪势在必行;

必备知识

        关于Shader变体的相关知识可查阅官方手册,笔者也是读官方手册研究出解决办法,一般能发现这个文档,对Shader变体也是有了解的,链接附上:https://docs.unity3d.com/Manual/shader-variants-and-keywords.html

里面说明了Keywords的相关原理以及unity在打包时对keywords的处理方式;

思路分析

        Shader变体裁剪的核心思想是打包时只编译我们需要的变体,怎样确定我们需要哪些变体呢?我们知道,unity编辑器会自动记录游戏中当前已经用了的变体,如图所示:

            

        因此,我们先运行游戏的相关场景,让unity收集相关的变体,再Save to Asset,获取当前收集到的变体,即上图的all文件;

        然后,在打包编译Shader时,可以依据上面all文件来编译需要的Shader变体,Unity提供了接口让我们可以自定义Shader变体裁剪:

        

        我们在这个接口中,根据all文件中记录的变体,针对性地编译我们需要的变体;

实现细节:

        1. 首先,编辑器和真机跑游戏时,需要的变体不一定是相同的,不过还好笔者只发现了一处,就是:

        

        注意项目不要开这个选项,如果开了,只在编辑器上有效,在移动平台是不生效的(笔者那时的项目是这样的),导致编辑器收集的变体,和移动平台需要的变体不匹配;

        不排除有其他的情况,如果出了移动平台丢失Shader的情况,用FrameDebug分别查看编辑器下和移动平台下对应的Keywords,找出差别分析一般就可以发现问题:

        

        2. 如果对项目的所有Shader开启变体裁剪,那么很麻烦,得将所有的Shader的变体都收集全,这是一项不可能的工作,比如一些特效,谁知道什么时候触发呢,因此,笔者只针对包含大量Keywords的Shader开启变体裁剪,其他的Shader就不开裁剪,默认全编译,这样我们只需要收集特定情况的Shader变体;

        3. 由于项目一般有不同的品质,在不同的品质下,光照和阴影计算一般是不同的,由此就有不同的变体;因此我们运行游戏时,得在各个情况下设置各个品质,触发unity对变体的收集;

        4. 收集各个情况的Shader变体可能得写编辑器代码支持,对于场景,我们只需要跑到那就好,但是场景上面可能有各个模型,而模型只在需要的时候加载,为了便于收集模型的Shader变体,可写编辑器代码,手动将所有模型全部实例化到场景,这样我们就可以收集所有的模型的Shader变体了;

        5. 其他情况:

        得确定代码是否有在某些特殊情况下开启某些关键字,防止变体没收集到;

        注意Shader keywords的声明是否正确,笔者项目先前升级Unity引擎时,unity对keywords做了修改,导致老的Shader在编译后不能得到正确的shader变体,总之用FrameDebug调试就行了;

        注意,在打AB包时,如果我们没有ForceRebuild,打包会使用缓存,一但此时我们要收集缺失的变体,打AB包不会触发Shader的编译,因为Shader资源没变化,只是我们的处理流程改了。一般正式上线的项目,打AB得开ForceRebuild,否则可能由于缓存导致资源没重新打;

成效:

        笔者的项目,在windows平台下跑游戏收集相关变体,可以直接应用到移动平台上,如安卓和ios,相当于只要在windows平台下收集一趟就好,还是很稳的。

使用方式:

        笔者在最后面会将源码公布,大家可以直接下载源码导入到项目中,会有如下工具:

        

        Save ShaderVariants,保存游戏当前收集到的变体,相当于:

                

        Merge ShaderVariants,合并Shader变体:

                当我们有新收集到的Shader变体时,需要将新收集到的和到all里面:

                

                执行Merge ShaderVariants,就会帮我们合并;

        提取需要预热的变体,保存到Resources文件夹下:

                有时候我们需要预热某些Shader的变体,我们可以使用这个工具;

                在我们收集到的所有Shader的变体中,提取我们指定的Shader的变体;

附加代码

#define SAVE_SHADER_INFO

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text;
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Rendering;
using UnityEngine;
using UnityEngine.Rendering;
using static UnityEngine.ShaderVariantCollection;

public class ShaderVariantsStrip : IPreprocessShaders
{
    public const string ShaderVariantsCollectPath = "Assets/ShaderVariantsForStrip/all.shadervariants";

    //这几个变体比较多的shader,只保留 在编辑器下跑游戏时 收集到的变体
    public static HashSet<string> stripShaderNames = new HashSet<string> {
        "FMGame/Lit",
        "FMGame/Simple Lit",
        "FMGame/Cloud",
        "FMGame/Environment/Lit_Pond",
        "FMGame/Versatile Blend",
        "FMGame/Simple Lit HAlphaBlend",
        "FMGame/FMLit_Scanning",
        "FMGame/VFX/Unlit_Hologram",
        "FMGame/GpuInstancesLit",
        "FMGame/GpuInstancesSimpleLit",
        "FMGame/Simple Lit GPUInstancing",
        "FMGame/Lit GPUInstancing",
        "FMGame/Lit_NoiseFlow",
        "FMGame/VFX/Lit_BuildingScan",
        "FMGame/Lit GPUInstancing Snow",
        "FMGame/Simple Lit GPUInstancing Snow",
        "FMGame/Lit_Snow",
        "FMGame/Lit_Wind",
        "FMGame/Lit_Outline",
        "FMGame/SimpleLit_Snow",
        "FMGame/Simple Lit Wind",
        "FMGame/Environment/Lit_Pond_Snow",

        "GPUSkinning/GPUSkinning_Outline",
        "GPUSkinning/GPUSkinning_Lit",
        "GPUSkinning/GPUSkinning_Lit_Skin4",
        "GPUSkinning/GPUSkinning_SimpleLit",
        "GPUSkinning/GPUSkinning_SimpleLit_Skin4",
        "GPUSkinning/GPUSkinning_Sample_Unlit",
        "GPUSkinning/GPUSkinning_Unlit",
        "GPUSkinning/GPUSkinning_Unlit_Skin4",
        "GPUSkinning/GPUSkinningUnlitSkin4_Outline",
        "GPUSkinning/GPUSkinningUnlitSkin4_Shadow",

        "MTE/URP/3 Textures2",
        "MTE/URP/4 Textures",
        "MTE/URP/5 Textures",
        "MTE/URP/TextureArray2Tex_2Normal",
        "MTE/URP/TextureArray3Tex_3Normal",
        "MTE/URP/TextureArray4Tex_4Normal_Simplified",
        "MTE/URP/TextureArray4Tex_4Normal",
        "MTE/URP/TextureArray5Tex_5Normal",
        "MTE/URP/TextureArray5Tex_6Normal",

        "MTE/URP/TextureArray2Tex_NoNormal",
        "MTE/URP/TextureArray3Tex_NoNormal",
        "MTE/URP/TextureArray4Tex_NoNormal",
        "MTE/URP/TextureArray4Tex_NoNormal_Simplified",
        "MTE/URP/TextureArray5Tex_NoNormal",
        "MTE/URP/TextureArray6Tex_NoNormal",
        "MTE/URP/TextureArray8Tex_NoNormal",
        "MTE/URP/TextureArray8Tex_8Normal",
        "MTE/URP/TextureArray8Tex_8Normal_Simplified",
        "MTE/URP/TextureArray8Tex_NoNormal_Simplified",

        "Universal Render Pipeline/Lit",
        "Universal Render Pipeline/Complex Lit",
        "Universal Render Pipeline/Simple Lit",
        
        "Shader Graphs/PhysicalMaterial3DsMax",
        "efronli/UV",
        "efronli/UV_b",
        "efronli/NQ_whater",
        "efronli/UV_Alpha Blended",
        "Effect_Mid/Additive",
        "Effect_Mid/Alpha Blend",
        "Legacy Shaders/Diffuse",
        "VFX/ComDissovle",
        "JYi/round_VertexColorContrel",
        "Kero/Alpha-Blended_HDR_URP",

        "Hidden/TerrainEngine/Details/UniversalPipeline/BillboardWavingDoublePass",
        "Hidden/TerrainEngine/Details/UniversalPipeline/WavingDoublePass",
        "Hidden/TerrainEngine/Details/UniversalPipeline/Vertexlit",
        "Hidden/Internal-PrePassLighting",
        "Hidden/Internal-DeferredShading",
        "Hidden/Universal Render Pipeline/UberPost",
        "Hidden/Universal Render Pipeline/StencilDeferred",
    };

    #region for Build
    private Dictionary<Shader, Dictionary<PassType, List<HashSet<string>> > > m_shader2keywords = new Dictionary<Shader, Dictionary<PassType, List<HashSet<string>> > >();

    private static ShaderVariantsStrip m_ins = null;
    public ShaderVariantsStrip()
    {
        m_ins = this;

        ShaderVariantCollection shaderVariantCollection = AssetDatabase.LoadAssetAtPath<ShaderVariantCollection>(ShaderVariantsCollectPath);
        if (shaderVariantCollection == null)
            return;
        SerializedObject serializedObject = new SerializedObject(shaderVariantCollection);
        SerializedProperty m_Shaders = serializedObject.FindProperty("m_Shaders");
        for (var i = 0; i < m_Shaders.arraySize; ++i)
        {
            var entryProp = m_Shaders.GetArrayElementAtIndex(i);
            Shader shader = (Shader)entryProp.FindPropertyRelative("first").objectReferenceValue;
            if (shader != null)
            {
                if(!m_shader2keywords.TryGetValue(shader, out Dictionary<PassType, List<HashSet<string>> >passType2keywords))
                {
                    passType2keywords = new Dictionary<PassType, List<HashSet<string>> >();
                    m_shader2keywords.Add(shader, passType2keywords);
                }

                var variantsProp = entryProp.FindPropertyRelative("second.variants");
                for (var j = 0; j < variantsProp.arraySize; ++j)
                {
                    var prop = variantsProp.GetArrayElementAtIndex(j);
                    var keywordsStr = prop.FindPropertyRelative("keywords").stringValue;
                    keywordsStr.Replace("\r\n", string.Empty);
                    string[] keywordArray = keywordsStr.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
                    var passType = (PassType)prop.FindPropertyRelative("passType").intValue;

                    if(!passType2keywords.TryGetValue(passType, out List<HashSet<string>> keywordSetList))
                    {
                        keywordSetList = new List<HashSet<string>>();
                        passType2keywords.Add(passType, keywordSetList);
                    }

                    HashSet<string> keywordSet = new HashSet<string>();
                    foreach (var item in keywordArray)
                    {
                        keywordSet.Add(item);
                    }
                    keywordSetList.Add(keywordSet);
                }
            }
        }
    }

    private StringBuilder stringBuilder = new StringBuilder();
    private Dictionary<string, List<string>> shader2Varaints = new Dictionary<string, List<string>>();

    public static void SaveBuildInfo(bool IsBuildAssetBundle)
    {
        if (m_ins != null)
        {
            foreach (var pair in m_ins.shader2Varaints)
            {
                foreach (var item in pair.Value)
                {
                    m_ins.stringBuilder.AppendLine(pair.Key + ": " + item);
                }
            }
            if (IsBuildAssetBundle)
                File.WriteAllText("Assets/BuildShaderInfo_AssetBundle.txt", m_ins.stringBuilder.ToString());
            else
                File.WriteAllText("Assets/BuildShaderInfo_Resources.txt", m_ins.stringBuilder.ToString());
        }
    }

    public int callbackOrder => -1000;

    Dictionary<ShaderCompilerPlatform, List<HashSet<string>>> m_Platform2ValidKeywordSetList = new Dictionary<ShaderCompilerPlatform, List<HashSet<string>>>();
    List<string> m_currentkeywords = new List<string>();

    HashSet<ShaderCompilerPlatform> m_platforms = new HashSet<ShaderCompilerPlatform>();

    List<HashSet<string>> m_CachHashSets = new List<HashSet<string>>();
    private HashSet<string> GetHashSet()
    {
        HashSet<string> ret;

        int count = m_CachHashSets.Count;
        if (count > 0)
        {
            ret = m_CachHashSets[count - 1];
            m_CachHashSets.RemoveAt(count - 1);
        }
        else
        {
            ret = new HashSet<string>();
        }
        return ret;
    }

    private void ReleaseHashSet(List<HashSet<string>> hashSetList)
    {
        foreach (var hashSet in hashSetList)
        {
            hashSet.Clear();
            m_CachHashSets.Add(hashSet);
        }
        hashSetList.Clear();
    }

    /*      测试代码
    [MenuItem("Build/Build Shader")]
    static void BuildShader()
    {
        string path = "AssetBundles";
        if(Directory.Exists(path))
            Directory.Delete(path, true);
        Directory.CreateDirectory(path);

        
        BuildPipeline.BuildAssetBundles(path, new AssetBundleBuild[] { 
            new AssetBundleBuild()
            {
                assetNames = new string[]{ "Assets/MyShader/TestShader.shader"},
                assetBundleName = "test"
            }
        }, BuildAssetBundleOptions.ForceRebuildAssetBundle, EditorUserBuildSettings.activeBuildTarget);
    }
    */

    public void OnProcessShader(Shader shader, ShaderSnippetData snippet, IList<ShaderCompilerData> data)
    {
        m_platforms.Clear();
        foreach (var item in data)
        {
            if(!m_platforms.Contains(item.shaderCompilerPlatform))
            {
                m_platforms.Add(item.shaderCompilerPlatform);
            }
        }

        m_Platform2ValidKeywordSetList.Clear();
        foreach (var platform in m_platforms)
        {
            if(m_Platform2ValidKeywordSetList.TryGetValue(platform, out List<HashSet<string>> m_ValidKeywordSetList))
            {
                ReleaseHashSet(m_ValidKeywordSetList);
            }
            else
            {
                m_ValidKeywordSetList = new List<HashSet<string>>();
                m_Platform2ValidKeywordSetList.Add(platform, m_ValidKeywordSetList);
            }

            if (m_shader2keywords.TryGetValue(shader, out Dictionary<PassType, List<HashSet<string>>> passType2keywords))
            {
                if (passType2keywords.TryGetValue(snippet.passType, out List<HashSet<string>> keywordSetList))
                {
                    foreach (var keywordSet in keywordSetList)
                    {
                        HashSet<string> newSet = GetHashSet();
                        foreach (var keyword in keywordSet)
                        {
                            LocalKeyword localKeyword = new LocalKeyword(shader, keyword);
                            if (ShaderUtil.PassHasKeyword(shader, snippet.pass, localKeyword, snippet.shaderType, platform))
                                newSet.Add(keyword);
                        }
                        m_ValidKeywordSetList.Add(newSet);
                    }
                }
            }
        }

        bool needStrip = checkNeedStripShader(shader.name);
        for (int i = data.Count - 1; i >= 0; --i)
        {
            ShaderKeywordSet shaderKeywordSet = data[i].shaderKeywordSet;
            ShaderKeyword[] shaderKeywords = shaderKeywordSet.GetShaderKeywords();
            m_currentkeywords.Clear();
            if (shaderKeywords.Length != 0)
            {
                for (int j = 0; j < shaderKeywords.Length; j++)
                {
                    string key = shaderKeywords[j].name;
                    m_currentkeywords.Add(key);
                }
            }

            bool need = false;
            if (needStrip)
            {
                bool needflag = false;
                List<HashSet<string>> m_ValidKeywordSetList = m_Platform2ValidKeywordSetList[data[i].shaderCompilerPlatform];
                foreach (var set in m_ValidKeywordSetList)
                {
                    if(m_currentkeywords.Count == set.Count)
                    {
                        bool flag = true;
                        foreach (var keyword in m_currentkeywords)
                        {
                            if(!set.Contains(keyword))
                            {
                                flag = false;
                                break;
                            }
                        }
                        if(flag)
                        {
                            needflag = true;
                            break;
                        }
                    }
                }
                need = needflag;
            }
            else
            {
                need = true;
            }
            if (!need)
                data.RemoveAt(i);

#if SAVE_SHADER_INFO
            if(need)
            {
                string str1 = snippet.shaderType + ", " + snippet.passType + ", " + snippet.passName + ": ";
                string str2 = string.Empty;
                foreach (var item in m_currentkeywords)
                {
                    str2 += item + ", ";
                }

                List<string> list;
                if(!shader2Varaints.TryGetValue(shader.name, out list))
                {
                    list = new List<string>();
                    shader2Varaints[shader.name] = list;
                }
                list.Add(str1 + str2);
            }
#endif
        }
    }

    private bool checkNeedStripShader(string shaderName)
    {
        if (stripShaderNames.Contains(shaderName))
            return true;
        return false;
    }
    #endregion

    #region for Tools

    [MenuItem("ShaderVariants/Save ShaderVariants")]
    static void SaveShaderVariants()
    {
        string dir = Path.GetDirectoryName(ShaderVariantsCollectPath);
        if (!Directory.Exists(dir))
            Directory.CreateDirectory(dir);
        string message = "Save shader variant collection";
        string assetPath = EditorUtility.SaveFilePanelInProject("Save Shader Variant Collection", "NewShaderVariants", "shadervariants", message, dir);
        if (!string.IsNullOrEmpty(assetPath))
        {
            Type type = typeof(ShaderUtil);
            MethodInfo methodInfo = type.GetMethod("SaveCurrentShaderVariantCollection", BindingFlags.Static | BindingFlags.NonPublic);
            methodInfo.Invoke(null, new object[] { assetPath });
        }
    }

    /// <summary>
    /// 合并收集到的shader变体
    /// </summary>
    [MenuItem("ShaderVariants/Merge ShaderVariants")]
    static void MergeShaderVariants()
    {
        string rootPath = "Assets/ShaderVariantsForStrip";
        string allName = "all.shadervariants";
        string allPath = rootPath + "/" + allName;

        ShaderVariant shaderVariant = new ShaderVariant();
        ShaderVariantCollection allCollection = new ShaderVariantCollection();
        List<string> keywordList = new List<string>();

        string[] fileNames = Directory.GetFiles(rootPath, "*.shadervariants");
        foreach (var fileName in fileNames)
        {
            ShaderVariantCollection curCollection = AssetDatabase.LoadAssetAtPath<ShaderVariantCollection>(fileName);
            SerializedObject serializedObject = new SerializedObject(curCollection);
            SerializedProperty m_Shaders = serializedObject.FindProperty("m_Shaders");
            for (var i = 0; i < m_Shaders.arraySize; ++i)
            {
                var entryProp = m_Shaders.GetArrayElementAtIndex(i);
                Shader shader = (Shader)entryProp.FindPropertyRelative("first").objectReferenceValue;
                if (shader != null)
                {
                    shaderVariant.shader = shader;
                    var variantsProp = entryProp.FindPropertyRelative("second.variants");

                    for (var j = 0; j < variantsProp.arraySize; ++j)
                    {
                        var prop = variantsProp.GetArrayElementAtIndex(j);
                        var keywordsStr = prop.FindPropertyRelative("keywords").stringValue;
                        keywordsStr.Replace("\n", string.Empty);
                        keywordsStr.Replace("\r\n", string.Empty);
                        string[] keywordArray = keywordsStr.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
                        shaderVariant.keywords = keywordArray;

                        var passType = (UnityEngine.Rendering.PassType)prop.FindPropertyRelative("passType").intValue;
                        shaderVariant.passType = passType;

                        if (!allCollection.Contains(shaderVariant))
                            allCollection.Add(shaderVariant);

                        //自动添加非INSTANCING_ON变体
                        keywordList.Clear();
                        keywordList.AddRange(keywordArray);
                        if (keywordList.Remove("INSTANCING_ON"))
                        {
                            string[] keywords = keywordList.ToArray();
                            shaderVariant.keywords = keywords;
                            if (!allCollection.Contains(shaderVariant))
                                allCollection.Add(shaderVariant);
                        }
                    }
                }
            }
        }

        AssetDatabase.CreateAsset(allCollection, allPath);
        AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
    }

    static HashSet<string> NeedWarmShaders = new HashSet<string>
    {
        "FMGame/Lit",
        "FMGame/Simple Lit",
        "FMGame/Cloud",
        "FMGame/Environment/Lit_Pond",
        "FMGame/Versatile Blend",
        "FMGame/Simple Lit HAlphaBlend",
        "FMGame/FMLit_Scanning",
        "FMGame/VFX/Unlit_Hologram",
        "FMGame/GpuInstancesLit",
        "FMGame/GpuInstancesSimpleLit",
        "FMGame/Simple Lit GPUInstancing",
        "FMGame/Lit GPUInstancing",
        "FMGame/Lit_NoiseFlow",
        "FMGame/VFX/Lit_BuildingScan",
        "FMGame/Lit GPUInstancing Snow",
        "FMGame/Simple Lit GPUInstancing Snow",
        "FMGame/Lit_Snow",
        "FMGame/Lit_Wind",
        "FMGame/Lit_Outline",
        "FMGame/SimpleLit_Snow",
        "FMGame/Simple Lit Wind",
        "FMGame/Environment/Lit_Pond_Snow",

        "MTE/URP/3 Textures2",
        "MTE/URP/4 Textures",
        "MTE/URP/5 Textures",
        "MTE/URP/TextureArray2Tex_2Normal",
        "MTE/URP/TextureArray3Tex_3Normal",
        "MTE/URP/TextureArray4Tex_4Normal_Simplified",
        "MTE/URP/TextureArray4Tex_4Normal",
        "MTE/URP/TextureArray5Tex_5Normal",
        "MTE/URP/TextureArray5Tex_6Normal",

        "MTE/URP/TextureArray2Tex_NoNormal",
        "MTE/URP/TextureArray3Tex_NoNormal",
        "MTE/URP/TextureArray4Tex_NoNormal",
        "MTE/URP/TextureArray4Tex_NoNormal_Simplified",
        "MTE/URP/TextureArray5Tex_NoNormal",
        "MTE/URP/TextureArray6Tex_NoNormal",
        "MTE/URP/TextureArray8Tex_NoNormal",
        "MTE/URP/TextureArray8Tex_8Normal",
        "MTE/URP/TextureArray8Tex_8Normal_Simplified",
        "MTE/URP/TextureArray8Tex_NoNormal_Simplified",

        "Hidden/Universal Render Pipeline/UberPost",
    };

    /// <summary>
    /// 只保留需要裁剪的变体
    /// </summary>
    [MenuItem("ShaderVariants/提取需要预热的变体,保存到Resources文件夹下")]
    static void FilterStripShaderVariants()
    {
        string allPath = ShaderVariantsStrip.ShaderVariantsCollectPath;
        string resoursePath = "Assets/Resources/ShaderVariants/all.shadervariants";

        if (!AssetDatabase.CopyAsset(allPath, resoursePath))
        {
            Debug.LogError($"复制{allPath}到{resoursePath}失败");
            return;
        }

        AssetDatabase.Refresh();
        ShaderVariantCollection allCollection = AssetDatabase.LoadAssetAtPath<ShaderVariantCollection>(resoursePath);
        if (allCollection == null)
        {
            return;
        }

        SerializedObject serializedObject = new SerializedObject(allCollection);
        SerializedProperty m_Shaders = serializedObject.FindProperty("m_Shaders");

        for (var i = m_Shaders.arraySize - 1; i >= 0; --i)
        {
            var entryProp = m_Shaders.GetArrayElementAtIndex(i);
            var firstProperty = entryProp.FindPropertyRelative("first");
            Shader shader = (Shader)firstProperty.objectReferenceValue;
            if (shader != null)
            {
                if (!NeedWarmShaders.Contains(shader.name))
                    m_Shaders.DeleteArrayElementAtIndex(i);
            }
            else
            {
                m_Shaders.DeleteArrayElementAtIndex(i);
            }
        }
        serializedObject.ApplyModifiedProperties();
        EditorUtility.SetDirty(allCollection);
        AssetDatabase.SaveAssets();
    }
    #endregion
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值