Add MeshPainter.Persistence.cs: auto-save the three canonical paint maps (albedo, MSO, emission) as PNGs under persistentDataPath on quit/play-stop and auto-load them as the backend seed on start, so painting survives sessions. Reuses the existing ReadCanonical/SetupBackend seed round-trip; no new color-space code. Add PaintHud.Persistence.cs: auto-save draggable panel positions and the widget-bound painter values to a small JSON layout file, restored on start via the painter -> SyncFromPainter -> widgets path. Complete SyncFromPainter to also cover the spray sliders.
165 lines
6.2 KiB
C#
165 lines
6.2 KiB
C#
using System.IO;
|
|
using UnityEngine;
|
|
using UnityEngine.SceneManagement;
|
|
|
|
public partial class MeshPainter
|
|
{
|
|
// --- Persistence: save the painted maps to disk and reload them next session ---
|
|
|
|
[Header("Persistence")]
|
|
[Tooltip("Save the painted maps to disk on quit / play-stop and reload them on the next " +
|
|
"start, so the painting persists across sessions. Off = paint is volatile (as before).")]
|
|
public bool persistPaint = true;
|
|
|
|
[Tooltip("Save file identifier. Empty = derived from the active scene + object name. Set " +
|
|
"explicitly to keep a save stable across object renames or to share one save.")]
|
|
public string saveId = "";
|
|
|
|
// Raw, bit-exact map dump: 'CHMP' magic + version + 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 = 1;
|
|
|
|
private bool _savedOnTeardown; // guards against a double save across OnApplicationQuit + OnDestroy
|
|
|
|
private static string PaintSaveDir => Path.Combine(Application.persistentDataPath, "MeshPaint");
|
|
|
|
private string PaintSavePath()
|
|
{
|
|
string key = string.IsNullOrEmpty(saveId)
|
|
? SanitizeKey(SceneManager.GetActiveScene().name + "_" + gameObject.name)
|
|
: SanitizeKey(saveId);
|
|
return Path.Combine(PaintSaveDir, key + ".paint");
|
|
}
|
|
|
|
private static string SanitizeKey(string s)
|
|
{
|
|
foreach (char c in Path.GetInvalidFileNameChars()) s = s.Replace(c, '_');
|
|
return s.Replace(' ', '_');
|
|
}
|
|
|
|
/// <summary>Reads all maps from the active backend (canonical layout) and writes them to the save file.</summary>
|
|
public void SavePaint()
|
|
{
|
|
if (!persistPaint || !_setupValid) return;
|
|
|
|
try
|
|
{
|
|
Directory.CreateDirectory(PaintSaveDir);
|
|
int n = textureSize * textureSize;
|
|
|
|
using (var fs = new FileStream(PaintSavePath(), FileMode.Create, FileAccess.Write))
|
|
using (var w = new BinaryWriter(fs))
|
|
{
|
|
w.Write(PaintFileMagic);
|
|
w.Write(PaintFileVersion);
|
|
w.Write(textureSize);
|
|
w.Write(textureSize);
|
|
w.Write(MAP_COUNT);
|
|
|
|
var bytes = new byte[n * 4];
|
|
for (int mi = 0; mi < MAP_COUNT; mi++)
|
|
{
|
|
Color32[] px = ReadCanonical(mi);
|
|
int count = Mathf.Min(n, px.Length);
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
int o = i * 4;
|
|
bytes[o] = px[i].r;
|
|
bytes[o + 1] = px[i].g;
|
|
bytes[o + 2] = px[i].b;
|
|
bytes[o + 3] = px[i].a;
|
|
}
|
|
w.Write(bytes, 0, count * 4);
|
|
}
|
|
}
|
|
}
|
|
catch (System.Exception e)
|
|
{
|
|
Debug.LogWarning($"[MeshPainter] Saving paint failed: {e.Message}", this);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads the saved maps in canonical layout for seeding, or null if none / incompatible.
|
|
/// Per-map null entries are tolerated by both backends (they fall back to the material seed).
|
|
/// </summary>
|
|
private Color32[][] LoadPaintMaps()
|
|
{
|
|
if (!persistPaint) return null;
|
|
|
|
string path = PaintSavePath();
|
|
if (!File.Exists(path)) return null;
|
|
|
|
try
|
|
{
|
|
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read))
|
|
using (var r = new BinaryReader(fs))
|
|
{
|
|
if (r.ReadUInt32() != PaintFileMagic) return null;
|
|
int ver = r.ReadInt32();
|
|
int w = r.ReadInt32();
|
|
int h = r.ReadInt32();
|
|
int maps = r.ReadInt32();
|
|
|
|
// Resolution / version change -> ignore the save rather than smear mismatched
|
|
// data; the brush re-seeds from the material instead.
|
|
if (ver != PaintFileVersion || w != textureSize || h != textureSize)
|
|
{
|
|
Debug.LogWarning($"[MeshPainter] Saved paint ignored (got {w}x{h} v{ver}, expected " +
|
|
$"{textureSize}x{textureSize} v{PaintFileVersion}). Re-seeding from material.", this);
|
|
return null;
|
|
}
|
|
|
|
int n = textureSize * textureSize;
|
|
var result = new Color32[MAP_COUNT][];
|
|
int load = Mathf.Min(maps, MAP_COUNT);
|
|
for (int mi = 0; mi < load; mi++)
|
|
{
|
|
byte[] raw = r.ReadBytes(n * 4);
|
|
if (raw.Length != n * 4) break; // truncated file -> remaining maps stay seeded
|
|
|
|
var px = new Color32[n];
|
|
for (int i = 0; i < n; i++)
|
|
{
|
|
int o = i * 4;
|
|
px[i] = new Color32(raw[o], raw[o + 1], raw[o + 2], raw[o + 3]);
|
|
}
|
|
result[mi] = px;
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
catch (System.Exception e)
|
|
{
|
|
Debug.LogWarning($"[MeshPainter] Loading paint failed: {e.Message}", this);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>Saves once during teardown (OnApplicationQuit and OnDestroy both call this; only the first writes).</summary>
|
|
private void SavePaintOnTeardown()
|
|
{
|
|
if (_savedOnTeardown) return;
|
|
_savedOnTeardown = true;
|
|
SavePaint();
|
|
}
|
|
|
|
/// <summary>Deletes this painter's save file (next start re-seeds from the material). Editor convenience.</summary>
|
|
[ContextMenu("Clear Saved Paint")]
|
|
public void ClearSavedPaint()
|
|
{
|
|
try
|
|
{
|
|
string path = PaintSavePath();
|
|
if (File.Exists(path)) File.Delete(path);
|
|
Debug.Log($"[MeshPainter] Cleared saved paint: {path}", this);
|
|
}
|
|
catch (System.Exception e)
|
|
{
|
|
Debug.LogWarning($"[MeshPainter] Clearing saved paint failed: {e.Message}", this);
|
|
}
|
|
}
|
|
}
|