Unity3D教程:换装方法
2016-02-20 19:37
441 查看
http://www.manew.com/4136.html
游戏内的角色,能够像纸娃娃换装那样子让玩家可以为自己的角色改变外观,一直是相当受欢迎的功能;一般而言,我们建好的3D模型,如果要将其中一个部位换成另外一个形状,最直接的就是将该物件部位的Mesh替换掉,那么外观就改变了,但这种方法如果运用在需要做动作的模型上,将发现被置换掉的部位不会正常动作,更糟的状况可能连模型显示的位置及方向都是错误的,所以,直接变更Mesh的方法只适用于静态模型物件,为此,我们必须找出更深入的方法来做换装的功能,幸好,此部份Unity官方已经有提供相关范例可以参考。
Unity官方提供的人物换装范例可以从官网下载CharacterCustomization,或是开启Unity编辑器的Window>AssetStore在CompleteProjects>Tutorials找到CharacterCustomization下载并汇入到自己的专案中。这个范例提供相当完整的示范,而且考虑到实作于游戏中时,不可能一次把全部的资源都载入,所以将模型、材质、纹理等资源都包成Assetbundle,只在要使用到时才载入需要的部份;也因为如此,对於不了解
Assetbundle的情况下,要透过这个范例来直接学习换装也相对变得困难;另外,范例中也对资源规范了特定形式的命名规则,主要也是为了建立Assetbundle内容资料及从Assetbundle取出资源而设计,在不了解这些规则之前,想要透过此范例学习换装,有一定程度的难度;当然,如果愿意使用与范例中characters目录中两个人物的模型、材质、纹理完全相同的命名规则及档案配置方式,几乎可以直接套用到自己的游戏中,而不太需要了解内部的运作方式。
在Unity开启Assetstore,在Assetstore中找出CharacterCustomization范例。
虽然,官方这个范例能够直接套用,但制作游戏常会有不同的客制化需求,如果不了解相关原理和流程的话,可能就无法自由灵活的运用,所以,以下将利用这个范例并排除掉Assetbundle的部份,直接在场景中完成纸娃娃换装的方法。
首先,先来看看模型的结构,从Projects视窗将CharacterCustomization>characters>Female>Female拉到场景中,在Hierarchy视窗将物件展开来,会发现几个名称相同并使用数字区别的物件,它们分别代表人物各部位的模型,由此可知,整个人物模型档包含多个相同部位的模型,而Famale_Hips则是整个人物的骨架结构,人物的动作则是设置在顶层物件(Female)的Animation,所以这个模型档是个模型资源,而不是实际上要放到在场景中的目标物件。
每个部位有多个模型物件
了解模型档内容後,接下来先建立一个名为TestChar的C#程式档用来控制换装,为了方便测试,在Projects视窗将CharacterCustomization>characters>Female>Female@walk的AnimationWrapMode改为Loop,并在程式档的Start()内加入animation.Play(“walk”),如此在执行状态将会使人物不断的做走路的动作。
选择Female@walk
AnimationWrapMode选择Loop
Unity官方这个范例,说穿了就是将模型档做为来源模型资源,然後再依照需求将各部位重新组合成一个新的目标模型,所以我们直接将人物模型Female拉两个到场景中,分别为它们命名为Source及Target,依照以下步骤做些准备动作:
从Projects视窗将CharacterCustomization>characters>Female>PerTextureMaterials依照名称把适当的材质球(Material)拉给Source的每个部位(不包含Female_Hips及其子物件)。
Source物件是做为来源资源使用,实际在场景中不需要运作,所以直接点选Source物件并将Inspector视窗中Source名称栏位前的方框取消勾选来将它关闭。
Source前面的方框取消勾选,取消勾选后会弹出对话视窗询问是否希望关闭全部的子物件,点击DeactivateChidren。把Target物件中除了Famale_Hips以外的子物件全部删除。把TestChar程式档拉给Target物件。Source中的各部位名称应该都要有编号(例如face-1),如果没有的话,加上编号。
完成以上的准备动作,接下来就要开始来写程式了,程式主要工作是先将Source中每个物件的SkinnedMeshRenderer资料取出储存在data变数中,data的内容则是依照部位分类,接下来在Target加入SkinnedMeshRenderer,然后每个部位取出一个指定的资料,利用CombineInstanceclass及Mesh.CombineMeshes()将各部位模型合并,同时也重新排列材质,然後依照取出的SkinnedMeshRenderer的bone的名称,找到与
Target的Female_Hips子物件内名称相对应的物件重建骨架列表,最后将这些重新组合建立的资料给Target的SkinnedMeshRenderer,如此就可完成换装的动作,以下为程式说明:
写完程式后,记得把场景中的Source及Target两个物件分别拉给附属在Target物件上的TestCharscript的source及target栏位;
程式动作都在Start()内进行,是因为最初目标物件并没有模型等资料,所以要先依照指定的各部位资料把人物建立出来并使它动作,而smr=_part.Value[“1”];的“1”则是表示指定此部位的“1”模型资料,所以只要改变各部位的这个值,就能为人物配置不同的造型,当然,前题是来源模型资源必须要有这个编号的物件才行;以上程式码主要是测试及解说流程用,在实作上应该把标示/**重组模型*/这一段程式独立出来,在需要换装时,给予各部位指定编号来执行。
以上是Unity官方范例中处理换装的方法,它把各部位模型、材质等资料重新组合合并成单一的模型并重建骨架列表,如此即使看起来人物身上有其中一个部位被置换了,仍能持续正常动作;当查看Target物件时会发现它的子物件仍然维持不变,只有Target物件本身在Inspector视窗中的Component多出了SkinnedMeshRenderer及各部位的Material,如果查看SkinnedMeshRenderer的Mesh栏位也会发现看不到任何的Mesh。
Target物件的内容
这种做法的来源模型与材质数量必须相对应,否则模型的贴图将会变得不正常,也就是说如果裤子的material有两个,其他部位的materail只有一个,那麽结果模型上的贴图将与预期的不同;为了使各部位的material使用上更为弹性,前面的程式将做些修改,使它的各部位都是独立的GameObject,如下所示:
在建立data变数内容时,同时为每个部位建立GameObject,另外也把变更部位内容的程式码独立出来为ChangePart方法,如此在每次需要变更该部位时,只要指定部位名及编号就可以直接为该部位换装,而不需要将每个部位都重建;因为每个部位都是GameObject实体,我们在Hierarchy或Scene视窗中点选该部位也可以清楚的从Inspector视窗中看到此部位内容,正因如此,每个部位就可以自由配置Material的数量了。
从以上程式中会发现换装除了把Mesh和Material从来源取出给目标置换之外,有个关键的地方是重建骨架列表,为什麽要重建骨架列表呢?最主要是变更Mesh之後的SkinnedMeshRenderer.bones及SkinnedMeshRenderer.sharedMesh.bindposes数量有可能会不同而产生错误讯息Numberofbindposesdoesn'tmatchnumberofbonesinskinnedmesh,即使数量相同而没有错误讯息,SkinnedMeshRenderer.sharedMesh.bindposes
内的Matrix4x4[]资料也会因为数值不正确而发生执行期模型扭曲成奇怪形状的问题;这部份可以将Female模型档汇入到3DSMax中查看,以鞋子为例,在Modify视窗中,可以很明显看出shoes-1和shoes-2的Bones列表内容是不同的,所以在为模型物件变更Mesh的同时必须重建骨架列表。
以上的说明主要是用于了解换装所需要的做法,实作时,不太可能把游戏中的角色全身各部位的模型资料全部都载入做为来源资料,例如游戏中的武器有100种,角色背包中有3种武器,但为了换装却把100种武器都载入到游戏中,而实际上此角色最多也只能变换背包中的3种武器而已,这样无疑是浪费了97种武器所占用的资源;所以在了解如何换装後,实作时应该尽量像官方范例那样把来源资源包装起来,只取出需要的资源来进行换装。
游戏内的角色,能够像纸娃娃换装那样子让玩家可以为自己的角色改变外观,一直是相当受欢迎的功能;一般而言,我们建好的3D模型,如果要将其中一个部位换成另外一个形状,最直接的就是将该物件部位的Mesh替换掉,那么外观就改变了,但这种方法如果运用在需要做动作的模型上,将发现被置换掉的部位不会正常动作,更糟的状况可能连模型显示的位置及方向都是错误的,所以,直接变更Mesh的方法只适用于静态模型物件,为此,我们必须找出更深入的方法来做换装的功能,幸好,此部份Unity官方已经有提供相关范例可以参考。
Unity官方提供的人物换装范例可以从官网下载CharacterCustomization,或是开启Unity编辑器的Window>AssetStore在CompleteProjects>Tutorials找到CharacterCustomization下载并汇入到自己的专案中。这个范例提供相当完整的示范,而且考虑到实作于游戏中时,不可能一次把全部的资源都载入,所以将模型、材质、纹理等资源都包成Assetbundle,只在要使用到时才载入需要的部份;也因为如此,对於不了解
Assetbundle的情况下,要透过这个范例来直接学习换装也相对变得困难;另外,范例中也对资源规范了特定形式的命名规则,主要也是为了建立Assetbundle内容资料及从Assetbundle取出资源而设计,在不了解这些规则之前,想要透过此范例学习换装,有一定程度的难度;当然,如果愿意使用与范例中characters目录中两个人物的模型、材质、纹理完全相同的命名规则及档案配置方式,几乎可以直接套用到自己的游戏中,而不太需要了解内部的运作方式。
在Unity开启Assetstore,在Assetstore中找出CharacterCustomization范例。
虽然,官方这个范例能够直接套用,但制作游戏常会有不同的客制化需求,如果不了解相关原理和流程的话,可能就无法自由灵活的运用,所以,以下将利用这个范例并排除掉Assetbundle的部份,直接在场景中完成纸娃娃换装的方法。
首先,先来看看模型的结构,从Projects视窗将CharacterCustomization>characters>Female>Female拉到场景中,在Hierarchy视窗将物件展开来,会发现几个名称相同并使用数字区别的物件,它们分别代表人物各部位的模型,由此可知,整个人物模型档包含多个相同部位的模型,而Famale_Hips则是整个人物的骨架结构,人物的动作则是设置在顶层物件(Female)的Animation,所以这个模型档是个模型资源,而不是实际上要放到在场景中的目标物件。
每个部位有多个模型物件
了解模型档内容後,接下来先建立一个名为TestChar的C#程式档用来控制换装,为了方便测试,在Projects视窗将CharacterCustomization>characters>Female>Female@walk的AnimationWrapMode改为Loop,并在程式档的Start()内加入animation.Play(“walk”),如此在执行状态将会使人物不断的做走路的动作。
选择Female@walk
AnimationWrapMode选择Loop
Unity官方这个范例,说穿了就是将模型档做为来源模型资源,然後再依照需求将各部位重新组合成一个新的目标模型,所以我们直接将人物模型Female拉两个到场景中,分别为它们命名为Source及Target,依照以下步骤做些准备动作:
从Projects视窗将CharacterCustomization>characters>Female>PerTextureMaterials依照名称把适当的材质球(Material)拉给Source的每个部位(不包含Female_Hips及其子物件)。
Source物件是做为来源资源使用,实际在场景中不需要运作,所以直接点选Source物件并将Inspector视窗中Source名称栏位前的方框取消勾选来将它关闭。
Source前面的方框取消勾选,取消勾选后会弹出对话视窗询问是否希望关闭全部的子物件,点击DeactivateChidren。把Target物件中除了Famale_Hips以外的子物件全部删除。把TestChar程式档拉给Target物件。Source中的各部位名称应该都要有编号(例如face-1),如果没有的话,加上编号。
完成以上的准备动作,接下来就要开始来写程式了,程式主要工作是先将Source中每个物件的SkinnedMeshRenderer资料取出储存在data变数中,data的内容则是依照部位分类,接下来在Target加入SkinnedMeshRenderer,然后每个部位取出一个指定的资料,利用CombineInstanceclass及Mesh.CombineMeshes()将各部位模型合并,同时也重新排列材质,然後依照取出的SkinnedMeshRenderer的bone的名称,找到与
Target的Female_Hips子物件内名称相对应的物件重建骨架列表,最后将这些重新组合建立的资料给Target的SkinnedMeshRenderer,如此就可完成换装的动作,以下为程式说明:
001 | //来源模型资源的物件 |
002 | |
003 | publicTransformsource; |
004 | |
005 | //目标物件 |
006 | |
007 | publicTransformtarget; |
008 | |
009 | //模型资源资料 |
010 | |
011 | privateDictionary<string,Dictionary<string,SkinnedMeshRenderer>>data=newDictionary<string,Dictionary<string,SkinnedMeshRenderer>>(); |
012 | |
013 | voidStart(){ |
014 | |
015 | //从来源模型资源取出各部位的SkinnedMeshRenderer |
016 | |
017 | SkinnedMeshRenderer[]parts=source.GetComponentsInChildren<SkinnedMeshRenderer>(true); |
018 | |
019 | foreach(SkinnedMeshRendererpartinparts){ |
020 | |
021 | //利用?字元分隔档名做为资料结构的key,档名为部位?编号储存为[部位][编号]=SkinnedMeshRenderer资料 |
022 | |
023 | string[]partName=part.name.Split('?'); |
024 | |
025 | //在data加入资料 |
026 | |
027 | if(!data.ContainsKey(partName[0]))data.Add(partName[0],newDictionary<string,SkinnedMeshRenderer>()); |
028 | |
029 | data[partName[0]].Add(partName[1],part); |
030 | |
031 | } |
032 | |
033 | //目标物件加入SkinnedMeshRenderer |
034 | |
035 | SkinnedMeshRenderertargetSmr=target.gameObject.AddComponent<SkinnedMeshRenderer>(); |
036 | |
037 | //从目标物件取得骨架资料(Female_Hips的全部物件) |
038 | |
039 | Transform[]hips=target.GetComponentsInChildren<Transform>(); |
040 | |
041 | /**开始重组模型*/ |
042 | |
043 | //初始化资料列表 |
044 | |
045 | List<CombineInstance>combineInstances=newList<CombineInstance>(); |
046 | |
047 | List<Material>materials=newList<Material>(); |
048 | |
049 | List<Transform>bones=newList<Transform>(); |
050 | |
051 | foreach(KeyValuePair<string,Dictionary<string,SkinnedMeshRenderer>>_partindata){ |
052 | |
053 | //从资料中取得各部位指定编号的SkinnedMeshRenderer |
054 | |
055 | SkinnedMeshRenderersmr=newSkinnedMeshRenderer(); |
056 | |
057 | switch(_part.Key){ |
058 | |
059 | case“eyes”: |
060 | |
061 | smr=_part.Value[“1”]; |
062 | |
063 | break; |
064 | |
065 | case“face”: |
066 | |
067 | smr=_part.Value[“1”]; |
068 | |
069 | break; |
070 | |
071 | case“hair”: |
072 | |
073 | smr=_part.Value[“1”]; |
074 | |
075 | break; |
076 | |
077 | case“pants”: |
078 | |
079 | smr=_part.Value[“1”]; |
080 | |
081 | break; |
082 | |
083 | case“shoes”: |
084 | |
085 | smr=_part.Value[“1”]; |
086 | |
087 | break; |
088 | |
089 | case“top”: |
090 | |
091 | smr=_part.Value[“1”]; |
092 | |
093 | break; |
094 | |
095 | } |
096 | |
097 | //准备要组合的Mesh |
098 | |
099 | CombineInstanceci=newCombineInstance(); |
100 | |
101 | ci.mesh=smr.sharedMesh; |
102 | |
103 | combineInstances.Add(ci); |
104 | |
105 | //排列新的材质列表 |
106 | |
107 | materials.AddRange(smr.materials); |
108 | |
109 | //取得相对应名称的骨架物件来建立新的骨架列表 |
110 | |
111 | foreach(Transformboneinsmr.bones){ |
112 | |
113 | foreach(Transformhipinhips){ |
114 | |
115 | if(hip.name!=bone.name)continue; |
116 | |
117 | bones.Add(hip); |
118 | |
119 | break; |
120 | |
121 | } |
122 | |
123 | } |
124 | |
125 | } |
126 | |
127 | //合并Mesh并写入至Target的SkinnedMeshRenderer |
128 | |
129 | targetSmr.sharedMesh=newMesh(); |
130 | |
131 | targetSmr.sharedMesh.CombineMeshes(combineInstances.ToArray(),false,false); |
132 | |
133 | //Target的SkinnedMeshRenderer写入新骨架列表 |
134 | |
135 | targetSmr.bones=bones.ToArray(); |
136 | |
137 | //Target的SkinnedMeshRenderer写入新材质列表 |
138 | |
139 | targetSmr.materials=materials.ToArray(); |
140 | |
141 | /**重组模型结束*/ |
142 | |
143 | //指定播放走路动作 |
144 | |
145 | animation.Play(“walk”); |
146 | |
147 | } |
148 |
程式动作都在Start()内进行,是因为最初目标物件并没有模型等资料,所以要先依照指定的各部位资料把人物建立出来并使它动作,而smr=_part.Value[“1”];的“1”则是表示指定此部位的“1”模型资料,所以只要改变各部位的这个值,就能为人物配置不同的造型,当然,前题是来源模型资源必须要有这个编号的物件才行;以上程式码主要是测试及解说流程用,在实作上应该把标示/**重组模型*/这一段程式独立出来,在需要换装时,给予各部位指定编号来执行。
以上是Unity官方范例中处理换装的方法,它把各部位模型、材质等资料重新组合合并成单一的模型并重建骨架列表,如此即使看起来人物身上有其中一个部位被置换了,仍能持续正常动作;当查看Target物件时会发现它的子物件仍然维持不变,只有Target物件本身在Inspector视窗中的Component多出了SkinnedMeshRenderer及各部位的Material,如果查看SkinnedMeshRenderer的Mesh栏位也会发现看不到任何的Mesh。
Target物件的内容
这种做法的来源模型与材质数量必须相对应,否则模型的贴图将会变得不正常,也就是说如果裤子的material有两个,其他部位的materail只有一个,那麽结果模型上的贴图将与预期的不同;为了使各部位的material使用上更为弹性,前面的程式将做些修改,使它的各部位都是独立的GameObject,如下所示:
001 | //来源模型资源的物件 |
002 | |
003 | publicTransformsource; |
004 | |
005 | //目标物件 |
006 | |
007 | publicTransformtarget; |
008 | |
009 | //模型资源资料 |
010 | |
011 | privateDictionary<string,Dictionary<string,Transform>>data=newDictionary<string,Dictionary<string,Transform>>(); |
012 | |
013 | //目标物件的骨架 |
014 | |
015 | privateTransform[]hips; |
016 | |
017 | //目标物件各部位的SkinnedMeshRenderer资料(参照) |
018 | |
019 | privateDictionary<string,SkinnedMeshRenderer>targetSmr=newDictionary<string,SkinnedMeshRenderer>(); |
020 | |
021 | voidStart(){ |
022 | |
023 | //从来源模型资源取出各部位的SkinnedMeshRenderer |
024 | |
025 | SkinnedMeshRenderer[]parts=source.GetComponentsInChildren<SkinnedMeshRenderer>(true); |
026 | |
027 | foreach(SkinnedMeshRendererpartinparts){ |
028 | |
029 | //利用?字元分隔档名做为资料结构的key,档名为部位?编号储存为[部位][编号]=Transform资料 |
030 | |
031 | string[]partName=part.name.Split('?'); |
032 | |
033 | //在data加入资料 |
034 | |
035 | if(!data.ContainsKey(partName[0])){ |
036 | |
037 | data.Add(partName[0],newDictionary<string,Transform>()); |
038 | |
039 | //建立新的GameObject并使用部位名称来命名,指定为目标物件的子物件 |
040 | |
041 | GameObjectpartObj=newGameObject(); |
042 | |
043 | partObj.name=partName[0]; |
044 | |
045 | partObj.transform.parent=target; |
046 | |
047 | //为新建立的GameObject加入SkinnedMeshRenderer,并将此SkinnedMeshRenderer存入targetSmr |
048 | |
049 | targetSmr.Add(partName[0],partObj.AddComponent<SkinnedMeshRenderer>()); |
050 | |
051 | } |
052 | |
053 | data[partName[0]].Add(partName[1],part.transform); |
054 | |
055 | } |
056 | |
057 | //从目标物件取得骨架资料(Female_Hips的全部物件) |
058 | |
059 | hips=target.GetComponentsInChildren<Transform>(); |
060 | |
061 | /**开始重组模型*/ |
062 | |
063 | foreach(KeyValuePair<string,Dictionary<string,Transform>>_partindata){ |
064 | |
065 | switch(_part.Key){ |
066 | |
067 | case“eyes”: |
068 | |
069 | ChangePart(“eyes”,“1”); |
070 | |
071 | break; |
072 | |
073 | case“face”: |
074 | |
075 | ChangePart(“face”,“1”); |
076 | |
077 | break; |
078 | |
079 | case“hair”: |
080 | |
081 | ChangePart(“hair”,“1”); |
082 | |
083 | break; |
084 | |
085 | case“pants”: |
086 | |
087 | ChangePart(“pants”,“1”); |
088 | |
089 | break; |
090 | |
091 | case“shoes”: |
092 | |
093 | ChangePart(“shoes”,“1”); |
094 | |
095 | break; |
096 | |
097 | case“top”: |
098 | |
099 | ChangePart(“top”,“1”); |
100 | |
101 | break; |
102 | |
103 | } |
104 | |
105 | } |
106 | |
107 | /**重组模型结束*/ |
108 | |
109 | //指定播放走路动作 |
110 | |
111 | target.animation.Play(“walk”); |
112 | |
113 | } |
114 | |
115 | privatevoidChangePart(stringpart,stringitem){ |
116 | |
117 | //从资料中取得各部位指定编号的SkinnedMeshRenderer |
118 | |
119 | SkinnedMeshRenderersmr=data[part][item].GetComponent<SkinnedMeshRenderer>(); |
120 | |
121 | //取得相对应名称的骨架物件来建立新的骨架列表 |
122 | |
123 | List<Transform>bones=newList<Transform>(); |
124 | |
125 | foreach(Transformboneinsmr.bones){ |
126 | |
127 | foreach(Transformhipinhips){ |
128 | |
129 | if(hip.name!=bone.name)continue; |
130 | |
131 | bones.Add(hip); |
132 | |
133 | break; |
134 | |
135 | } |
136 | |
137 | } |
138 | |
139 | //更新指定部位GameObject的SkinnedMeshRenderer内容 |
140 | |
141 | targetSmr[part].sharedMesh=smr.sharedMesh; |
142 | |
143 | targetSmr[part].bones=bones.ToArray(); |
144 | |
145 | targetSmr[part].materials=smr.materials; |
146 | |
147 | } |
148 |
从以上程式中会发现换装除了把Mesh和Material从来源取出给目标置换之外,有个关键的地方是重建骨架列表,为什麽要重建骨架列表呢?最主要是变更Mesh之後的SkinnedMeshRenderer.bones及SkinnedMeshRenderer.sharedMesh.bindposes数量有可能会不同而产生错误讯息Numberofbindposesdoesn'tmatchnumberofbonesinskinnedmesh,即使数量相同而没有错误讯息,SkinnedMeshRenderer.sharedMesh.bindposes
内的Matrix4x4[]资料也会因为数值不正确而发生执行期模型扭曲成奇怪形状的问题;这部份可以将Female模型档汇入到3DSMax中查看,以鞋子为例,在Modify视窗中,可以很明显看出shoes-1和shoes-2的Bones列表内容是不同的,所以在为模型物件变更Mesh的同时必须重建骨架列表。
以上的说明主要是用于了解换装所需要的做法,实作时,不太可能把游戏中的角色全身各部位的模型资料全部都载入做为来源资料,例如游戏中的武器有100种,角色背包中有3种武器,但为了换装却把100种武器都载入到游戏中,而实际上此角色最多也只能变换背包中的3种武器而已,这样无疑是浪费了97种武器所占用的资源;所以在了解如何换装後,实作时应该尽量像官方范例那样把来源资源包装起来,只取出需要的资源来进行换装。
相关文章推荐
- 收集一些Unity插件
- Unity中的DrawCall与NGUI原理
- {Unity} C#和Android Java交互
- unity 解析tmx 2
- 【Unity3d】3d角色换装实现原理及步骤
- unity 解析tmx
- unity 解析xml
- SubShader 中的标签-LOD
- Unity3D研究院编辑器之打开unity不可识别的文件
- Unity3D研究院之监听平台切换完毕的事件
- Unity3D中的坐标系
- Forward渲染路径学习笔记
- Unity5 如何做资源管理和增量更新,unity5增量
- [转]Unity3d之MonoBehaviour的可重写函数整理
- Unity插件研究院之自动保存场景
- Unity3D热更新 -脚本(三) C#LightEvil语法与调试
- Unity3D热更新 -脚本(二) 两级分化
- Unity3D热更新 初识脚本
- Unity3D逻辑热更新,第二代舒爽解决方案,L#使用简介
- Unity3D热更新 下载