资讯动态

Unity装扮类游戏实战:美甲沙龙源码的模块化架构与渲染技法

发布时间:2026/9/15 2:47:17 来源:尧图企业网站定制
简介这是一份基于Unity 2018.3.3f1及以上版本开发的美甲装扮类游戏完整源码面向Unity开发者与休闲游戏策划人员用于快速上手女生向装扮游戏的场景搭建、交互逻辑与美术资源整合。项目覆盖手部装饰与美甲流程内置9种指甲形状、48种饰品以及16色指甲油、16款闪亮图案、16张指甲贴、16种图案设计、16颗水钻和16贴花并支持绘画引擎、撤销操作与拍照场景适合作为美甲题材项目的功能范本或二次开发基础。包体共1092个文件主要以C#脚本、Prefab预制体、Animation动画、Animator控制器、PNG贴图及Unity资源文件构成同时包含Android相关SDKAAR/JAR与广告接入文件压缩包约81.35MB目录结构可清晰对应场景、脚本和美术素材。目前已有288人学习/下载适合希望掌握Unity完整小游戏制作流程的初中级开发者参考可从现成代码中学习UI交互、装饰品动态生成、纹理绘制及拍照功能的实现思路减少从零搭建的工作量。1. 从美甲沙龙的源码结构看 Unity 装扮类游戏的模块拆分Magic Nail Spa Salon 这套美甲游戏源码第一眼像普通换皮项目但把场景列表和插件目录翻一遍就是一个完整的休闲装扮游戏闭环手部装饰、美甲彩绘、拍照分享三个场景9 种指甲形状、48 件饰品、16 色甲油、16 款闪亮图案、16 张贴纸、16 种图案设计、16 颗水钻、16 张贴花正好覆盖 UGUI 数据驱动 UI 的典型用法。项目声明支持 Unity 2018.3.3f1 及以上内置渲染管线和 C# API 到今天依旧稳定适合拆解。实际价值在三个可复用模块手部模型上的装饰品挂点系统指甲画布上的多画笔绘制引擎带撤销栈的纹理快照机制。Unity 新手可以跟着代码补上UI 点击 - 数据查询 - 模型变更的完整链路做休闲游戏原型的工程师把美甲素材换成贴纸题材就能快速验证玩法。下文按场景分层、装饰挂载、绘画撤销和打包发布四条线拆解所有操作以 Unity 2018.3.3f1 为基准。2. 场景与资源分层手部装饰、美甲台和拍照场景的 Unity 组织方式2.1 场景职责与切换管理打开工程先看 Build Settings。这个项目把玩法拆成三个场景手部装饰场景负责指甲形状和饰品佩戴美甲装饰场景承载颜色、图案、贴纸和绘画拍照场景最后把成品模型放在背景里输出图片。三个场景共享一个数据层通常是挂着DontDestroyOnLoad的GameData单例保存当前手型、形状索引、配色方案和已挂载饰品列表否则从手部场景切到美甲场景再拍完照模型状态就全部丢了。场景核心职责主要数据手部装饰场景9 种指甲形状、48 件饰品挂载形状索引、饰品 ID 列表美甲装饰场景颜色、图案、贴纸、水钻、绘画当前画布、贴纸 ID、撤销栈拍照场景相机输出、保存图片屏幕快照、存储路径场景切换直接用SceneManager.LoadScene当然可以但我在改造这种多场景工程时会把场景名收敛到一个静态类方便重命名场景时只改一处public static class SceneRouter { public const string HandScene HandScene; public const string NailScene NailScene; public const string PhotoScene PhotoScene; public static void LoadScene(string sceneName, bool additive false) { if (additive) SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive); else SceneManager.LoadScene(sceneName); } }additive参数在从手部场景切到美甲场景时有用手部模型和已挂饰品不销毁美甲场景加载后直接读取GameData找到模型继续绘制。需要注意Android 上连续切换场景间隔不要少于 0.5 秒否则底层 Activity 切换容易黑屏。不用 additive 的话就得把模型状态序列化到 GameData目标场景Awake时重建模型和饰品但重建时锚点重新绑定容易出现瞬间抖动。2.2 用 ScriptableObject 管理装饰资源项目里 16 色甲油、16 款闪亮图案、16 张贴纸、16 种图案设计、16 颗水钻和 16 张贴花如果全部用 Prefab 排列在场景里Inspector 会非常难维护。更合理的组织方式是每个系列一个ScriptableObject把颜色和 Sprite 数组集中在一份资产里运行时遍历数组生成 UI。下面这个数据结构是我在实际项目中常用的形态[CreateAssetMenu(fileName NailPolishSet, menuName MagicNail/PolishSet)] public class NailPolishSet : ScriptableObject { public string setId; public NailShapeType shapeType; public Color[] normalColors; // 16 色普通指甲油 public Sprite[] glitterOverlays; // 16 款闪亮图案 public Sprite[] stickers; // 16 张指甲贴 public Sprite[] stencils; // 16 种图案设计 public Sprite[] rhinestones; // 16 颗水钻 public Sprite[] decals; // 16 张贴花 }数组长度正好对应用户界面上的格数第 i 个按钮绑定数组下标 i点击时把下标传给绘制层。如果素材数量不足 16 个UI 循环越界后很难定位所以我一般会在Start里加一行Debug.Assert(set.normalColors.Length 16)启动时发现问题比用户玩到一半崩溃更容易排查。2.3 加载路径与启动优化Unity 2018.3 还没有 Addressables这套源码大概率走的是Resources.LoadAll或 AssetBundle。常见做法是把 48 个饰品 Prefab 放在 Resources 目录按饰品类型分子目录打开手部场景时只加载当前 9 种形状需要的组。把 48 个 Prefab 全量塞进场景会让冷启动多几百毫秒我一般改成按setId分组切换指甲形状时再异步加载下一组同时用Resources.UnloadUnusedAssets清理旧资源。提示拍照前要先把 UGUI 的 Canvas 隐藏或者把 Canvas 的 Render Camera 切到拍照专用相机否则输出图会带上 UI 残影。3. 装饰品挂载与美甲绘制从 UGUI 点击到模型变色的完整链路3.1 UGUI 点击事件与装饰面板生成装饰玩法本质上是点 UI - 查数据 - 改模型。UGUI 的按钮响应依赖场景里的 EventSystem以及 Canvas 上的 GraphicRaycaster缺一不可。项目里装饰面板通常是动态生成的因为 48 个饰品如果手工拖到 Canvas 上改一个样式要动十几个引用。动态生成时最常见的问题是循环变量闭包捕获for (int i 0; i decoItems.Count; i) { int index i; Button btn slot.GetComponentButton(); btn.onClick.AddListener(() SpawnDeco(decoItems[index].id, index)); }index是循环体内新建的局部副本如果不加这一行而直接引用i所有按钮回调拿到的都是最后一个i。这个 C# 问题在装扮类项目里几乎必踩因为格子数量大、生成逻辑集中。装饰类型数量挂载锚点交互结果戒指多个指关节点替换当前手指上的饰品手链多个手腕点替换手腕饰品纹身多个手背或手臂面片覆盖在皮肤表面3.2 装饰品挂载与锚点设计装饰品的挂载核心是把 Prefab 放到锚点子级并清零本地坐标public class DecoSpawner : MonoBehaviour { [SerializeField] private Transform[] fingerAnchors; [SerializeField] private DecoItemDatabase decoDb; public void SpawnDeco(int decoId, int anchorIndex) { DecoItem item decoDb.GetById(decoId); if (item null || anchorIndex fingerAnchors.Length) return; Transform anchor fingerAnchors[anchorIndex]; for (int i anchor.childCount - 1; i 0; i--) { Destroy(anchor.GetChild(i).gameObject); } GameObject go Instantiate(item.prefab, anchor); go.transform.localPosition Vector3.zero; go.transform.localRotation Quaternion.identity; go.transform.localScale Vector3.one; } }锚点最好在建模阶段放置在骨骼节点下并做好命名约定例如finger_02_ring。运行时最忌讳用transform.Find(finger_02_ring)去搜索因为 SkinnedMeshRenderer 的骨骼树一旦被优化或重命名查找就失败。localPosition必须清零否则 Prefab 自己的偏移会叠加锚点偏移导致饰品悬空。清理已有子物体用倒序遍历避免正序删除时索引错位。水钻和贴花这类带透明通道的装饰品挂载后还要检查材质是否设置 Transparent 渲染队列。常见做法是 Prefab 里用SpriteRenderer或者MeshRenderer 透明 Shader两者在拍照场景的表现不一样SpriteRenderer 会随相机旋转但不会投阴影MeshRenderer 可以接收光照和阴影。项目里的纹身贴在皮肤上应该用带_Cutoff的 Cutout 类型否则边缘会有一圈黑边。改成 Cutout 后注意把_Cutoff调到 0.5 附近太低的话透明边缘发灰太高的话贴纸自身亮度被吃掉。3.3 形状切换与颜色上色9 种指甲形状本质上是同一个手部模型上的 9 个 Mesh 变体运行时替换SkinnedMeshRenderer.sharedMesh。上色时不要直接改renderer.material否则每个形状都会生成新的材质实例切换几次后内存里堆积大量材质。标准做法是MaterialPropertyBlockprivate void ApplyNailColor(Renderer nailRenderer, Color color) { MaterialPropertyBlock block new MaterialPropertyBlock(); nailRenderer.GetPropertyBlock(block); block.SetColor(_Color, color); nailRenderer.SetPropertyBlock(block); }MaterialPropertyBlock做的是渲染属性覆盖不会新建材质DrawCall 也不增加。属性名要按渲染管线切Unity 2018.3 内置管线用_ColorUnity 2021 以后如果切 URP 的 Lit Shader就要改成_BaseColor否则颜色不生效。贴纸和纹身则不是直接贴在指甲表面上那样会跟指甲 Mesh 深度冲突产生闪烁常见做法是做一个很薄的 Decal 面片沿法线方向偏移 0.01 到 0.02 个单位关闭深度写入只渲染透明层。切换指甲形状前先清空上一个形状的 MaterialPropertyBlock否则共享材质的残留颜色会串到新形状上。4. 绘画引擎与撤销机制RenderTexture 画笔和命令栈实现4.1 在 RenderTexture 上实现多画笔绘制美甲场景的核心玩法是不同画笔绘制。实现上并不需要真的创建多个笔刷模型而是把一张 RenderTexture 当作画布手指滑动时用笔刷纹理在画布上反复绘制。RenderTexture 走 GPU 带宽比Texture2D.SetPixel再Apply快一个量级而且天然支持叠加和贴花。一个可运行的最小核心如下public class NailPainter : MonoBehaviour { public RenderTexture canvas; public Texture2D brushTexture; public Material brushMaterial; public Color currentColor Color.red; public float brushSize 0.08f; public void Paint(Vector2 uv) { brushMaterial.SetColor(_Color, currentColor); RenderTexture.active canvas; GL.PushMatrix(); GL.LoadPixelMatrix(0, canvas.width, canvas.height, 0); float size brushSize * canvas.width; Rect rect new Rect(uv.x * canvas.width - size * 0.5f, uv.y * canvas.height - size * 0.5f, size, size); Graphics.DrawTexture(rect, brushTexture, brushMaterial); GL.PopMatrix(); RenderTexture.active null; } }代码把传入的 UV 坐标换算成像素坐标以笔刷大小为中心画一个矩形。brushMaterial的 Shader 必须带_MainTex并开启 Alpha Blend否则笔刷贴图会被当成不透明方块。16 款闪亮图案本质上就是 16 张带高光纹理的brushTexture切换画笔时只需换纹理不需要改材质。要注意RenderTexture.active null;这行不能省否则后续相机渲染会把主画面输出到这张画布上出现整屏白屏或 UI 消失。4.2 撤销栈的两种设计与内存取舍美甲绘制最影响体验的是撤消。实现上分两类方案快照栈和命令栈。快照栈在每次落笔前把当前画布完整拷一份撤销是 O(1) 的一次Graphics.Blit命令栈只记录每次绘制的参数撤销时清空画布重放内存占用低但耗时随笔数增长。两种方案取舍如下方案内存占用撤销耗时适合场景快照栈高栈深 N 约等于 N 张画布O(1)笔数少、追求流畅命令栈低只存命令参数O(笔数 × 重绘)图层多、长会话如果项目源码里用的是快照栈一定要控制栈深并在撤销后释放旧快照private ListRenderTexture snapshotList new ListRenderTexture(); private const int MaxSnapshots 12; public void PushUndo() { if (snapshotList.Count MaxSnapshots) { RenderTexture.ReleaseTemporary(snapshotList[0]); snapshotList.RemoveAt(0); } var snap RenderTexture.GetTemporary(canvas.width, canvas.height, 0, RenderTextureFormat.ARGB32); Graphics.Blit(canvas, snap); snapshotList.Add(snap); } public void Undo() { if (snapshotList.Count 0) return; var snap snapshotList[snapshotList.Count - 1]; Graphics.Blit(snap, canvas); RenderTexture.ReleaseTemporary(snap); snapshotList.RemoveAt(snapshotList.Count - 1); }1024×1024 的 ARGB32 画布一张约 4MB栈上限 12 层是 48MB低端 Android 上会比较吃紧。可以把MaxSnapshots降到 6或者把画布尺寸降到 768×768。还有一种做法是混合每 3 笔压一次快照中间笔的撤销用命令重放这样内存和手感能平衡。4.3 闪亮图案与模板遮罩的细节16 款闪亮指甲油图案的绘制要切换到 Additive 混合叠加多次才会越来越亮呈现闪粉质感。如果继续用 Alpha Blend深色底上一片闪粉会发灰。切换方式是在 brushMaterial 上设置混合系数比如brushMaterial.SetFloat(_SrcBlend, (float)BlendMode.One)。16 种图案设计里如果有轮廓模板需求更稳的方案是用 RenderTexture 的 alpha 通道先写入一张图案遮罩画笔 Shader 开启 Stencil 比较Stencil Ref 1Comp EqualPass Keep这样笔刷只出现在模板区域内指甲边缘也不会画出锯齿。5. Android 打包与发布技巧从 aar 依赖到照片导出踩坑源码包里那一串.aar是 Android 构建的依赖拿到后先分清哪些要保留、哪些要删。我拆这类工程时习惯先看Assets/Plugins/Android把广告 SDK 的版本理清楚aar作用常见冲突点play-services-ads-11.6.0.aar、ads-liteAdMob 广告与 support 库重复UnityAds.aarUnity 广告初始化时序support-compat、support-core-ui、support-fragmentAndroid 兼容库版本不一致如果项目没有广告变现需求直接把 ads 相关 aar 全部删除并清理代码里的MobileAds.Initialize调用包体大约能减少 3 到 4MB。如果要保留Android Gradle 构建时留意support-compat25.2.0 和 play-services-basement 11.6.0 之间的依赖传递尽量用exclude把重复的 support 库排除只留一份。5.1 相册导出与读纹理拍照场景的保存代码核心是把 RenderTexture 读回 Texture2D 再 EncodeToPNG。这里最常见的坑是RenderTexture.active没有还原以及ReadPixels区域固定从 0,0 读取。我一般用这样一个方法public static string SaveRenderTexture(RenderTexture rt, string fileName) { RenderTexture oldActive RenderTexture.active; RenderTexture.active rt; Texture2D tex new Texture2D(rt.width, rt.height, TextureFormat.RGB24, false); tex.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); tex.Apply(); RenderTexture.active oldActive; byte[] bytes tex.EncodeToPNG(); Destroy(tex); string path Path.Combine(Application.persistentDataPath, fileName); File.WriteAllBytes(path, bytes); return path; }oldActive先保存原 RenderTexture.active读图结束后还原这样不会破坏主相机后续渲染。Android 上写入后图库不会自动刷新还需要用 MediaScanner 广播一次#if UNITY_ANDROID using (var player new AndroidJavaClass(com.unity3d.player.UnityPlayer)) using (var scanner new AndroidJavaClass(android.media.MediaScannerConnection)) { var activity player.GetStaticAndroidJavaObject(currentActivity); scanner.CallStatic(scanFile, activity, new string[] { path }, new string[] { image/png }, null); } #endifscanFile的第二个参数是路径数组第三个是 mime 类型数组传 null 让系统自动推断也可以。iOS 上不需要扫描但建议对文件调一次UnityEngine.iOS.Device.SetNoBackupFlag避免作品被 iCloud 当成缓存清理。这个代码在 WebGL 上不可用如果要发布 WebGL 版本注意Application.persistentDataPath的写入依赖浏览器 IndexedDB没申请持久化配额时保存照片会报写入失败。5.2 换皮时的批量替换脚本最后给一个直接能用的技巧改成自己的题材时场景引用太多容易漏。写一个编辑器菜单选中NailPolishSet资产后从指定目录批量导入同名 Sprite[MenuItem(MagicNail/Tools/Rebuild PolishSet)] public static void RebuildPolishSet() { NailPolishSet set Selection.activeObject as NailPolishSet; if (set null) return; set.normalColors LoadColors(Assets/Art/Colors); set.glitterOverlays LoadSprites(Assets/Art/Glitters); set.stickers LoadSprites(Assets/Art/Stickers); set.stencils LoadSprites(Assets/Art/Stencils); EditorUtility.SetDirty(set); AssetDatabase.SaveAssets(); }LoadSprites用AssetDatabase.LoadAllAssetsAtPath遍历目录下的 Sprite 子资源再按文件名排序填进数组。每次换皮只替换文件夹里的美术资源不用手动拖 6 个面板。要注意所有 Sprite 的PixelsPerUnit保持一致否则同一 UI 格子里素材显示大小不统一脚本不会帮你拉伸修正。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价