Building the Cleaning System!
Over the past month, I’ve been developing a major part of my game, the Cleaning System. This system will be used to transform old dusty, dirty and rusty items to their sparkly former glory, and it can also handle stripping the paint all the way off of an item to reveal the bare surface underneath.
This system allows players to transform old, dusty, dirty, and rusty items back to their former glory — and even strip paint completely to reveal the bare surface underneath.
Here’s how it works.
The Big Idea (Super Simple Version)
Imagine that the cleanable game object in the has a hidden “dirt map” texture. That texture contains four layers, each stored in a different channel:
Red = Dust
Green = Dirt
Blue = Rust
Alpha = Paint
Each layer is like a transparent sticker sitting on top of the object. When the player cleans something, they’re essentially erasing parts of that hidden texture. The shader reads this texture and decides what should appear dirty and what should look clean.
Inspiration & Research
Inspired by several existing systems and tutorials, I combined a few different techniques to achieve this, and whilst there is still room for improvement and optimisation, I thought I’d share how the current system roughly works for those who are curious.
Chief Valenine's interactive inspiration
SmartTexture Asset for Unity
I originally saw this video on a Discord forum from Chief Valentine and was immediately intrigued. After reaching out to them, they directed me to this article
After reading through it and deciding not to use LightMap UVs, I opted for a simpler UV texture mask/swapping system as explained in the following video from the YouTuber Code Monkey.
I also found this Unity Asset that allowed for multiple layers of dirt using multiple masks, all stored into 1 texture and 1 material, meaning fewer draw calls than switching materials at run time. So If I could combine this with the texture swapping system as shown in the video, I might be able to get something unique with multiple types of dirt.
After downloading and playing around with it, the pack essentially uses 3 to 4 unity black and white texture maps, one stored in each channel of a Mask texture. (Red, Green, Blue, and Alpha Channels). I decided to make my own Shader based on this idea using Shader Graph, with the added option for setting the colour for the Rust.
Here you can see my Shader for laying multiple textures and using the Masks texture to reveal certain amounts of each texture to get the final output.
As you can see, the final material is built up by layering each mask upon the next, with a unique mask for each dirt type. So Dust is on the top, followed by Dirt, with Rust, and then finally a Paint layer. The masks are generated in Substance using dynamic mask generators so that I can make use of the object curvature. I mostly used Blender’s auto UV layout and packing, but will continue to look into better solutions for automating UVs on these low-poly assets.
Once I had 4 black and white texture maps, I needed a way to pack them into a single texture. I couldn’t find an easy way to get this output from Substance Painter, and rather than use something like Photoshop, I found the following free asset that can generate a packed texture all within Unity:
SmartTexture Asset for Unity
SmartTexture is a custom asset for Unity that allows you a channel packing workflow in the editortextures and use them in the Unity editor for a streamlined workflow. SmartTextures work as a regular 2D texture asset and you can assign it to material inspectors.
Dependency tracking is handled by SmartTexture, meaning if input textures change, the SmartTexture will be re-generated automatically. The input textures are editor-only dependencies; they will not be marked for inclusion in the build.
Texture packing done in Unity allows for less destructive workflow when an individual mask is updated.
Cleaning Tools
I then built upon my existing Interactive Tool pick up system and created a new class of Tool, a ‘Cleaning Tool’ that has unique properties, such as following the surface normal and generating particles as it interacts. Each cleaning Tool has a list of surfaces (or masks) that it can interact with. For example, the Sponge tool removes Dust and Dirt, but does not affect the Rust or Paint Masks. A sandpaper or sandblaster tool might affect all 4 masks, revealing the bare surface underneath! Each tool also has a unique bush alpha, too, allowing for more or less scrubbing required.
An example of the Cleaning Tool Scriptable Object . Here we have a Sponge that will effect the Dust and Dirt masks only, and a unique Brush Texture to use.
I then created a Cleaning Mask Manager Component that is needed on any item you want to be 'cleanable'. This component lives on the Mesh object and compares the original Mask texture to the one held in memory to work out the Cleaned Percentage amount for each layer.
The Cleaning Mask Manager component lives on the mesh object. Making a duplicate of the Mask texture in memory, it can then compare this to the original Mask to work out the Cleaned Percentage for each layer.
So how does it update at run time? Very similar to the simple cleaning mini game as shown in Code Monkey’s video, the tool generates a render texture at run time, and then compares this to the original Base Mask Texture (Channel map packed texture) to calculate the Cleaned Percentage of each Layer. This Runtime texture would then be saved to disk as a .png so that the cleaning status would be saved.
Step 1 - Creating the “Scratch-Off Card”
When an object initialises: It grabs its material, it creates a unique instance of that material (so changes don’t affect other objects) It creates a new writable texture in memory. This texture becomes the runtime dirt mask — essentially a scratch-off card the player can modify.
Step 2 - Copying the Starting Condition
Each object has a baseMaskTexture. This represents its starting condition: A fully rusty car, a lightly dusty table, and a half-painted crate. At startup, the system: Reads every pixel from this base texture, scales it to a consistent resolution (1024x1024), and copies it into the runtime editable texture. Now the object has its own private dirt texture that can be modified independently.
Step 3 — Connecting It to the Shader
The runtime mask is assigned to the material: runtimeMaterial.SetTexture("_Masks", runtimeMaskTexture); The shader then reads: Red channel → Dust Green channel → Dirt Blue channel → Rust Alpha channel → Paint. If a value is high, that layer appears strong. If it’s near zero, that layer disappears.
Step 4 — When the Player Cleans
When the player uses a cleaning tool, A brush texture is projected onto the object using UV coordinates. A specific channel (Dust/Dirt/Rust/Paint) is selected. Pixel values in that channel are reduced based on brush strength. So instead of: Rust = 1.0 (fully rusty) It might become: Rust = 0.3 Rust = 0.0 When it approaches zero… That part looks clean.
Each Cleaning Tool follows the surface normal, Spawns particles while interacting, has a unique brush alpha (scrubbing strength), and specifies which layers it can affect. For example, A Sponge removes Dust and Dirt, Sandpaper might remove Paint, and a Sandblaster could strip all layers down to the base surface. This makes tools feel mechanically distinct rather than cosmetic.
Step 5 — Tracking Cleaning Progress
To track progress, the system loops through all pixels, checks how many are nearly zero in a given channel, and calculates the percentage cleaned. This allows me to drive: UI progress bars, Completion triggers, and Scoring systems
Step 6 — Saving & Loading
To save cleaning progress: The runtime mask texture is encoded to PNG Converted to Base64, and stored in JSON. When loading: The PNG is reconstructed, reapplied to the texture, and reassigned to the material. The object remains exactly as the player left it.
Where It Can Improve?
There’s still room for optimisation: GPU-based painting instead of CPU pixel edits, smarter resolution scaling, and more automated UV workflows. I might also do away with having base textures for Dirt, Dust, Rust and Paint and just use colour values as I did for the Rust multiplierer, as this would suit my low-poly look and save some memory, but as a foundation, this system is: Flexible, visually believable, fully persistent, and most importantly, it feels satisfying to use.
I hope this Blog was interesting and helpful. If you have any questions or comments, please leave them below, and I'll continue to document any interesting new systems as I build them. Until next time...
- Chris
