Teardown uses an 8-bit color palette for voxel materials, so any voxel volume can have up to 255 different materials and the representation per voxel is then just a single byte to save memory. A material specifies not only the color, but also things such as roughness, emissiveness, reflectivity and physical material type (wood, metal, foliage, etc). Each object can have a unique palette, but a lot of them share the same one. When something breaks, all the pieces inherit the original object palette, so the number of palettes do not increase over time. I pack all palettes in a texture that is 256 in width and number of materials in height and keep that on the GPU for rendering. This way of handling materials conflicts with one particular feature that I really wanted in the game - the spraycan.
If each voxel stored RGB values, recoloring them would be trivial, but doing it with a fixed palette is a whole different story, especially since I wanted the ability to paint with two different colors (yellow for spraycan and black from fire and explosions) and also allow recoloring in several shades to do gradients and antialiased edges. Here is what the end result looks like and how I solved the problem.
The basic idea is to create color variations of all used materials in the palette and populate the unused areas with these variations as a precomputation step at load time. Allowing two color shades in four steps requires eight empty slots in the palette per used material, cutting the usable number of entries in the palette down from 255 to a mere 28.
Most objects in teardown actually only use a handful of materials, so this is rarely a problem. A simple prop, like a chair or a table might even use just a single material, but the more complex ones, like a large boat or a house might use dozens of materials. On top of this, small objects are often merged into larger volumes to improve performance and at this merge step, materials from all merged objects must be combined into the same palette, so it can fill up pretty quickly.
If running out of empty slots, I search for visually similar materials and try to squeeze as many as possible of them into the palette. Afterwards I create a translation table for each shade that can be used as a lookup when recoloring (I know DOOM did a similar thing back in the day to emulate lighting with a fixed palette by using a translation table to pick darker variants of existing colors by referencing the best match out all existing colors in the palette). For each palette there is a translation table like this:
unsigned char yellowVariant[STEPS];
So, for instance if I want to tint a voxel one step towards yellow I do this:
yellow = yellowVariant[original];
And then if I want to make the voxel even more yellow I can do the same one more time, but now with the new index:
moreYellow = yellowVariant[yellow];
This would have been the same things as using the second step of the table from the beginning (this is actaully not always true when running out of empty slots):
moreYellow = yellowVariant[original];
There is a similar table for the black shades as well, and this is where it gets complex. Say you use the spraycan to paint something yellow, then you blast a bomb near that area to tint the yellow paint black. This is were palette indices start running out quickly, because we need a black variant, not only for all the original materials, but also for each yellow variant of each unique material and the same of course applies the other way around.
The implementation in teardown uses a rather naive implementation that merges visually similar colors and simply stops adding new colors when the palette is full. (It actually prioritizes opaque colors and adds transparent colors only if there are available slots in the palette, since blending transparency doesn’t work well in the engine anyway).
The translation table is done last in a separate pass when all new colors have been added. It can choose freely from all available materials and pick the best match, so it is totally possible that one original material gets translated to another original material if it happens to be a yellow or black variant.
At some point I would like to improve the generation of new materials and use some kind of optimization algorithm to select the materials that generate the best translation table given the constraints, but it’s a non-trivial task that might be quite hard to pull off.
If you want to see the limitations of the current approach, open the Castle example we ship in Create mode and bring out the spray can. The castle level is built as one huge scene in MagicaVoxel and therefore uses a single palette for the entire level. You’ll notice that some materials get a brown tint instead of yellow (probably because there already are a lot of brown shades in the palette) and a couple of materials that won’t even change at all, most likely because the palette filled up before reaching that index in the palette, forcing the best yellow variant of that color to simply become the original color itself.