diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Persistence.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Persistence.cs index 7f1c154..0591cf7 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Persistence.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Persistence.cs @@ -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//albedo.png sRGB colour (the visible camo pattern) + // Paintings//emission.png sRGB colour + // Paintings//metallic.png greyscale = canonical MSO.R + // Paintings//occlusion.png greyscale = canonical MSO.G + // Paintings//smoothness.png greyscale = canonical MSO.A + // Paintings//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"; /// - /// 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. /// - 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"); /// Filesystem-safe key: invalid filename characters and spaces become underscores. 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); - /// Disk path for a slot. The display name lives inside the file; the file name is a sanitized key. - private static string PathForName(string name) => - Path.Combine(PaintLibraryDir, SanitizeKey(name) + PaintFileExt); + /// Per-painting folder. The display name lives in the JSON; the folder name is a sanitized key. + private static string FolderForName(string name) => + Path.Combine(PaintLibraryDir, SanitizeKey(name)); /// True if a slot with this name already exists (used to block overwriting on save). public static bool PaintExists(string name) => - !string.IsNullOrWhiteSpace(name) && File.Exists(PathForName(name)); + !string.IsNullOrWhiteSpace(name) && Directory.Exists(FolderForName(name)); - /// Display names of every saved slot, sorted alphabetically. Each name is read from the file header. + /// Display names of every saved slot, sorted alphabetically. Each name is read from its JSON sidecar. public static List ListSavedPaintings() { var names = new List(); 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 } /// - /// 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 + /// 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 /// is true; otherwise the call fails rather than clobbering it. /// 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; } - /// Deletes a named slot. Returns true if a file was removed. + /// Deletes a named slot folder and everything in it. Returns true if a folder was removed. 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 } } - /// Reads just the display-name header of a slot file (or the file name as a fallback). - private static string ReadHeaderName(string path) + /// Reads a slot's display name from its JSON sidecar (the folder name as a fallback). + 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); } - /// - /// 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). - /// - private Color32[][] ReadMaps(string path, out PaintScalars scalars) + /// Reads and parses a slot's JSON sidecar, or null if it is missing / unreadable. + 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(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; } } - /// Validates the paint-file magic and version range (legacy v2 .. current). - private static bool TryReadPaintVersion(BinaryReader r, out int ver) + /// + /// 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. + /// + 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; } - /// Writes every map's canonical Color32 bytes to the stream (shared by slot save + net snapshot). + /// + /// Writes one map as a PNG, remapping each canonical texel through (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. + /// + private bool WriteMapPng(string path, Color32[] canonical, System.Func 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); } + } + + /// Loads a map PNG into canonical (material) layout; false if missing / wrong resolution / undecodable. + 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) --- + + /// Writes every map's canonical Color32 bytes to the stream (shared by the net snapshot). private void WriteCanonicalMaps(BinaryWriter w) { int n = textureSize * textureSize;