diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Persistence.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Persistence.cs index 01747e0..ad7d61d 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Persistence.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Persistence.cs @@ -176,15 +176,9 @@ public partial class MeshPainter 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; - } + // No resolution gate: a slot saved at any size is bilinearly resampled to this avatar's textureSize + // per map (see ReadMapPng), so any painting loads onto any avatar regardless of the size it was + // saved at. meta.width/height are kept only as informational metadata. Color32[][] canonical = ReadFolderMaps(dir); if (canonical == null) return false; @@ -314,7 +308,11 @@ public partial class MeshPainter finally { Destroy(tex); } } - /// Loads a map PNG into canonical (material) layout; false if missing / wrong resolution / undecodable. + /// + /// Loads a map PNG into canonical (material) layout; false only if the file is missing / undecodable. + /// A PNG whose resolution differs from the current textureSize is bilinearly resampled up/down to + /// textureSize (see ResampleBilinear), so a slot saved at any resolution loads onto any avatar. + /// private bool ReadMapPng(string path, out Color32[] px) { px = null; @@ -324,13 +322,9 @@ public partial class MeshPainter 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(); + px = (tex.width == textureSize && tex.height == textureSize) + ? tex.GetPixels32() + : ResampleBilinear(tex.GetPixels32(), tex.width, tex.height, textureSize, textureSize); return true; } catch (System.Exception e) @@ -341,6 +335,51 @@ public partial class MeshPainter finally { Destroy(tex); } } + /// + /// Bilinearly resamples a raw Color32 byte grid to a new resolution. Interpolation runs on the stored + /// bytes with no gamma/linear conversion, matching how the maps are consumed downstream: colour maps + /// (albedo/emission) rescale smoothly, and the greyscale mask channels + the fur length channel + /// interpolate as linear scalars. Clamped edge sampling; used to load a slot saved at a different + /// resolution than the current avatar's textureSize. + /// + private static Color32[] ResampleBilinear(Color32[] src, int srcW, int srcH, int dstW, int dstH) + { + var dst = new Color32[dstW * dstH]; + for (int y = 0; y < dstH; y++) + { + float sy = (y + 0.5f) * srcH / dstH - 0.5f; + int y0 = Mathf.Clamp(Mathf.FloorToInt(sy), 0, srcH - 1); + int y1 = Mathf.Min(y0 + 1, srcH - 1); + float fy = Mathf.Clamp01(sy - y0); + for (int x = 0; x < dstW; x++) + { + float sx = (x + 0.5f) * srcW / dstW - 0.5f; + int x0 = Mathf.Clamp(Mathf.FloorToInt(sx), 0, srcW - 1); + int x1 = Mathf.Min(x0 + 1, srcW - 1); + float fx = Mathf.Clamp01(sx - x0); + + int r0 = y0 * srcW, r1 = y1 * srcW; + Color32 c00 = src[r0 + x0], c10 = src[r0 + x1]; + Color32 c01 = src[r1 + x0], c11 = src[r1 + x1]; + + dst[y * dstW + x] = new Color32( + Bilerp(c00.r, c10.r, c01.r, c11.r, fx, fy), + Bilerp(c00.g, c10.g, c01.g, c11.g, fx, fy), + Bilerp(c00.b, c10.b, c01.b, c11.b, fx, fy), + Bilerp(c00.a, c10.a, c01.a, c11.a, fx, fy)); + } + } + return dst; + } + + /// Bilinear blend of four byte samples (rounded, clamped to 0..255). + private static byte Bilerp(byte c00, byte c10, byte c01, byte c11, float fx, float fy) + { + float top = c00 + (c10 - c00) * fx; + float bot = c01 + (c11 - c01) * fx; + return (byte)Mathf.Clamp(Mathf.RoundToInt(top + (bot - top) * fy), 0, 255); + } + // --- 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).