Store paintings as per-painting folder of PNGs + JSON

Replace the flat binary .paint format with a folder per painting under
Paintings/<key>/: albedo/emission as sRGB PNGs, the packed MSO map split
into human-readable metallic/occlusion/smoothness grayscale PNGs, and the
non-baked scalars (name, size, emission intensity, camo/height knobs) in
painting.json. MSO.B is always 0 (pack shader), so the split round-trips
losslessly with no alpha ambiguity. Public API (SavePaintAs/LoadPaintByName/
ListSavedPaintings/PaintExists/DeletePaint) and the network snapshot path
(WriteCanonicalMaps/ReadCanonicalMaps) are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-07-03 14:52:16 +02:00
1 parent 6434e1c4fc
commit 6ef4f56394
1 file changed
+203 -139
@@ -1,6 +1,5 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEngine;
public partial class MeshPainter
@@ -11,30 +10,54 @@ public partial class MeshPainter
// player explicitly saves it to a named slot, and a fresh session always starts from the material
// seed. Saved slots live in a single global library folder (role/object independent), so any avatar
// can load any saved painting as a camouflage preset.
//
// On-disk layout: one human-readable folder per painting.
// Paintings/<key>/albedo.png sRGB colour (the visible camo pattern)
// Paintings/<key>/emission.png sRGB colour
// Paintings/<key>/metallic.png greyscale = canonical MSO.R
// Paintings/<key>/occlusion.png greyscale = canonical MSO.G
// Paintings/<key>/smoothness.png greyscale = canonical MSO.A
// Paintings/<key>/painting.json display name + map size + the non-baked material scalars
// The packed mask map is split into three greyscale images so every file is viewable/editable. This
// also makes the round-trip byte-exact by construction: none of the stored images carry a meaningful
// alpha channel (colour maps are forced opaque, the mask channels become opaque greyscale), so there
// is no PNG alpha / premultiply ambiguity. Canonical MSO.B is always 0 (see MeshPaintPackMS.shader)
// and is reconstructed on load. The downstream reseed path (ReseedCanonical) is unchanged, so pixel
// fidelity equals the previous .paint format plus exact PNG transport.
// Raw, bit-exact map dump: 'CHMP' magic + version + name + width + height + map count, then each
// map's canonical Color32 bytes. Raw (not PNG) avoids any sRGB/alpha-premultiply ambiguity and
// round-trips the exact canonical bytes ReadCanonical produces.
private const uint PaintFileMagic = 0x504D4843; // 'CHMP' little-endian
private const int PaintFileVersion = 4; // v3: emission + camo scalars; v4: + painted-height knobs
private const string PaintFileExt = ".paint";
private const int PaintMetaVersion = 5; // folder format: split PNG maps + JSON scalars
private const string MetaFileName = "painting.json";
private const string AlbedoFileName = "albedo.png";
private const string EmissionFileName = "emission.png";
private const string MetallicFileName = "metallic.png";
private const string OcclusionFileName = "occlusion.png";
private const string SmoothnessFileName = "smoothness.png";
/// <summary>
/// Per-player material scalars stored alongside the maps from v3 on. These are NOT baked into the
/// painted pixels (a global emission multiplier + the PlayerCamoLit knobs), so they ride the slot
/// header and are re-applied onto the painter when the slot loads.
/// JSON sidecar for a slot: display name, map resolution, and the per-player material scalars that are
/// NOT baked into the painted pixels (a global emission multiplier + the PlayerCamoLit camo knobs + the
/// painted-height knobs). They ride alongside the maps and are re-applied onto the painter on load.
/// JsonUtility needs public fields on a concrete [Serializable] type.
/// </summary>
private struct PaintScalars
[System.Serializable]
private class PaintMeta
{
public bool has; // false = legacy v2 slot with no scalars -> leave the painter's values untouched
public bool hasHeight; // v4+ painted-height knobs present (occlusion threshold + normal/parallax intensity)
public string name;
public int version = PaintMetaVersion;
public int width;
public int height;
public float emissionIntensity;
public float camoHalfLambert, camoHalfLambertBrightness, camoSpecularDampen;
public float occlusionThreshold, heightNormalIntensity, parallaxIntensity;
public float camoHalfLambert;
public float camoHalfLambertBrightness;
public float camoSpecularDampen;
public float occlusionThreshold;
public float heightNormalIntensity;
public float parallaxIntensity;
}
private static string PaintLibraryDir =>
Path.Combine(Application.persistentDataPath, "MeshPaint", "Saved");
Path.Combine(Application.persistentDataPath, "Paintings");
/// <summary>Filesystem-safe key: invalid filename characters and spaces become underscores.</summary>
internal static string SanitizeFileKey(string s)
@@ -46,24 +69,24 @@ public partial class MeshPainter
private static string SanitizeKey(string s) =>
string.IsNullOrEmpty(s) ? "unnamed" : SanitizeFileKey(s);
/// <summary>Disk path for a slot. The display name lives inside the file; the file name is a sanitized key.</summary>
private static string PathForName(string name) =>
Path.Combine(PaintLibraryDir, SanitizeKey(name) + PaintFileExt);
/// <summary>Per-painting folder. The display name lives in the JSON; the folder name is a sanitized key.</summary>
private static string FolderForName(string name) =>
Path.Combine(PaintLibraryDir, SanitizeKey(name));
/// <summary>True if a slot with this name already exists (used to block overwriting on save).</summary>
public static bool PaintExists(string name) =>
!string.IsNullOrWhiteSpace(name) && File.Exists(PathForName(name));
!string.IsNullOrWhiteSpace(name) && Directory.Exists(FolderForName(name));
/// <summary>Display names of every saved slot, sorted alphabetically. Each name is read from the file header.</summary>
/// <summary>Display names of every saved slot, sorted alphabetically. Each name is read from its JSON sidecar.</summary>
public static List<string> ListSavedPaintings()
{
var names = new List<string>();
try
{
if (!Directory.Exists(PaintLibraryDir)) return names;
foreach (string path in Directory.GetFiles(PaintLibraryDir, "*" + PaintFileExt))
foreach (string dir in Directory.GetDirectories(PaintLibraryDir))
{
string n = ReadHeaderName(path);
string n = ReadDisplayName(dir);
if (!string.IsNullOrEmpty(n)) names.Add(n);
}
}
@@ -76,8 +99,8 @@ public partial class MeshPainter
}
/// <summary>
/// Writes the current painted maps to a named slot. Returns false without writing if the painter is
/// not ready. An existing slot of the same name is overwritten only when <paramref name="overwrite"/>
/// Writes the current painted maps to a named slot folder. Returns false without writing if the painter
/// is not ready. An existing slot of the same name is overwritten only when <paramref name="overwrite"/>
/// is true; otherwise the call fails rather than clobbering it.
/// </summary>
public bool SavePaintAs(string name, bool overwrite = false)
@@ -88,33 +111,39 @@ public partial class MeshPainter
try
{
Directory.CreateDirectory(PaintLibraryDir);
string dir = FolderForName(name);
Directory.CreateDirectory(dir);
using (var fs = new FileStream(PathForName(name), FileMode.Create, FileAccess.Write))
using (var w = new BinaryWriter(fs))
Color32[] albedo = ReadCanonical(MAP_ALBEDO);
Color32[] mso = ReadCanonical(MAP_MSO);
Color32[] emission = ReadCanonical(MAP_EM);
// Colour maps are stored opaque (the reseed path re-derives coverage/alpha anyway). The packed
// mask map is split into one opaque greyscale image per channel: R=metallic, G=occlusion,
// A=smoothness (canonical MSO.B is always 0).
WriteMapPng(Path.Combine(dir, AlbedoFileName), albedo, c => new Color32(c.r, c.g, c.b, 255));
WriteMapPng(Path.Combine(dir, EmissionFileName), emission, c => new Color32(c.r, c.g, c.b, 255));
WriteMapPng(Path.Combine(dir, MetallicFileName), mso, c => new Color32(c.r, c.r, c.r, 255));
WriteMapPng(Path.Combine(dir, OcclusionFileName), mso, c => new Color32(c.g, c.g, c.g, 255));
WriteMapPng(Path.Combine(dir, SmoothnessFileName), mso, c => new Color32(c.a, c.a, c.a, 255));
// The painter is ready here (_setupValid), so the material-backed camo/height getters return the
// live values rather than the not-ready 0.
var meta = new PaintMeta
{
w.Write(PaintFileMagic);
w.Write(PaintFileVersion);
w.Write(name); // length-prefixed UTF8 (BinaryWriter.Write(string))
w.Write(textureSize);
w.Write(textureSize);
w.Write(MAP_COUNT);
// v3: per-player material scalars that are NOT baked into the maps (a global emission
// multiplier + the PlayerCamoLit knobs). The painter is ready here (_setupValid), so the
// material-backed camo getters return the live values rather than the not-ready 0.
w.Write(emissionIntensity);
w.Write(CamoHalfLambert);
w.Write(CamoHalfLambertBrightness);
w.Write(CamoSpecularDampen);
// v4: the painted-height knobs (same material-instance source of truth as the camo knobs).
w.Write(OcclusionThreshold);
w.Write(HeightNormalIntensity);
w.Write(ParallaxIntensity);
WriteCanonicalMaps(w);
}
name = name,
version = PaintMetaVersion,
width = textureSize,
height = textureSize,
emissionIntensity = emissionIntensity,
camoHalfLambert = CamoHalfLambert,
camoHalfLambertBrightness = CamoHalfLambertBrightness,
camoSpecularDampen = CamoSpecularDampen,
occlusionThreshold = OcclusionThreshold,
heightNormalIntensity = HeightNormalIntensity,
parallaxIntensity = ParallaxIntensity,
};
File.WriteAllText(Path.Combine(dir, MetaFileName), JsonUtility.ToJson(meta, true));
return true;
}
catch (System.Exception e)
@@ -132,7 +161,22 @@ public partial class MeshPainter
{
if (!_setupValid) return false;
Color32[][] canonical = ReadMaps(PathForName(name), out PaintScalars scalars);
string dir = FolderForName(name);
if (!Directory.Exists(dir)) return false;
PaintMeta meta = ReadMeta(dir);
// Resolution change -> ignore rather than smear mismatched data (the brush re-seeds from the
// material instead). A missing/old sidecar has width 0 -> skip the header check and trust the PNGs
// (ReadMapPng validates each image's resolution on its own).
if (meta != null && meta.width > 0 && (meta.width != textureSize || meta.height != textureSize))
{
Debug.LogWarning($"[MeshPainter] Saved paint ignored (got {meta.width}x{meta.height}, expected " +
$"{textureSize}x{textureSize}).", this);
return false;
}
Color32[][] canonical = ReadFolderMaps(dir);
if (canonical == null) return false;
ReseedCanonical(canonical);
@@ -141,36 +185,32 @@ public partial class MeshPainter
// baseline, clearing any prior undo/redo. Peers reset to the same baseline via the load broadcast.
ResetHistoryToCurrent(canonical);
// v3 slots also carry the per-player material scalars; apply them so the loaded look is complete.
// Legacy v2 slots have none -> leave the current emission/camo untouched. The painter is ready here
// (_setupValid), so the material-backed camo setters take effect; PlayerPaintSync replicates the
// Apply the slot's per-player material scalars so the loaded look is complete. The painter is ready
// here (_setupValid), so the material-backed setters take effect; PlayerPaintSync replicates the
// changed values to peers via their owner-authoritative NetworkVariables on its next LateUpdate.
if (scalars.has)
// A slot with no readable sidecar leaves the current emission/camo/height untouched.
if (meta != null)
{
emissionIntensity = scalars.emissionIntensity;
emissionIntensity = meta.emissionIntensity;
RefreshEmissionColor();
CamoHalfLambert = scalars.camoHalfLambert;
CamoHalfLambertBrightness = scalars.camoHalfLambertBrightness;
CamoSpecularDampen = scalars.camoSpecularDampen;
}
// v4 slots also carry the painted-height knobs; older slots (v2/v3) leave them at the current value.
if (scalars.hasHeight)
{
OcclusionThreshold = scalars.occlusionThreshold;
HeightNormalIntensity = scalars.heightNormalIntensity;
ParallaxIntensity = scalars.parallaxIntensity;
CamoHalfLambert = meta.camoHalfLambert;
CamoHalfLambertBrightness = meta.camoHalfLambertBrightness;
CamoSpecularDampen = meta.camoSpecularDampen;
OcclusionThreshold = meta.occlusionThreshold;
HeightNormalIntensity = meta.heightNormalIntensity;
ParallaxIntensity = meta.parallaxIntensity;
}
return true;
}
/// <summary>Deletes a named slot. Returns true if a file was removed.</summary>
/// <summary>Deletes a named slot folder and everything in it. Returns true if a folder was removed.</summary>
public static bool DeletePaint(string name)
{
try
{
string path = PathForName(name);
if (!File.Exists(path)) return false;
File.Delete(path);
string dir = FolderForName(name);
if (!Directory.Exists(dir)) return false;
Directory.Delete(dir, recursive: true);
return true;
}
catch (System.Exception e)
@@ -180,91 +220,115 @@ public partial class MeshPainter
}
}
/// <summary>Reads just the display-name header of a slot file (or the file name as a fallback).</summary>
private static string ReadHeaderName(string path)
/// <summary>Reads a slot's display name from its JSON sidecar (the folder name as a fallback).</summary>
private static string ReadDisplayName(string dir)
{
try
{
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read))
using (var r = new BinaryReader(fs, Encoding.UTF8))
{
if (!TryReadPaintVersion(r, out _)) return null;
return r.ReadString();
}
}
catch
{
return Path.GetFileNameWithoutExtension(path);
}
var meta = ReadMeta(dir);
return (meta != null && !string.IsNullOrEmpty(meta.name)) ? meta.name : Path.GetFileName(dir);
}
/// <summary>
/// Reads all maps from a slot file in canonical layout, or null if missing / incompatible. Per-map
/// null entries are tolerated by both backends (they fall back to the material seed).
/// </summary>
private Color32[][] ReadMaps(string path, out PaintScalars scalars)
/// <summary>Reads and parses a slot's JSON sidecar, or null if it is missing / unreadable.</summary>
private static PaintMeta ReadMeta(string dir)
{
scalars = default;
if (!File.Exists(path)) return null;
try
{
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read))
using (var r = new BinaryReader(fs, Encoding.UTF8))
{
if (!TryReadPaintVersion(r, out int ver)) return null;
r.ReadString(); // display name (header only; not needed for the pixel load)
int w = r.ReadInt32();
int h = r.ReadInt32();
int maps = r.ReadInt32();
// v3+: the per-player material scalars sit between the header and the pixel maps.
if (ver >= 3)
{
scalars.has = true;
scalars.emissionIntensity = r.ReadSingle();
scalars.camoHalfLambert = r.ReadSingle();
scalars.camoHalfLambertBrightness = r.ReadSingle();
scalars.camoSpecularDampen = r.ReadSingle();
}
// v4+: the painted-height knobs follow the camo scalars.
if (ver >= 4)
{
scalars.hasHeight = true;
scalars.occlusionThreshold = r.ReadSingle();
scalars.heightNormalIntensity = r.ReadSingle();
scalars.parallaxIntensity = r.ReadSingle();
}
// Resolution change -> ignore rather than smear mismatched data; the brush re-seeds
// from the material instead.
if (w != textureSize || h != textureSize)
{
Debug.LogWarning($"[MeshPainter] Saved paint ignored (got {w}x{h}, expected " +
$"{textureSize}x{textureSize}).", this);
return null;
}
return ReadCanonicalMaps(r, maps);
}
string metaPath = Path.Combine(dir, MetaFileName);
if (!File.Exists(metaPath)) return null;
return JsonUtility.FromJson<PaintMeta>(File.ReadAllText(metaPath));
}
catch (System.Exception e)
{
Debug.LogWarning($"[MeshPainter] Loading paint failed: {e.Message}", this);
Debug.LogWarning($"[MeshPainter] Reading paint meta in '{Path.GetFileName(dir)}' failed: {e.Message}");
return null;
}
}
/// <summary>Validates the paint-file magic and version range (legacy v2 .. current).</summary>
private static bool TryReadPaintVersion(BinaryReader r, out int ver)
/// <summary>
/// Reads a slot folder into canonical (material) layout, or null if nothing usable was read. Per-map
/// null entries are tolerated by both backends (they fall back to the material seed): a missing colour
/// image leaves that map seeded; the mask map needs all three channel images to be rebuilt.
/// </summary>
private Color32[][] ReadFolderMaps(string dir)
{
if (r.ReadUInt32() != PaintFileMagic) { ver = 0; return false; }
ver = r.ReadInt32();
return ver >= 2 && ver <= PaintFileVersion;
var result = new Color32[MAP_COUNT][];
int loaded = 0;
if (ReadMapPng(Path.Combine(dir, AlbedoFileName), out Color32[] albedo))
{ result[MAP_ALBEDO] = albedo; loaded++; }
if (ReadMapPng(Path.Combine(dir, EmissionFileName), out Color32[] emission))
{ result[MAP_EM] = emission; loaded++; }
// Recombine the split greyscale channels into the packed canonical layout
// (R=metallic, G=occlusion, B=0, A=smoothness). B is always 0 (see MeshPaintPackMS.shader).
if (ReadMapPng(Path.Combine(dir, MetallicFileName), out Color32[] metallic) &&
ReadMapPng(Path.Combine(dir, OcclusionFileName), out Color32[] occlusion) &&
ReadMapPng(Path.Combine(dir, SmoothnessFileName), out Color32[] smoothness))
{
int n = textureSize * textureSize;
var mso = new Color32[n];
for (int i = 0; i < n; i++)
mso[i] = new Color32(metallic[i].r, occlusion[i].r, 0, smoothness[i].r);
result[MAP_MSO] = mso;
loaded++;
}
return loaded > 0 ? result : null;
}
/// <summary>Writes every map's canonical Color32 bytes to the stream (shared by slot save + net snapshot).</summary>
/// <summary>
/// Writes one map as a PNG, remapping each canonical texel through <paramref name="map"/> (colour maps
/// forced opaque; a mask channel expanded to opaque greyscale). RGBA32 + opaque alpha makes the round-
/// trip byte-exact. Returns false if the source array is missing / wrong size.
/// </summary>
private bool WriteMapPng(string path, Color32[] canonical, System.Func<Color32, Color32> map)
{
int n = textureSize * textureSize;
if (canonical == null || canonical.Length < n) return false;
var pixels = new Color32[n];
for (int i = 0; i < n; i++) pixels[i] = map(canonical[i]);
var tex = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false);
try
{
tex.SetPixels32(pixels);
tex.Apply(false);
File.WriteAllBytes(path, tex.EncodeToPNG());
return true;
}
finally { Destroy(tex); }
}
/// <summary>Loads a map PNG into canonical (material) layout; false if missing / wrong resolution / undecodable.</summary>
private bool ReadMapPng(string path, out Color32[] px)
{
px = null;
if (!File.Exists(path)) return false;
var tex = new Texture2D(2, 2, TextureFormat.RGBA32, false);
try
{
if (!tex.LoadImage(File.ReadAllBytes(path))) return false;
if (tex.width != textureSize || tex.height != textureSize)
{
Debug.LogWarning($"[MeshPainter] Saved map ignored (got {tex.width}x{tex.height}, expected " +
$"{textureSize}x{textureSize}): {Path.GetFileName(path)}", this);
return false;
}
px = tex.GetPixels32();
return true;
}
catch (System.Exception e)
{
Debug.LogWarning($"[MeshPainter] Loading map '{Path.GetFileName(path)}' failed: {e.Message}", this);
return false;
}
finally { Destroy(tex); }
}
// --- Shared canonical map (de)serialization: also used by the network snapshot path (MeshPainter.Net) ---
/// <summary>Writes every map's canonical Color32 bytes to the stream (shared by the net snapshot).</summary>
private void WriteCanonicalMaps(BinaryWriter w)
{
int n = textureSize * textureSize;