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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-07-03 10:32:29 +02:00
1 parent f8b6524515
commit fbbb4f7419
1 file changed
+38 -13
@@ -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);
}
/// <summary>Reads a RenderTexture back as Color32 (for the backend switch).</summary>
/// <summary>
/// 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.
/// </summary>
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);