
简介这是一套面向Unity开发者与游戏爱好者的高压清洗3D模拟器完整项目源码基于C#编写适合想学习休闲清洁类游戏实现、快速搭建可玩Demo或进行二次开发的人群。资源包共约2000个文件压缩后274.33MB其中asset与meta文件构成场景、模型与资源引用体系png提供贴图素材cs脚本承载核心逻辑另含shader、prefab、json配置及pdf、docx文档结构完整便于按模块查阅。项目支持Unity 2020.3.30f1及以上版本内置多层级关卡、64位支持、激励广告与应用内购买代码开发者友好皮肤替换与新增关卡都较为便捷。玩法上通过操纵杆移动喷嘴、按钮控制水流清洗汽车、房屋与废品场并以百分比参数衡量完成度配合逼真水声与高质量3D图形带来沉浸体验。目前已有50人学习适合作为清洁模拟品类的参考工程。1. 高压清洗模拟器的技术底座为什么 Unity C# 是这类 3D 清洁游戏的最优解很多人第一次看到 Power Wash Game 3D Simulator 这类项目第一反应是「不就是拿水枪冲脏东西吗能有多难」。真动手做过才知道这类游戏的技术密度远超表面观感它同时踩中了实时网格脏污遮罩、粒子水流物理、UV 空间污渍映射、性能预算控制四个硬骨头。而 Unity 加 C# 这套组合恰好是当前做 3D 清洁模拟器最成熟、最容易复现的路线。Unity 的 Shader Graph 和 Compute Shader 能直接在 GPU 上跑脏污擦除逻辑C# 侧负责输入、进度统计、关卡状态机两边分工清晰。这个方向适合两类人一类是想入门 Unity 商业级小游戏开发的新手另一类是想把「清洁/维护/翻新」这类解压玩法做成产品的独立开发者。下面我从脏污系统怎么搭、水流怎么做、性能怎么保三个层面把可复现的路径拆开讲。2. 脏污遮罩系统从一张 RenderTexture 到可擦除的 3D 表面2.1 为什么不用顶点色而用 RenderTexture 做脏污脏污系统的核心需求是「局部可擦除」。如果用顶点色存脏污模型顶点密度不够时擦除边缘会呈锯齿状而且顶点色是静态数据运行时修改需要重建 Mesh开销大。常见做法是用一张 RenderTexture 作为脏污遮罩通过 UV 坐标把遮罩投射到物体表面Shader 里用遮罩的 R 通道控制脏污显示强度。擦除时只需要往这张 RenderTexture 上「画」透明像素GPU 端一次 Draw Call 就能完成不需要碰 Mesh 数据。具体做法是给每个可清洁物体挂一个独立相机或者用 CommandBuffer相机朝向物体、正交投影把「擦除笔刷」渲染到脏污 RenderTexture 上。笔刷本质是一个带软边缘的圆形贴图位置由水枪射线与物体的碰撞点决定。这样擦除精度只取决于 RenderTexture 分辨率跟模型面数无关。// DirtMaskController.cs // 挂在可清洁物体上负责管理脏污遮罩的初始化与擦除 using UnityEngine; public class DirtMaskController : MonoBehaviour { [Header(脏污遮罩设置)] public int maskResolution 1024; // 遮罩分辨率512/1024/2048 三档常见 public Texture2D initialDirtTexture; // 初始脏污分布图可用噪声生成 public RenderTexture dirtMask; // 运行时脏污遮罩 [Header(擦除笔刷)] public Texture2D brushTexture; // 软边缘圆形笔刷 public float brushSize 0.15f; // 笔刷 UV 空间大小 public float eraseStrength 1.0f; // 单次擦除强度 private Material eraseMaterial; // 用于往遮罩上绘制的材质 private RenderTexture tempRT; // 双缓冲避免读写同一张 RT void Start() { // 创建脏污遮罩格式用 ARGB32 保证兼容性 dirtMask new RenderTexture(maskResolution, maskResolution, 0, RenderTextureFormat.ARGB32); dirtMask.Create(); // 用初始脏污图填充遮罩 Graphics.Blit(initialDirtTexture, dirtMask); // 擦除材质一个简单的 Blit 材质用笔刷 alpha 做减法 eraseMaterial new Material(Shader.Find(Hidden/DirtErase)); tempRT new RenderTexture(maskResolution, maskResolution, 0, RenderTextureFormat.ARGB32); } // 由水枪射线调用uvPoint 是碰撞点的 UV 坐标 public void EraseAt(Vector2 uvPoint) { eraseMaterial.SetVector(_BrushCenter, new Vector4(uvPoint.x, uvPoint.y, 0, 0)); eraseMaterial.SetFloat(_BrushSize, brushSize); eraseMaterial.SetFloat(_EraseStrength, eraseStrength); eraseMaterial.SetTexture(_BrushTex, brushTexture); // 双缓冲先读到 tempRT再写回 dirtMask Graphics.Blit(dirtMask, tempRT); Graphics.Blit(tempRT, dirtMask, eraseMaterial); } void OnDestroy() { if (dirtMask ! null) dirtMask.Release(); if (tempRT ! null) tempRT.Release(); } }这段代码的关键点有三个。第一maskResolution不要一上来就开 20481024 在大多数场景下已经够用2048 会让显存和带宽翻四倍移动端直接翻车。第二擦除必须用双缓冲因为 GPU 不允许同一张 RenderTexture 同时作为输入和输出直接 Blit 自己会出现未定义行为这是血泪经验。第三eraseStrength控制单帧擦除量如果水枪每帧都调用EraseAt这个值要调小0.05 到 0.2否则一帧就把整块脏污擦没了手感会很假。2.2 脏污 Shader 的混合公式与参数调节脏污遮罩建好之后物体表面的 Shader 需要把遮罩和基础材质混合。核心公式是finalColor lerp(cleanColor, dirtyColor, dirtMask.r)。但直接 lerp 会让脏污看起来像一层均匀的膜缺少层次。更好的做法是引入一个dirtContrast参数对遮罩值做幂运算让脏污边缘更锐利。// DirtSurface.shader 的核心片段 // 输入_MainTex 基础贴图_DirtMask 脏污遮罩_DirtColor 脏污颜色 half4 frag(v2f i) : SV_Target { half4 baseCol tex2D(_MainTex, i.uv); half dirt tex2D(_DirtMask, i.uv).r; // 对比度调节dirtContrast 越大脏污边缘越硬 dirt pow(dirt, _DirtContrast); // 脏污颜色叠加_DirtTint 控制脏污偏向油污偏黑、水垢偏白 half3 dirtyCol baseCol.rgb * _DirtColor.rgb * _DirtTint.rgb; half3 finalCol lerp(baseCol.rgb, dirtyCol, dirt); return half4(finalCol, baseCol.a); }_DirtContrast一般设在 0.5 到 2.0 之间。低于 0.5 脏污会显得糊高于 2.0 边缘会出现硬切。_DirtTint是个容易被忽略但很出效果的参数不同关卡用不同 tint厨房油污用深棕浴室水垢用灰白车库泥点用土黄玩家一眼就能感知到「这里脏得不一样」。2.3 脏污进度统计与关卡完成判定擦除之后要统计「还剩多少脏」。最直接的办法是定期把 RenderTexture 读回 CPU 做像素求和但ReadPixels是同步操作每帧调用会卡死主线程。常见做法是每隔 0.5 秒异步读一次或者用 Compute Shader 做归约求和把结果写到一个 1x1 的 RenderTexture 里再读回。// DirtProgressTracker.cs // 异步统计脏污剩余量避免每帧 ReadPixels using UnityEngine; public class DirtProgressTracker : MonoBehaviour { public DirtMaskController maskController; public float checkInterval 0.5f; // 统计间隔秒 public float cleanThreshold 0.05f; // 低于此值判定为清洁完成 private float timer; private Texture2D readbackTex; private float lastDirtRatio 1f; void Start() { readbackTex new Texture2D(64, 64, TextureFormat.R8, false); } void Update() { timer Time.deltaTime; if (timer checkInterval) return; timer 0f; // 降采样读回把 1024 的遮罩 Blit 到 64x64 再读减少带宽 RenderTexture small RenderTexture.GetTemporary(64, 64, 0, RenderTextureFormat.R8); Graphics.Blit(maskController.dirtMask, small); RenderTexture.active small; readbackTex.ReadPixels(new Rect(0, 0, 64, 64), 0, 0); readbackTex.Apply(); RenderTexture.active null; RenderTexture.ReleaseTemporary(small); // 求平均脏污值 Color[] pixels readbackTex.GetPixels(); float sum 0f; for (int i 0; i pixels.Length; i) sum pixels[i].r; lastDirtRatio sum / pixels.Length; if (lastDirtRatio cleanThreshold) { Debug.Log(清洁完成); // 触发关卡完成逻辑 } } }这里降采样到 64x64 是关键优化。1024x1024 读回是 100 万像素64x64 只有 4096 像素带宽差 256 倍而统计精度对关卡判定来说完全够用。cleanThreshold不要设成 0因为软边缘笔刷永远会留一点点残值0.05 是比较稳的经验值。3. 水流与粒子系统让高压水枪看起来「有劲」3.1 水柱用粒子还是用 Mesh两种方案的取舍高压水枪的水柱有两种主流做法。第一种是粒子系统用大量小粒子模拟水花和飞溅优点是自然、容易做碰撞反弹缺点是水柱主体容易显得散不够「高压」。第二种是用一条拉伸的 Mesh通常用 Trail Renderer 或自定义 Tube Mesh做水柱主体粒子只负责末端飞溅。我一般会混合使用Mesh 做水柱核心粒子做撞击点飞溅和雾气。Mesh 水柱的关键是让顶点沿路径做正弦扰动模拟水流的抖动。路径由水枪口到撞击点决定每帧更新。如果水柱长度变化剧烈还要做 UV 滚动让水看起来在流动。// WaterJetMesh.cs // 生成一条从枪口到撞击点的水柱 Mesh using UnityEngine; [RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] public class WaterJetMesh : MonoBehaviour { public Transform nozzle; // 枪口位置 public float baseRadius 0.02f; // 水柱基础半径 public float taperFactor 0.5f; // 末端收缩系数 public int segments 12; // 路径分段数 public float waveAmplitude 0.01f; // 抖动幅度 public float waveFrequency 20f; // 抖动频率 private Mesh mesh; private Vector3[] vertices; private Vector2[] uvs; private int[] triangles; void Start() { mesh new Mesh(); mesh.name WaterJet; GetComponentMeshFilter().mesh mesh; BuildMesh(); } void BuildMesh() { // 每个分段 4 个顶点上下左右构成一个四边形环 vertices new Vector3[segments * 4]; uvs new Vector2[segments * 4]; triangles new int[(segments - 1) * 4 * 6]; int triIndex 0; for (int i 0; i segments - 1; i) { int a i * 4; int b (i 1) * 4; for (int j 0; j 4; j) { int next (j 1) % 4; triangles[triIndex] a j; triangles[triIndex] b j; triangles[triIndex] b next; triangles[triIndex] a j; triangles[triIndex] b next; triangles[triIndex] a next; } } mesh.vertices vertices; mesh.uv uvs; mesh.triangles triangles; } void Update() { Vector3 start nozzle.position; Vector3 end GetHitPoint(); // 射线检测得到的撞击点 Vector3 dir (end - start).normalized; float length Vector3.Distance(start, end); // 构建正交基 Vector3 right Vector3.Cross(dir, Vector3.up).normalized; Vector3 up Vector3.Cross(right, dir).normalized; for (int i 0; i segments; i) { float t i / (float)(segments - 1); Vector3 center Vector3.Lerp(start, end, t); // 正弦抖动让水柱有「高压」感 float wave Mathf.Sin(t * waveFrequency Time.time * 30f) * waveAmplitude; center right * wave; float radius Mathf.Lerp(baseRadius, baseRadius * taperFactor, t); int baseIdx i * 4; vertices[baseIdx 0] center right * radius; vertices[baseIdx 1] center up * radius; vertices[baseIdx 2] center - right * radius; vertices[baseIdx 3] center - up * radius; for (int j 0; j 4; j) uvs[baseIdx j] new Vector2(j / 4f, t * length * 2f); } mesh.vertices vertices; mesh.uv uvs; mesh.RecalculateBounds(); } Vector3 GetHitPoint() { // 从枪口向前发射射线命中则返回命中点否则返回最大距离处 if (Physics.Raycast(nozzle.position, nozzle.forward, out RaycastHit hit, 50f)) return hit.point; return nozzle.position nozzle.forward * 50f; } }waveAmplitude和waveFrequency是手感调节的核心。幅度太大水柱会像鞭子太小又显得软。我一般从 0.01 和 20 起步根据水枪压力等级动态调低压档幅度小频率低高压档幅度大频率高。taperFactor控制末端收缩0.5 意味着末端半径是根部的一半视觉上更有冲击力。3.2 撞击点飞溅粒子的参数配置水柱打到物体表面后需要飞溅粒子。粒子系统的配置有几个必调参数发射速率Rate over Time跟水枪压力挂钩压力越高发射越多粒子生命周期设 0.3 到 0.6 秒太长会显得拖沓速度用 Cone 形状角度 30 到 60 度速度 2 到 5 米每秒。碰撞模块开启让粒子碰到物体后反弹或消失。// SplashParticleController.cs // 根据水枪压力动态调整飞溅粒子 using UnityEngine; public class SplashParticleController : MonoBehaviour { public ParticleSystem splashPS; public Transform hitPoint; // 撞击点位置由射线更新 private ParticleSystem.EmissionModule emission; private ParticleSystem.MainModule main; void Start() { emission splashPS.emission; main splashPS.main; } // pressure: 0~1由水枪档位决定 public void SetPressure(float pressure) { // 发射速率随压力线性增长 emission.rateOverTime Mathf.Lerp(20f, 200f, pressure); // 粒子速度随压力增长 var velocity splashPS.velocityOverLifetime; velocity.speedModifier Mathf.Lerp(0.5f, 2.0f, pressure); // 粒子大小随压力略微增大 main.startSize Mathf.Lerp(0.02f, 0.06f, pressure); } void Update() { // 粒子系统跟随撞击点 if (hitPoint ! null) splashPS.transform.position hitPoint.position; } }这里有个容易踩的坑粒子系统的rateOverTime如果每帧都赋值会触发内部重建性能开销比想象中大。更好的做法是只在压力档位变化时调用SetPressure而不是每帧调。另外粒子系统的 Simulation Space 建议设成 World否则水枪快速移动时粒子会跟着「粘」在枪上看起来很不自然。3.3 水花与脏污的联动擦除反馈怎么做清洁游戏最爽的瞬间是「脏东西被冲掉」的反馈。除了脏污遮罩变透明还需要视觉和音效配合。视觉上可以在擦除位置生成一小簇脏污粒子颜色取脏污 tint音效上根据脏污剩余量调整冲刷声的强度。脏污越厚声音越闷快干净时声音变清脆。这个细节很多项目会忽略但它是「解压感」的重要来源。// CleanFeedback.cs // 擦除时的视听反馈 using UnityEngine; public class CleanFeedback : MonoBehaviour { public ParticleSystem dirtParticles; // 脏污飞溅粒子 public AudioSource washAudio; public AudioClip washLoop; public float audioPitchRange 0.3f; // 音调变化范围 private float dirtRatio 1f; public void OnErase(Vector3 worldPos, float currentDirtRatio) { dirtRatio currentDirtRatio; // 在擦除位置播放脏污粒子 dirtParticles.transform.position worldPos; dirtParticles.Emit(5); // 根据脏污剩余量调整音调越脏越低沉 washAudio.pitch 1f - dirtRatio * audioPitchRange; } }audioPitchRange设 0.3 意味着脏污满时音调降到 0.7干净时回到 1.0。这个变化幅度人耳能明显感知但不会觉得突兀。粒子发射数量 5 是个经验值太多会糊屏太少没感觉。4. 性能优化与移动端适配别让水花把帧率冲没了4.1 脏污遮罩的分辨率与显存预算脏污遮罩是这类游戏最大的显存消耗源之一。每个可清洁物体一张 1024x1024 的 ARGB32 RenderTexture占 4MB 显存。如果场景里有 20 个可清洁物体就是 80MB移动端直接爆。常见做法是分级主要物体用 1024次要物体用 512远景物体用 256。另外 RenderTexture 的格式可以降到 R8只存脏污强度一个通道显存直接省四分之三。// 根据物体重要性选择遮罩分辨率 public int GetMaskResolution(ObjectImportance importance) { switch (importance) { case ObjectImportance.High: return 1024; case ObjectImportance.Medium: return 512; case ObjectImportance.Low: return 256; default: return 512; } }R8 格式在 Unity 里对应RenderTextureFormat.R8Shader 里读.r通道即可。注意有些老设备不支持 R8需要回退到 ARGB32用SystemInfo.SupportsRenderTextureFormat判断。4.2 粒子数量与 Draw Call 的平衡粒子系统如果不做限制一个水枪就能产生上千个粒子每个粒子一次 Draw Call 的话直接卡死。必须开启粒子系统的 GPU Instancing并且用一张图集把飞溅、雾气、水滴都打进去减少材质切换。另外粒子的 Max Particles 要设上限移动端建议不超过 500PC 端不超过 2000。// 粒子系统性能配置 var main splashPS.main; main.maxParticles 500; // 移动端上限 main.simulationSpace ParticleSystemSimulationSpace.World; var renderer splashPS.GetComponentParticleSystemRenderer(); renderer.renderMode ParticleSystemRenderMode.Billboard; renderer.material splashAtlasMaterial; // 图集材质 renderer.enableGPUInstancing true; // 开启 GPU InstancingenableGPUInstancing在粒子数量多的时候效果显著但要注意图集材质必须支持 Instancing否则开了也没用。另外renderMode用 Billboard 比 Mesh 便宜得多除非有特殊需求否则不要用 Mesh 粒子。4.3 移动端发热与帧率稳定策略移动端跑这类游戏最大的敌人是发热降频。脏污遮罩的 Blit 操作和粒子模拟都是 GPU 密集任务连续跑几分钟手机就会烫。策略是动态降级检测到帧率持续低于阈值时自动降低脏污遮罩更新频率从每帧擦除改成每两帧擦除一次同时减少粒子发射量。// AdaptiveQuality.cs // 根据帧率动态调整质量 using UnityEngine; public class AdaptiveQuality : MonoBehaviour { public DirtMaskController maskController; public SplashParticleController splashController; public float targetFPS 30f; public float checkWindow 2f; private float timer; private int frameCount; private int qualityLevel 2; // 2高1中0低 void Update() { timer Time.deltaTime; frameCount; if (timer checkWindow) { float fps frameCount / timer; if (fps targetFPS * 0.8f qualityLevel 0) { qualityLevel--; ApplyQuality(); } else if (fps targetFPS * 1.1f qualityLevel 2) { qualityLevel; ApplyQuality(); } timer 0f; frameCount 0; } } void ApplyQuality() { switch (qualityLevel) { case 2: maskController.eraseStrength 0.15f; splashController.SetPressure(1f); break; case 1: maskController.eraseStrength 0.1f; splashController.SetPressure(0.6f); break; case 0: maskController.eraseStrength 0.08f; splashController.SetPressure(0.3f); break; } } }这套自适应逻辑的核心是「宁可降质量也不掉帧」。玩家对帧率波动的敏感度远高于画质细微变化。checkWindow设 2 秒是为了避免频繁切换切换本身也有开销。5. 避坑与排查那些让清洁游戏「冲不干净」的典型问题5.1 脏污擦除后边缘出现黑边现象擦除区域边缘有一圈黑色像素看起来像没擦干净。原因通常是 RenderTexture 的 alpha 通道在 Blit 时被污染或者 Shader 里 lerp 的插值出了问题。解决方法是确保擦除 Shader 输出时 alpha 保持为 1只修改 RGB 或单通道强度。如果用的是 R8 格式检查 Blit 材质是否错误地写了 alpha。5.2 水柱 Mesh 在快速移动时撕裂现象玩家快速转动视角时水柱 Mesh 出现断裂或扭曲。原因是 Mesh 顶点每帧重建但重建发生在 Update 里而渲染可能发生在 LateUpdate 之后导致顶点数据和实际枪口位置不同步。解决方法是把 Mesh 更新放到 LateUpdate并且在更新前先做射线检测确定撞击点。5.3 粒子系统在低端机上不显示现象PC 上正常打包到移动端后飞溅粒子完全消失。原因通常是粒子材质用了不支持的 Shader或者 Max Particles 设得太大导致显存分配失败。排查步骤先检查材质 Shader 是否在移动端支持列表里再把 Max Particles 降到 100 测试如果恢复显示就是显存问题。5.4 脏污进度统计始终不归零现象明明肉眼看着已经冲干净了进度条就是差最后一点。原因是软边缘笔刷在边缘留下了极低但不为零的残值而cleanThreshold设得太低。解决方法是把阈值调到 0.05 到 0.1 之间或者在 Shader 里对脏污值做一次 clamp低于 0.02 的直接归零。5.5 多物体同时擦除时帧率骤降现象场景里多个可清洁物体同时被水枪扫到时帧率从 60 掉到 20。原因是每个物体每帧都做一次 Blit 双缓冲Draw Call 叠加。解决方法是合并脏污遮罩把相邻物体的 UV 映射到同一张 RenderTexture 的不同区域一次 Blit 完成多个物体的擦除。这需要提前规划 UV 布局但性能收益很大。6. 进阶技巧用 Compute Shader 把擦除性能再压一个数量级前面用的Graphics.Blit方案在物体数量少时没问题但当天可清洁物体超过 30 个Blit 的 Draw Call 开销就开始显现。这时候可以上 Compute Shader把所有脏污遮罩合并成一张大图集用一个 Dispatch 完成所有擦除操作。核心思路是把所有可清洁物体的脏污遮罩按网格排列到一张 4096x4096 的大 RenderTexture 上每个物体占一个格子。擦除时Compute Shader 接收一个擦除点列表世界坐标转 UV 再转图集坐标每个线程处理一个像素判断该像素是否在任一擦除笔刷范围内是则减去擦除量。// DirtErase.compute // 批量擦除一次 Dispatch 处理所有擦除点 #pragma kernel EraseDirt Texture2Dfloat _DirtAtlas; // 输入脏污图集 RWTexture2Dfloat _DirtAtlasOut; // 输出脏污图集 StructuredBufferfloat4 _ErasePoints; // 擦除点列表 (uvX, uvY, radius, strength) int _ErasePointCount; [numthreads(8, 8, 1)] void EraseDirt(uint3 id : SV_DispatchThreadID) { float2 uv (id.xy 0.5) / float2(4096, 4096); float dirt _DirtAtlas[id.xy]; for (int i 0; i _ErasePointCount; i) { float4 point _ErasePoints[i]; float dist distance(uv, point.xy); if (dist point.z) { // 软边缘衰减 float falloff 1.0 - smoothstep(0.0, point.z, dist); dirt - falloff * point.w; } } _DirtAtlasOut[id.xy] saturate(dirt); }C# 侧每帧收集所有擦除点写入StructuredBuffer然后Dispatch一次。相比每个物体一次 BlitDraw Call 从 N 降到 1。numthreads用 8x8 是通用选择4096 的图集需要 Dispatch 512x512 个线程组。参数上_ErasePoints的数量要设上限比如 64 个超过就丢弃最远的点。point.w是擦除强度跟水枪压力挂钩。smoothstep的衰减曲线决定了笔刷边缘的软硬想要硬边缘就把 smoothstep 的范围缩小。这套方案的代价是图集管理复杂度上升每个物体的 UV 要映射到图集格子物体增删时要重新分配格子。我一般会写一个DirtAtlasAllocator来管理格子分配和释放用简单的网格分配算法就够不需要动态碎片整理。最后说个我自己的习惯每次调完脏污参数我都会用一张纯白背景的测试场景跑一遍把脏污遮罩单独显示在屏幕上肉眼确认擦除边缘和残值分布。这比在复杂场景里猜要快得多也少走很多弯路。希望帮到你。本文还有配套的精品资源点击获取