Deduplication pass (44 extractions): - New SmoothedLook: shared smoothed yaw/pitch look tick + view seeding (FirstPersonController, FreeCamController) - New CharacterTeleport: disable-CC-around-teleport idiom (rescue, owner position apply, free-cam sync) - New GeometryUtil: RescaleToUnitScale + TransformBounds (concealment meter/volume, skinned collider baker) - Paint family: canonical map read/write shared between Net and Persistence, snapshot decode, backend stroke dispatch, RebuildBackend, ResolveSeed, chunked RPC send/gate/copy, CamoKnobs/HeightKnobs aggregates, SanitizeFileKey, NewMaskTexture, AdoptAlbedo, FindSlider - Player/modes: GameMode SetPlayerControl/SetPainting/SetOrbitInputEnabled, InputUtil.GameplayKeyPressed, NearestClimbHit, ComputeXRay delegates to ComputeResolutionFreeze, FingerIK TryInPlaneAngle - Match/misc: UnhookSessionCallbacks, SendEmptyNamedMessage, SwapScenes, RunSessionOpAsync, CovarianceEigen, CollectClusterPoints, MatchHud GUIStyle cache, HsvColorPicker rehook, VoxelCenter Documentation pass: remove 6 triplicated doc blocks and 2 restating inline comments left after the previous reduction. Behavior-neutral; hot-path rasterizer loops intentionally untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
310 lines
13 KiB
C#
310 lines
13 KiB
C#
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Text;
|
|
using UnityEngine;
|
|
|
|
public partial class MeshPainter
|
|
{
|
|
// --- Persistence: named paint slots, saved/loaded explicitly from the HUD ---
|
|
//
|
|
// The painter no longer auto-loads on start or auto-saves on quit; painting is volatile until the
|
|
// 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.
|
|
|
|
// 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";
|
|
|
|
/// <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.
|
|
/// </summary>
|
|
private struct PaintScalars
|
|
{
|
|
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 float emissionIntensity;
|
|
public float camoHalfLambert, camoHalfLambertBrightness, camoSpecularDampen;
|
|
public float occlusionThreshold, heightNormalIntensity, parallaxIntensity;
|
|
}
|
|
|
|
private static string PaintLibraryDir =>
|
|
Path.Combine(Application.persistentDataPath, "MeshPaint", "Saved");
|
|
|
|
/// <summary>Filesystem-safe key: invalid filename characters and spaces become underscores.</summary>
|
|
internal static string SanitizeFileKey(string s)
|
|
{
|
|
foreach (char c in Path.GetInvalidFileNameChars()) s = s.Replace(c, '_');
|
|
return s.Replace(' ', '_');
|
|
}
|
|
|
|
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>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));
|
|
|
|
/// <summary>Display names of every saved slot, sorted alphabetically. Each name is read from the file header.</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))
|
|
{
|
|
string n = ReadHeaderName(path);
|
|
if (!string.IsNullOrEmpty(n)) names.Add(n);
|
|
}
|
|
}
|
|
catch (System.Exception e)
|
|
{
|
|
Debug.LogWarning($"[MeshPainter] Listing saved paintings failed: {e.Message}");
|
|
}
|
|
names.Sort(System.StringComparer.CurrentCultureIgnoreCase);
|
|
return names;
|
|
}
|
|
|
|
/// <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"/>
|
|
/// is true; otherwise the call fails rather than clobbering it.
|
|
/// </summary>
|
|
public bool SavePaintAs(string name, bool overwrite = false)
|
|
{
|
|
if (!_setupValid || string.IsNullOrWhiteSpace(name)) return false;
|
|
name = name.Trim();
|
|
if (!overwrite && PaintExists(name)) return false;
|
|
|
|
try
|
|
{
|
|
Directory.CreateDirectory(PaintLibraryDir);
|
|
|
|
using (var fs = new FileStream(PathForName(name), FileMode.Create, FileAccess.Write))
|
|
using (var w = new BinaryWriter(fs))
|
|
{
|
|
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);
|
|
}
|
|
return true;
|
|
}
|
|
catch (System.Exception e)
|
|
{
|
|
Debug.LogWarning($"[MeshPainter] Saving paint '{name}' failed: {e.Message}", this);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads a named slot and re-seeds the active backend with it, replacing the current painting.
|
|
/// Returns false if the slot is missing or incompatible (the current painting is left untouched).
|
|
/// </summary>
|
|
public bool LoadPaintByName(string name)
|
|
{
|
|
if (!_setupValid) return false;
|
|
|
|
Color32[][] canonical = ReadMaps(PathForName(name), out PaintScalars scalars);
|
|
if (canonical == null) return false;
|
|
|
|
ReseedCanonical(canonical);
|
|
|
|
// A load replaces the whole painting and is NOT undoable: the loaded maps become the new history
|
|
// 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
|
|
// changed values to peers via their owner-authoritative NetworkVariables on its next LateUpdate.
|
|
if (scalars.has)
|
|
{
|
|
emissionIntensity = scalars.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;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Deletes a named slot. Returns true if a file was removed.</summary>
|
|
public static bool DeletePaint(string name)
|
|
{
|
|
try
|
|
{
|
|
string path = PathForName(name);
|
|
if (!File.Exists(path)) return false;
|
|
File.Delete(path);
|
|
return true;
|
|
}
|
|
catch (System.Exception e)
|
|
{
|
|
Debug.LogWarning($"[MeshPainter] Deleting paint '{name}' failed: {e.Message}");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
catch (System.Exception e)
|
|
{
|
|
Debug.LogWarning($"[MeshPainter] Loading paint failed: {e.Message}", this);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>Validates the paint-file magic and version range (legacy v2 .. current).</summary>
|
|
private static bool TryReadPaintVersion(BinaryReader r, out int ver)
|
|
{
|
|
if (r.ReadUInt32() != PaintFileMagic) { ver = 0; return false; }
|
|
ver = r.ReadInt32();
|
|
return ver >= 2 && ver <= PaintFileVersion;
|
|
}
|
|
|
|
/// <summary>Writes every map's canonical Color32 bytes to the stream (shared by slot save + net snapshot).</summary>
|
|
private void WriteCanonicalMaps(BinaryWriter w)
|
|
{
|
|
int n = textureSize * textureSize;
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Reads up to MAP_COUNT canonical map blocks; a truncated stream leaves the remaining maps null (seeded).</summary>
|
|
private Color32[][] ReadCanonicalMaps(BinaryReader r, int mapsInFile)
|
|
{
|
|
int n = textureSize * textureSize;
|
|
var result = new Color32[MAP_COUNT][];
|
|
int load = Mathf.Min(mapsInFile, MAP_COUNT);
|
|
for (int mi = 0; mi < load; mi++)
|
|
{
|
|
byte[] raw = r.ReadBytes(n * 4);
|
|
if (raw.Length != n * 4) break; // truncated -> remaining maps stay at the material seed
|
|
|
|
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;
|
|
}
|
|
}
|