From fbbb4f741995c005e03a1e3dafcdf3040d654436 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Fri, 3 Jul 2026 10:32:29 +0200 Subject: [PATCH] Fix low-opacity paint never reaching the target colour Paint RTs were ARGB32 (8 bpp). Repeated source-over at low alpha rounds back to the previous value once the per-stroke decrement dst*alpha drops below half an LSB, so it freezes at a grey floor (~0.5/alpha in 255ths) - e.g. black at low opacity never reaches 0. Switch the accumulation buffers to ARGBHalf (16-bit linear). The sRGB round-trip stays at the byte boundary (seed decodes on upload, ReadRtPixels re-encodes on readback), so canonical bytes and CPU/GPU parity are unchanged - only the accumulation gained bits. Co-Authored-By: Claude Opus 4.8 --- .../Components/MeshPainter/MeshPainter.Gpu.cs | 51 ++++++++++++++----- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs index 98474f1..e1aa12e 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs @@ -54,9 +54,9 @@ public partial class MeshPainter var gm = new GpuMap { sRGB = !linear, - display = CreateRT("MeshPainter_DisplayRT_" + mi, linear), - temp = CreateRT("MeshPainter_TempRT_" + mi, linear), - final = (mi == MAP_MSO) ? CreateRT("MeshPainter_FinalRT_" + mi, linear) : null + display = CreateRT("MeshPainter_DisplayRT_" + mi), + temp = CreateRT("MeshPainter_TempRT_" + mi), + final = (mi == MAP_MSO) ? CreateRT("MeshPainter_FinalRT_" + mi) : null }; _gpu[mi] = gm; @@ -134,10 +134,17 @@ public partial class MeshPainter if (_packMat) { Destroy(_packMat); _packMat = null; } } - private RenderTexture CreateRT(string rtName, bool linear) + private RenderTexture CreateRT(string rtName) { - var rt = new RenderTexture(textureSize, textureSize, 0, RenderTextureFormat.ARGB32, - linear ? RenderTextureReadWrite.Linear : RenderTextureReadWrite.sRGB) + // 16-bit half-float, NOT ARGB32: repeated low-opacity source-over only converges to the + // target colour if the accumulation buffer has sub-8-bit precision. At 8 bpp the per-stroke + // decrement dst*alpha rounds back to dst once it drops below half an LSB, so painting e.g. + // black at low opacity freezes at a grey floor (~0.5/alpha in 255ths) and never reaches 0. + // Half-float removes that floor. Storage is linear; the sRGB round-trip for colour maps + // happens at the byte boundary (seed decodes on upload, ReadRtPixels re-encodes on readback), + // so canonical bytes and CPU/GPU parity are unchanged - only the accumulation gained bits. + var rt = new RenderTexture(textureSize, textureSize, 0, RenderTextureFormat.ARGBHalf, + RenderTextureReadWrite.Linear) { name = rtName, wrapMode = TextureWrapMode.Clamp, @@ -161,21 +168,39 @@ public partial class MeshPainter tmp.Apply(false); bool prev = GL.sRGBWrite; - GL.sRGBWrite = !linear; + // Paint RTs store LINEAR half-float, so never sRGB-encode on write. The source tex's own + // colour space (sRGB for colour maps) still decodes to linear when the blit samples it. + GL.sRGBWrite = false; Graphics.Blit(tmp, rt); GL.sRGBWrite = prev; Destroy(tmp); } - /// Reads a RenderTexture back as Color32 (for the backend switch). + /// + /// Reads a paint RT back into 8-bit canonical bytes. The paint RTs are linear 16-bit half (for + /// blend precision), so this first blits down into a throwaway 8-bit RT using the SAME encoding + /// the old ARGB32 RTs had - sRGB for colour maps (GL.sRGBWrite re-encodes), linear for data maps - + /// then reads that. The resulting bytes are bit-for-bit what the old 8-bit path (and the CPU + /// backend) produced for the same colour; only the accumulation gained precision. + /// private Color32[] ReadRtPixels(RenderTexture rt, bool linear) { - var prev = RenderTexture.active; - RenderTexture.active = rt; + var prevActive = RenderTexture.active; + + var tmp = RenderTexture.GetTemporary(textureSize, textureSize, 0, RenderTextureFormat.ARGB32, + linear ? RenderTextureReadWrite.Linear : RenderTextureReadWrite.sRGB); + bool prevSRGB = GL.sRGBWrite; + GL.sRGBWrite = !linear; // colour maps: re-encode linear->sRGB on write; data maps: linear + Graphics.Blit(rt, tmp); + GL.sRGBWrite = prevSRGB; + + RenderTexture.active = tmp; var tex = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, linear); tex.ReadPixels(new Rect(0, 0, textureSize, textureSize), 0, 0, false); tex.Apply(false); - RenderTexture.active = prev; + + RenderTexture.active = prevActive; + RenderTexture.ReleaseTemporary(tmp); var px = tex.GetPixels32(); Destroy(tex); return px; @@ -206,7 +231,7 @@ public partial class MeshPainter var gm = _gpu[mi]; Graphics.SetRenderTarget(gm.display); - GL.sRGBWrite = gm.sRGB; // sRGB color map vs linear data map (MS) + GL.sRGBWrite = false; // linear half RTs: blend natively in linear, no sRGB encode // Stamp placement is shared by every draw on this map. _brushMat.SetTexture(MaskTexId, _stampMask); @@ -279,7 +304,7 @@ public partial class MeshPainter // Edge padding against UV seams: dilate the display RT into temp, then swap. if (gm.drew && paddingTexels > 0) { - GL.sRGBWrite = gm.sRGB; + GL.sRGBWrite = false; _dilateMat.SetInt(PadId, paddingTexels); Graphics.Blit(gm.display, gm.temp, _dilateMat, 0);