
简介这是一套以美甲沙龙装扮为主题的Unity女生向游戏完整源码采用C#脚本编写支持Unity 2018.3.3f1及以上版本适合想要研究休闲装扮类游戏的学生或开发者。项目玩法模块完整既有手部装饰场景也有美甲装饰场景和拍照场景覆盖从选择指甲形状、搭配饰品到涂抹指甲油、绘制图案的完整美甲流程。内置九种指甲形状、四十八种饰品以及十六色指甲油、闪粉、贴纸、水钻、贴花等大量美术内容同时带有支持不同画笔的绘画引擎和撤销操作方便学习交互逻辑与界面管理。资源压缩包约八十一兆内部文件丰富共一千零九十二个文件其中包含核心脚本、图片贴图、动画片段、动画控制器、预制体、动态链接库以及安卓和iOS原生集成模块等可从代码、场景、美术到平台接入逐层拆解。目前已有两百八十八人学习整体结构清晰适合作为美甲题材休闲游戏的参考模板进行二次开发。1. 魔法美甲沙龙这个 Unity 装扮项目先把游戏逻辑层拆干净“魔法美甲沙龙”这个 Unity 装扮项目最容易低估的不是建模而是状态管理。十个指甲每个指甲独立颜色、独立质地、可以叠贴纸和魔法光效还要能一键把搭配存下来——这套逻辑放在任何一个小游戏里都比渲染本身复杂。很多人上手先去找手模素材结果做了两天发现卡在“给单个指甲换色”和“贴纸拖上去放不准”这种看似不起眼的功能上。这个项目本质上是一个 2D 装扮玩法套着 3D 表现壳核心玩法是选择、叠加、保存。技术拆解下来是三块用 C# 控制材质参数实现涂指甲用 RenderTexture 做贴纸图层再用 UGUI 事件和截图把“满意的一刻”留下来。适合刚学完 Unity 基础、想完整走一遍小游戏流程的开发者也适合想看看装扮类项目里 C# 逻辑和资源如何分离的人。2. 指甲颜色与材质用 C# 参数化 Standard 着色器搭最小玩法2.1 建模入模时先拆分指甲材质槽动手写第一个 NailPaint 脚本前先确认手模怎么导。一般装扮类手模是 10 根手指加手背一个 Mesh指甲和皮肤在同一个模型上。如果整个手只给一个材质程序里无论怎么 SetColor整只手都会变色。常见做法是在导入设置里把 Submesh 拆开或者要求建模师在导出 FBX 时给每个指甲单独分配材质槽。十个指甲加手背一共 11 个槽这是一开始就要定下的美术规范。做法是在 Unity 里选中手模的导入模型去 Materials 页签把材质重命名为 Thumb_1、Index_1 这类带手指索引的命名然后把材质生成方式改成“允许实例化”。这样代码里按名字索引预制体实例上的 renderer.materials不会意外污染共享资产。用 renderer.materials 而不是 sharedMaterials正是为了让每个玩家手部实例独占一份材质状态换颜色不会影响到别人。2.1.1 为什么不建议在运行时克隆材质运行时 new Material 会多出加载和内存开销一个手模 11 个槽不算多但加上贴纸层、装饰物之后单一实现会绷不住。更稳的方式是在预制体上直接挂好 11 个材质实例只在初始化时把 renderer.materials 取出来缓存。之后所有涂色操作都走材质属性不产生新对象分配也便于撤销和重做。一个 NailPaint 组件就能管完一双手下面是按这个设计写的最小版本。using UnityEngine; public class NailPaint : MonoBehaviour { // 10个指甲材质按手指索引排列 public Material[] nailMaterials; public void SetColor(int nailIndex, Color color) { if (nailIndex 0 || nailIndex nailMaterials.Length) return; // 内置管线 Standard 是 _ColorURP Lit 要换成 _BaseColor nailMaterials[nailIndex].SetColor(_Color, color); } public void SetFinish(int nailIndex, float metallic, float smoothness) { if (nailIndex 0 || nailIndex nailMaterials.Length) return; nailMaterials[nailIndex].SetFloat(_Metallic, metallic); nailMaterials[nailIndex].SetFloat(_Smoothness, smoothness); } public void SetMagicGlow(int nailIndex, Color glowColor, float intensity) { if (nailIndex 0 || nailIndex nailMaterials.Length) return; // HDR 颜色乘 2 是为了在 Bloom 下可见 Color hdr new Color( glowColor.r * intensity, glowColor.g * intensity, glowColor.b * intensity) * 2f; nailMaterials[nailIndex].SetColor(_EmissionColor, hdr); nailMaterials[nailIndex].EnableKeyword(_EMISSION); } }这段代码把三种最常用操作收敛成三个方法。SetColor 是普通指甲油SetFinish 是调整质地SetMagicGlow 负责“魔法”部分的发光效果。参数 nailIndex 直接用手指序号 0-9外部 UI 只需要维护当前选的是哪个指甲跟手部模型的具体层次结构完全解耦。2.2 用 Metallic 和 Smoothness 区分指甲油的四种质地指甲油在 Standard 着色器里可以看成一层带颜色的清漆底色决定颜色Metallic 决定金属感Smoothness 决定反光锐度。美甲常见的款式不需要自定义 Shader调这四个参数就能出来。指甲油质地MetallicSmoothnessEmission 强度对应款式哑光磨砂00.20日常通勤款亮片0.10.50.1派对款猫眼0.30.90.4沙龙招牌款魔法荧光0.20.80.8 - 2.0HDR魔法光效款涂色之后立刻在该指甲的材质上调用 SetFinish把表格里的数值写进去。哑光款要把 Smoothness 压到 0.2 以下反光会变成漫反射颜色看起来更“粉”。亮片款靠 Metallic 0.1 的细微金属响应模拟碎片反光。而魔法荧光必须在 Emission 上做文章把强度值乘到颜色通道里还要配合 Bloom 后处理否则高亮只会让指甲变白没有发光感。2.2.1 颜色空间和 Bloom 的搭配在 Player Settings 里把 Color Space 设为 Linear 之后Emission 颜色乘 2 再乘强度才符合预期。如果项目开关是 Gamma同一套参数会显得过曝要把强度调回 0.8 以下。这个差异在真机上尤其明显Gamma 空间下 HDR 值会被裁掉发光范围看起来像一块白斑。控制发光范围的办法是给材质配一张灰阶 mask 贴图让 EmissionColor 与 mask 相乘后再进 Bloom。mask 只覆盖指甲中间区域手指皮肤即使被光照到也不会亮起来。这个素材可以由美术顺手导出不需要额外写 Shader。2.3 最小可玩原型的验收标准做到这一章结束玩家应当能选择任意指甲、从色板换颜色、切换四种质地并且魔法荧光有可见的辉光。如果你做完发现某些指甲颜色跟着一起变优先检查是不是实例化材质没生效而不是去翻代码。能跑通这步后面的贴纸和存档才有意义。3. 贴纸与魔法光效用 RenderTexture 在指甲上叠图层3.1 三种贴花方案先做选型避免做到一半推翻给指甲贴花有几种常见路径。用 UV 平面映射直接把贴纸 Sprite 画在模型上方最简单但曲面会拉伸用 Decal Projector 投影在曲面上贴合好但多个装饰叠加时管理很乱用 RenderTexture 做每个指甲的合成画布实现起来最重却天然支持图层、可擦除、可做魔法脉冲动画。方案曲面贴合多层叠加运行时开销适合规模UV 平面贴花中越靠近边缘拉伸越明显简单叠加极低单个 Logo、小图标Decal Projector高多个 Decal 叠加后层级混乱中等关卡地面贴花RenderTexture 合成中高取决于指甲 UV天然是图层显存 纹理采样美甲、换装类我在装扮类项目里默认选第三种原因是美甲的核心操作就是叠先底色再贴水钻再描金边最后加一层星光。每一步都是独立图层前一步不能被后一步覆盖掉。RenderTexture 可以把这四层合并成一张最终贴图撤销时只需要重画上一帧的状态列表。3.2 指甲贴纸为什么不用 SpriteRenderer 直接挂子物体可能有人会想贴纸不就是把一个小 Sprite 放到指甲上方吗在平面 UI 上确实是这样但放到 3D 手模上很快会出问题。一是贴纸和指甲表面在不同角度下会出现穿模需要手动调整高度二是玩家旋转视角时贴纸始终保持世界方向看起来是浮空的三是换甲油颜色后贴纸叠在上面的混合关系无法跟随底色变化。RenderTexture 方案把所有贴纸先绘制到一张独立画布上再把画布作为指甲材质的主纹理。这样贴纸在空间上和指甲融为一体旋转、换色、加发光都是一张完整材质的事情。核心代码是把一张 Sprite 按 UV 坐标绘到目标 RenderTexture。using UnityEngine; public class NailStickerLayer : MonoBehaviour { public RenderTexture nailRT; // 每个指甲一张作为该指甲的贴纸画布 private Material blitMaterial; // 透明混合材质URP 下可用 UI/Default public void DrawSticker(Texture sticker, Vector2 uvPos, float size, float opacity) { RenderTexture prev RenderTexture.active; RenderTexture.active nailRT; GL.PushMatrix(); GL.LoadPixelMatrix(0, nailRT.width, nailRT.height, 0); // 把 UV 坐标换算成像素坐标 float px uvPos.x * nailRT.width; float py (1f - uvPos.y) * nailRT.height; // RT 原点在左上角 float half size * nailRT.width * 0.5f; Graphics.DrawTexture( new Rect(px - half, py - half, half * 2, half * 2), sticker, new Rect(0, 0, 1, 1), 0, 0, 0, 0, new Color(1, 1, 1, opacity), blitMaterial ); GL.PopMatrix(); RenderTexture.active prev; } public void ClearLayer() { RenderTexture prev RenderTexture.active; RenderTexture.active nailRT; GL.Clear(true, true, Color.clear); RenderTexture.active prev; } }这段代码的关键是 GL.LoadPixelMatrix它把当前绘制坐标切到像素坐标之后的 Graphics.DrawTexture 传入的是目标矩形。uvPos 是射线检测时记录的位置换算成像素后以 center 为中心扩出一个正方形。py 做了一次上下翻转因为屏幕 UV 的 Y 轴方向和 RenderTexture 像素坐标方向相反。3.3 用射线检测确定贴纸落在哪根指甲玩家在 UI 上点击要用的贴纸然后把手指拖到某个指甲上。此时需要一次普通射线检测把屏幕位置转成 3D 世界坐标再判断命中的是哪根指甲。最直接的办法是每个指甲挂一个 Collider然后调 Physics.Raycast 拿到命中点再通过hit.collider.GetComponentNailStickerLayer()找对应的贴纸画布。一个值得留意的点不要直接用 hit.textureCoord 换算 UV因为某些手模的 UV 第一象限不等于贴图第一象限。我在项目里会先 Debug.Log 一次 textureCoord 看范围确认 0-1 映射正常再写换算。手模做过 UV 翻转的话坐标 Y 要取 1 减否则贴纸会在指甲背面。3.3.1 贴纸层保存与重绘玩家退出到主菜单再回来时贴纸还在指甲上这要求每根指甲保存自己的贴纸列表。常用数据结构是一个 List 记录贴纸 Sprite 名、UV 位置、大小、透明度、旋转角Load 时按顺序重新画一遍。撤销操作也简单去掉列表最后一项清空 RenderTexture 再全量重绘。这套逻辑的另一好处是魔法光效可以做成独立特效层。魔法星光如果要做脉冲动画单独放一个 RenderTexture 只画发光贴纸协程里每帧改变 opacity用 Mathf.PingPong(Time.time, 1f) 当透明度输入。发光贴纸的主色用 HDR 颜色例如 RGB(2.2f, 1.4f, 3.0f) 的粉紫色Bloom 处理后会自然外扩比粒子系统省性能。3.4 接缝、拉伸和边缘发白的三个处理UV 平面贴花绕不开的问题是指甲曲面。越靠近指甲边缘UV 拉伸越明显圆形贴纸会变成椭圆形。缓解方式有两类一是美甲贴纸素材尽量保持在指甲中间 60% 区域边缘让给底色二是把贴纸画布的分辨率提高纹理采样变细后视觉拉伸感会弱很多。提示RenderTexture 的宽高建议用 256 或 512 的 2 次幂方便移动端纹理压缩。低端安卓机对非 2 次幂的合成画布有采样性能惩罚而这个项目一个手模就要 10 张画布。边缘发白是另一个常见问题。当贴纸画布的 RGB 是黑色但 Alpha 是 0 时叠加到指甲材质上会显得边缘蒙了一层灰。处理方式是在合成 Shader 里把画布主纹理采样乘上画布 Alpha或者用预乘 Alpha 存储。核心原则是贴纸画布必须保持正确的透明度否则换任何底色边缘都会脏。4. 色板滑块、拖拽事件与 C# 存档截图女生向 UI 的落地细节4.1 用三个 Slider 做 HSV 色板附带一行 C# 转换美甲的颜色选择器不适合直接给 RGB 三个条。玩家脑子里想的是“淡一点”“粉一点”“亮一点”这在 HSV 模型里就是 V 和 S 的单方向调整。顶栏放三个 Sliderhue 管色相sat 管饱和度val 管明度底部放一排预设色卡能覆盖 90% 的操作需求。using UnityEngine; using UnityEngine.UI; public class ColorPickerUI : MonoBehaviour { [SerializeField] private Slider hueSlider; [SerializeField] private Slider satSlider; [SerializeField] private Slider valSlider; [SerializeField] private Image preview; public System.ActionColor onColorChanged; public void OnSliderChanged(float _) { // 三个 Slider 范围都是 0-1不用再做归一化 Color c Color.HSVToRGB(hueSlider.value, satSlider.value, valSlider.value); preview.color c; onColorChanged?.Invoke(c); } }Slider 的 onValueChanged 拖拽时每帧触发多次所以回调方法的参数被命名为_来丢弃单值实际取的是三个 Slider 的当前值。Color.HSVToRGB 返回不透明 Color要做半透明底油时把返回颜色的 alpha 单独设为 0.8 再用。整个色板 UI 没有依赖任何第三方库纯 C# 加 UGUI 就能跑通。4.1.1 Slider 的交互手感装扮类游戏的 UI 手感比功能更重要。Slider 默认的拖动反馈太硬常见做法是把 handle 的点击区域放大到视觉面积的 1.5 倍再给 Slider 加一个 SmoothDamp 插值。手指按下去后 handle 滑向目标松手后缓动继续走完剩下的距离视觉上比直接跟着指针走柔和。实现时在 Update 里对当前值做 Mathf.SmoothDamp不要每帧直接写 value。4.2 拖贴纸到指甲两道事件一条判距贴纸从底部装饰栏拖到指甲上实现时用 IDragHandler 配合 EventSystem 的射线。在装饰栏的贴纸按钮上挂一个可拖拽组件OnBeginDrag 时把贴纸 Sprite 传给一个全局装饰管理器OnDrag 时把跟随指尖的半透明贴纸移到 PointerEventData.position。松手时再把位置转成世界坐标发一次 3D 射线命中指甲就调用 DrawSticker。一个隐蔽的坑是 UGUI 的 raycast 默认会挡住 3D 射线。拖拽结束时 Input.mousePosition 在 UI 按钮正上方Physics.Raycast 会先碰到浅层 UGUI 物体导致永远命中不了指甲。做法是在松手事件里给 EventSystem 设 enabled false发完 3D 射线再恢复或者把装饰栏的整块背景设置成 raycastTarget false。4.2.1 扩大按钮点击区域的常用技巧UGUI 的点击区域由 Graphic 的 raycastTarget 矩形决定。想扩大按钮范围最简单的是在按钮下加一个同名的透明 Image把 rect 调得比可见图标大一圈再关掉 Image 的渲染颜色。更稳的方案是写一个继承 Graphic 的空组件重写 Raycast 方法自己判断点击点是否落在扩展矩形内不产生额外图片资源。想彻底搞明白原理可以直接看 UGUI 源码里 Graphic.Raycast 的实现它判断的是屏幕坐标能否转进自身局部坐标系。4.3 截图存档用 C# 把当前搭配存成 PNG 并写入相册装扮类游戏到了成品阶段会需要“生成海报”“保存分享图”。Unity 的 ScreenCapture.CaptureScreenshotAsTexture 会给出一张 Texture2D自己编码成 PNG 后写入 persistentDataPath比 CaptureScreenshot 直接写默认文件名更可控还能在写文件前加品牌 Logo 和玩家 ID。public static IEnumerator SaveScreenshotToGallery() { yield return new WaitForEndOfFrame(); Texture2D tex ScreenCapture.CaptureScreenshotAsTexture(); byte[] png tex.EncodeToPNG(); string path Path.Combine(Application.persistentDataPath, nail_ System.DateTime.Now.ToString(yyyyMMdd_HHmmss) .png); File.WriteAllBytes(path, png); DestroyImmediate(tex); #if UNITY_ANDROID !UNITY_EDITOR using (var unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) using (var activity unityPlayer.GetStaticAndroidJavaObject(currentActivity)) using (var media new AndroidJavaObject(android.media.MediaScannerConnection)) { media.CallStatic(scanFile, activity, new[] { path }, new[] { image/png }, null); } #endif }yield return WaitForEndOfFrame 是为了确保 GPU 渲染结果已经回读到 CPU。Android 侧写入后要调 MediaScannerConnection.scanFile否则相册里看不到新文件。iOS 默认是 app 沙盒内要原生保存功能需要接一段 Objective-C 代码不过当前方案在编辑器和 iOS 模拟器上已经能跑通回显流程。4.4 存档结构JSON 比二进制序列化更稳一个搭配的数据包括十个指甲的颜色、质地三档参数、贴纸列表。直接用 JsonUtility 序列化一个 NailSetSaveData 类字段用 List 存贴纸列表JSON 文件写到 Application.persistentDataPath 下的 save.json。JsonUtility 处理嵌套 List 没问题但不能序列化 Dictionary所以贴纸名到 Sprite 的映射要在加载时现查 Resources 或在 Inspector 里引用好。提示不要在每帧 Update 里做全量 JSON 序列化。正确姿势是“脏标记”任何涂色、贴纸操作只把数据对象标记为 dirty保存按钮或切后台时再真正写盘。C# 侧序列化一个 10 个指甲加 20 张贴纸的数据结构耗时大约几毫秒但每帧做依然会造成卡顿。5. 合批、GC 与 WebGL 存档美甲项目发布前的 Unity 优化清单5.1 贴纸图集与合批先在 Frame Debugger 看 SetPass Call美甲项目场景简单但 UI 元素数量很容易失控装饰栏几十个贴纸、色板滑块、预览图、魔法特效。把贴纸全部收进一张 Sprite Atlas让所有贴纸共用同一种材质才能把 SetPass Call 压下来。打开 Window Analysis Frame Debugger逐帧看 DrawCall目标是在主界面上少于 60。UGUI 的合批会被不同字体打断Text 节点尽量集中摆放。5.2 C# 层的 GC 压力滑条回调与拖拽事件是重灾区调色和拖拽都是高频事件。Slider 的 onValueChanged 在拖动时每帧触发多次这里如果用闭包捕获外部变量会持续产生堆分配。回调函数应写成普通方法绑定避免匿名函数。Update 里也不要每帧创建 List贴纸重绘用的临时列表声明成成员变量循环复用。高频代码里还要注意 C# 结构体的传参方式。比如定义一个 NailState 保存当前指甲状态如果用普通传值方式传入方法含 Color 加三个 float 的结构体整体会复制一次。热循环里应该用in关键字传只读引用并且结构体方法要显式标注 readonly否则编译器会因为防御性复制仍然产生一次复制。这类问题看 Profiler 的 GC Alloc 列非常明显。5.3 摄像机环绕与烘焙阴影装扮查看器的表现力关键装扮类视角一般是手模绕中心旋转做法是摄像机挂一个 OrbitCamera 组件用 RotateAround 处理旋转输入。public class OrbitCamera : MonoBehaviour { public Transform target; public float speed 8f; void Update() { // 编辑器用鼠标真机要改成 Input.touches 计算 delta float x Input.GetAxis(Mouse X); if (Mathf.Abs(x) 0.01f) transform.RotateAround(target.position, Vector3.up, x * speed); } }注意移动端 Input.GetAxis(Mouse X) 在纯触屏上为零发布到手机前要改成用 Input.touches 计算手指位移。阴影方面运行时动态阴影只有一盏主光时手模会显得硬项目里常用烘焙 Lightmap再配一盏实时点光只给指甲区域让指甲油高光有层次。角色材质关掉阴影接收能省一个阴影 Pass魔法光效的光晕用 Billboard 粒子贴在指甲上方比额外一盏动态光便宜得多。5.4 WebGL 发布时的 IDBFS 写入失败问题Unity WebGL 版本地存档写进 IndexedDB 时经常失败。症状是第一次进游戏设置正常刷新后存档消失或者 syncfs 后立刻读文件报错。原因是 WebGL 的虚拟文件系统需要主动把内存数据同步到浏览器的 IndexedDB而且必须在用户手势内申请持久化存储。做法是在玩家第一次点击“保存”时调用一次 navigator.storage.persist() 申请持久化然后把 File.WriteAllBytes 和 syncfs 放在按钮同一帧用户手势内执行不要延迟到定时器里。发布后用 Chrome DevTools 的 Application 面板看 IndexedDB 里的 Unity 数据库确认真实落盘。5.5 三个用于验收的验证技巧最后给三个能立刻用上的验证手段。第一在 Game 窗口把分辨率切到 iPhone SE 档位所有 UI 贴紧边距再看一遍装扮类玩家的手指遮挡区集中在屏幕下半部分操作板不宜放太低。第二用 Profiler 录制 20 秒连续涂色、换色、拖贴纸的操作看 PSS 内存不能持续增长GC Alloc 要保持在每帧 10KB 以下。第三把手的材质导出给美术复查确认 Standard 着色器的 Smoothness 没有多编译一份 Shader 变体导致包体积膨胀。本文还有配套的精品资源点击获取