Spray brush mode: bakes a procedural scattered-dot mask per stamp (random position, size, alpha within the brush disc) and paints it through the existing single-stamp pipeline; dot centers clamped to (0.5 - dotR) so the spray footprint matches the solid brush radius. Paint interval: float on MeshPainter; 0 keeps the continuous interpolated stroke, >0 emits a single timed dab every x.xx seconds. Color picker alpha: horizontal alpha bar on both HSV pickers, runtime-built in Rebind (no prefab re-bake), passed through to brushColor.a / emissionColor.a. Metallic/smoothness + occlusion paint opacity: new opacity fields applied in both CPU and GPU backends (GPU MSO draw split into MS and occlusion sub-stamps for independent opacity), with cloned alpha sliders wired into the HUD. Also includes the prefab layout tweaks and scene adjustments. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
277 lines
11 KiB
C#
277 lines
11 KiB
C#
using UnityEngine;
|
|
|
|
public partial class MeshPainter
|
|
{
|
|
// --- GPU backend ---
|
|
|
|
/// <summary>GPU backend per map: a display RenderTexture + scratch for the dilation ping-pong.</summary>
|
|
private class GpuMap
|
|
{
|
|
public RenderTexture display; // painted into (work RT for the mask map)
|
|
public RenderTexture temp; // dilation ping-pong scratch
|
|
public RenderTexture final; // MSO only: packed mask map (R=metallic, G=occlusion, A=smoothness); null otherwise
|
|
public bool drew; // anything drawn this stroke?
|
|
public bool sRGB; // sRGB color map (albedo/emission) vs linear data map (MSO)
|
|
}
|
|
|
|
public Shader brushShader; // optional; else Shader.Find("Hidden/MeshPaintBrush")
|
|
public Shader dilateShader; // optional; else Shader.Find("Hidden/MeshPaintDilate")
|
|
public Shader packMsShader; // optional; else Shader.Find("Hidden/MeshPaintPackMS")
|
|
private GpuMap[] _gpu;
|
|
private Material _brushMat;
|
|
private Material _dilateMat;
|
|
private Material _packMat;
|
|
private bool _prevSRGB;
|
|
|
|
/// <summary>
|
|
/// Sets up the GPU backend (RenderTexture set + materials per map). Returns true on
|
|
/// success; on missing shaders it falls back to the CPU backend and returns false so
|
|
/// the caller leaves _gpuActive=false (and useGpu in sync) instead of crashing later.
|
|
/// </summary>
|
|
private bool SetupGpu(Color32[][] canonical)
|
|
{
|
|
if (!brushShader) brushShader = Shader.Find("Hidden/MeshPaintBrush");
|
|
if (!dilateShader) dilateShader = Shader.Find("Hidden/MeshPaintDilate");
|
|
if (!packMsShader) packMsShader = Shader.Find("Hidden/MeshPaintPackMS");
|
|
|
|
if (!brushShader || !dilateShader || !packMsShader)
|
|
{
|
|
Debug.LogError("[MeshPainter] GPU shaders not found (Hidden/MeshPaintBrush, " +
|
|
"Hidden/MeshPaintDilate or Hidden/MeshPaintPackMS). Falling back to CPU painting.", this);
|
|
useGpu = false;
|
|
SetupCpu(canonical);
|
|
return false;
|
|
}
|
|
|
|
_brushMat = new Material(brushShader) { hideFlags = HideFlags.HideAndDontSave };
|
|
_dilateMat = new Material(dilateShader) { hideFlags = HideFlags.HideAndDontSave };
|
|
_packMat = new Material(packMsShader) { hideFlags = HideFlags.HideAndDontSave };
|
|
|
|
_gpu = new GpuMap[MAP_COUNT];
|
|
for (int mi = 0; mi < MAP_COUNT; mi++)
|
|
{
|
|
bool linear = MapIsLinear(mi);
|
|
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
|
|
};
|
|
_gpu[mi] = gm;
|
|
|
|
SeedGpuMap(mi, canonical);
|
|
|
|
// Color maps render to the display RT; the MS map exposes its packed final RT.
|
|
AssignMapTexture(mi, gm.final != null ? gm.final : gm.display);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Seeds a GPU map's display RT from canonical data (alpha = 0 = unpainted), then packs MS to final.</summary>
|
|
private void SeedGpuMap(int mi, Color32[][] canonical)
|
|
{
|
|
var src = (canonical != null && canonical[mi] != null && canonical[mi].Length == textureSize * textureSize)
|
|
? canonical[mi] : _seedCanonical[mi];
|
|
var gm = _gpu[mi];
|
|
|
|
if (mi == MAP_MSO)
|
|
{
|
|
// Canonical (R=metallic, G=occlusion, A=smoothness) -> work layout
|
|
// (R=metallic, G=smoothness, B=occlusion); alpha = coverage stays 0 (unpainted).
|
|
var work = new Color32[src.Length];
|
|
for (int i = 0; i < src.Length; i++) work[i] = new Color32(src[i].r, src[i].a, src[i].g, 0);
|
|
UploadColorsToRT(work, gm.display, paintedAlpha: false, linear: true);
|
|
PackMs(gm); // build the final mask RT once so the material shows the seeded values
|
|
}
|
|
else
|
|
{
|
|
UploadColorsToRT((Color32[])src.Clone(), gm.display, paintedAlpha: false, linear: gm.sRGB == false);
|
|
}
|
|
}
|
|
|
|
private void TeardownGpu()
|
|
{
|
|
if (_gpu != null)
|
|
{
|
|
foreach (var gm in _gpu)
|
|
{
|
|
if (gm == null) continue;
|
|
if (gm.display) { gm.display.Release(); Destroy(gm.display); }
|
|
if (gm.temp) { gm.temp.Release(); Destroy(gm.temp); }
|
|
if (gm.final) { gm.final.Release(); Destroy(gm.final); }
|
|
}
|
|
_gpu = null;
|
|
}
|
|
if (_brushMat) { Destroy(_brushMat); _brushMat = null; }
|
|
if (_dilateMat) { Destroy(_dilateMat); _dilateMat = null; }
|
|
if (_packMat) { Destroy(_packMat); _packMat = null; }
|
|
}
|
|
|
|
private RenderTexture CreateRT(string rtName, bool linear)
|
|
{
|
|
var rt = new RenderTexture(textureSize, textureSize, 0, RenderTextureFormat.ARGB32,
|
|
linear ? RenderTextureReadWrite.Linear : RenderTextureReadWrite.sRGB)
|
|
{
|
|
name = rtName,
|
|
wrapMode = TextureWrapMode.Clamp,
|
|
filterMode = FilterMode.Bilinear,
|
|
antiAliasing = 1,
|
|
useMipMap = false,
|
|
autoGenerateMips = false
|
|
};
|
|
rt.Create();
|
|
return rt;
|
|
}
|
|
|
|
/// <summary>Writes Color32 data into a RenderTexture via a temporary texture.</summary>
|
|
private void UploadColorsToRT(Color32[] colors, RenderTexture rt, bool paintedAlpha, bool linear)
|
|
{
|
|
var tmp = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, linear);
|
|
// Alpha marks painted (255) vs unpainted (0) for the dilation pass.
|
|
byte a = paintedAlpha ? (byte)255 : (byte)0;
|
|
for (int i = 0; i < colors.Length; i++) colors[i].a = a;
|
|
tmp.SetPixels32(colors);
|
|
tmp.Apply(false);
|
|
|
|
bool prev = GL.sRGBWrite;
|
|
GL.sRGBWrite = !linear;
|
|
Graphics.Blit(tmp, rt);
|
|
GL.sRGBWrite = prev;
|
|
Destroy(tmp);
|
|
}
|
|
|
|
/// <summary>Reads a RenderTexture back as Color32 (for the backend switch).</summary>
|
|
private Color32[] ReadRtPixels(RenderTexture rt, bool linear)
|
|
{
|
|
var prev = RenderTexture.active;
|
|
RenderTexture.active = rt;
|
|
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;
|
|
var px = tex.GetPixels32();
|
|
Destroy(tex);
|
|
return px;
|
|
}
|
|
|
|
private void PackMs(GpuMap gm)
|
|
{
|
|
bool prev = GL.sRGBWrite;
|
|
GL.sRGBWrite = false; // linear data, no conversion
|
|
Graphics.Blit(gm.display, gm.final, _packMat, 0);
|
|
GL.sRGBWrite = prev;
|
|
}
|
|
|
|
// --- GPU painting ---
|
|
|
|
private void BeginGpuStroke()
|
|
{
|
|
_prevSRGB = GL.sRGBWrite;
|
|
for (int mi = 0; mi < MAP_COUNT; mi++)
|
|
if (IsChannelActive(mi)) _gpu[mi].drew = false;
|
|
}
|
|
|
|
private void DrawBrushGpu(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n)
|
|
{
|
|
for (int mi = 0; mi < MAP_COUNT; mi++)
|
|
{
|
|
if (!IsChannelActive(mi)) continue;
|
|
var gm = _gpu[mi];
|
|
|
|
Graphics.SetRenderTarget(gm.display);
|
|
GL.sRGBWrite = gm.sRGB; // sRGB color map vs linear data map (MS)
|
|
|
|
// Stamp placement is shared by every draw on this map.
|
|
_brushMat.SetTexture(MaskTexId, _stampMask);
|
|
_brushMat.SetVector(BrushCenterId, pLocal);
|
|
_brushMat.SetFloat(BrushRadiusId, radiusLocal);
|
|
_brushMat.SetVector(BrushUId, u);
|
|
_brushMat.SetVector(BrushVId, v);
|
|
_brushMat.SetVector(BrushNormalId, n);
|
|
|
|
if (mi == MAP_MSO)
|
|
{
|
|
// Metallic/smoothness and occlusion share one texture but paint with their OWN
|
|
// opacity, so each is a separate stamp (own _BrushColor.a + per-channel write
|
|
// mask). Untouched channels keep their value while coverage (A) accumulates.
|
|
if (paintMetallicSmoothness)
|
|
StampMso(Mathf.Clamp01(metallicSmoothnessOpacity), 13f); // R|G|A (skip B = occlusion)
|
|
if (paintOcclusion)
|
|
StampMso(Mathf.Clamp01(occlusionOpacity), 3f); // B|A (occlusion only)
|
|
}
|
|
else
|
|
{
|
|
// Albedo / emission: full RGBA, opacity carried by the colour's own alpha.
|
|
_brushMat.SetColor(BrushColorId, mi == MAP_ALBEDO ? brushColor : emissionColor);
|
|
_brushMat.SetFloat(ColorWriteMaskId, 15f);
|
|
DrawStamp();
|
|
}
|
|
|
|
gm.drew = true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Issues one mask-map stamp: raw (metallic, smoothness, occlusion) data with the given paint
|
|
/// opacity in alpha and a per-channel colour write mask, so metallic/smoothness and occlusion
|
|
/// stay independently paintable with their own opacity. EncodeData pre-compensates the Color
|
|
/// property's sRGB->linear upload so the values round-trip exactly (CPU parity); alpha is not converted.
|
|
/// </summary>
|
|
private void StampMso(float opacity, float colorMask)
|
|
{
|
|
_brushMat.SetVector(BrushColorId, new Vector4(
|
|
EncodeData(metallic), EncodeData(smoothness), EncodeData(occlusion), opacity));
|
|
_brushMat.SetFloat(ColorWriteMaskId, colorMask);
|
|
DrawStamp();
|
|
}
|
|
|
|
/// <summary>Draws the baked mesh once per sub-mesh with the current brush pass (UV-space rasterization).</summary>
|
|
private void DrawStamp()
|
|
{
|
|
if (!_brushMat.SetPass(0)) return;
|
|
int sub = _bakedMesh.subMeshCount;
|
|
for (int i = 0; i < sub; i++)
|
|
Graphics.DrawMeshNow(_bakedMesh, Matrix4x4.identity, i);
|
|
}
|
|
|
|
private void EndGpuStroke()
|
|
{
|
|
Graphics.SetRenderTarget(null);
|
|
|
|
for (int mi = 0; mi < MAP_COUNT; mi++)
|
|
{
|
|
if (!IsChannelActive(mi)) continue;
|
|
var gm = _gpu[mi];
|
|
|
|
// Edge padding against UV seams: dilate the display RT into temp, then swap.
|
|
if (gm.drew && paddingTexels > 0)
|
|
{
|
|
GL.sRGBWrite = gm.sRGB;
|
|
_dilateMat.SetInt(PadId, paddingTexels);
|
|
Graphics.Blit(gm.display, gm.temp, _dilateMat, 0);
|
|
|
|
var swap = gm.display; gm.display = gm.temp; gm.temp = swap;
|
|
if (gm.final == null) AssignMapTexture(mi, gm.display);
|
|
}
|
|
|
|
// MS: pack the work layout (R=metallic, G=smoothness) into the URP final layout.
|
|
if (gm.final != null) PackMs(gm);
|
|
}
|
|
|
|
GL.sRGBWrite = _prevSRGB;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pre-compensates a raw data value (0..1) for the brush's Color property, which Unity
|
|
/// converts sRGB->linear on upload in a linear project. Applying the inverse here makes
|
|
/// the value round-trip exactly (so metallic/smoothness/occlusion are stored faithfully
|
|
/// and match the CPU byte writes). In gamma color space no conversion happens, so pass through.
|
|
/// </summary>
|
|
private static float EncodeData(float v)
|
|
{
|
|
v = Mathf.Clamp01(v);
|
|
return QualitySettings.activeColorSpace == ColorSpace.Linear ? Mathf.LinearToGammaSpace(v) : v;
|
|
}
|
|
}
|