简介:这是一套面向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不要一上来就开 2048,1024 在大多数场景下已经够用,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"; GetComponent<MeshFilter>().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.R8,Shader 里读.r通道即可。注意有些老设备不支持 R8,需要回退到 ARGB32,用SystemInfo.SupportsRenderTextureFormat判断。
4.2 粒子数量与 Draw Call 的平衡
粒子系统如果不做限制,一个水枪就能产生上千个粒子,每个粒子一次 Draw Call 的话直接卡死。必须开启粒子系统的 GPU Instancing,并且用一张图集把飞溅、雾气、水滴都打进去,减少材质切换。另外粒子的 Max Particles 要设上限,移动端建议不超过 500,PC 端不超过 2000。
// 粒子系统性能配置 var main = splashPS.main; main.maxParticles = 500; // 移动端上限 main.simulationSpace = ParticleSystemSimulationSpace.World; var renderer = splashPS.GetComponent<ParticleSystemRenderer>(); 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 Texture2D<float> _DirtAtlas; // 输入脏污图集 RWTexture2D<float> _DirtAtlasOut; // 输出脏污图集 StructuredBuffer<float4> _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一次。相比每个物体一次 Blit,Draw Call 从 N 降到 1。numthreads用 8x8 是通用选择,4096 的图集需要 Dispatch 512x512 个线程组。
参数上,_ErasePoints的数量要设上限,比如 64 个,超过就丢弃最远的点。point.w是擦除强度,跟水枪压力挂钩。smoothstep的衰减曲线决定了笔刷边缘的软硬,想要硬边缘就把 smoothstep 的范围缩小。
这套方案的代价是图集管理复杂度上升:每个物体的 UV 要映射到图集格子,物体增删时要重新分配格子。我一般会写一个DirtAtlasAllocator来管理格子分配和释放,用简单的网格分配算法就够,不需要动态碎片整理。
最后说个我自己的习惯:每次调完脏污参数,我都会用一张纯白背景的测试场景跑一遍,把脏污遮罩单独显示在屏幕上,肉眼确认擦除边缘和残值分布。这比在复杂场景里猜要快得多,也少走很多弯路。希望帮到你。
本文还有配套的精品资源,点击获取