Files
chameleon/Assets/Scripts/MeshPainter.cs
T
admin c67d519f68 Add square mask brush with opacity blending to mesh painter
Replace the 3D sphere brush with a square stamp in the surface tangent
plane whose shape and opacity come from a grayscale/alpha mask. Masks
are assignable via an inspector list with a live-switchable active index;
an empty list falls back to a procedurally generated circle mask that
reproduces the previous round brush.

- Mask drives opacity (luma * alpha * brushOpacity) via source-over blend
  instead of a hard threshold cut; gray 0.5 = 50%.
- GPU and CPU paths stay identical; non-readable textures are supported on
  the CPU via GPU blit-readback.
- Dilate pass treats any alpha > 0 as painted so soft edges survive seam
  padding.
- Brush alignment enum (Camera/World) plus manual rotation; tangent frame
  handedness fixed so stamps read upright and unmirrored in both modes.
2026-06-23 11:28:19 +02:00

1002 lines
38 KiB
C#

using System.Collections.Generic;
using UnityEngine;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
/// <summary>
/// Live mesh painting in Play mode: paints the brush color onto the mesh texture
/// with the mouse. Two backends (switchable live via <see cref="useGpu"/>): GPU
/// shader painting into a RenderTexture (fast, default) and CPU rasterization
/// (slower, identical result). Both paint seamlessly across UV seams because the
/// brush is evaluated in 3D local space around the hit point, capturing triangles
/// on both sides of a seam at once.
///
/// The brush is a square stamp in the surface tangent plane whose shape is given by
/// a grayscale mask texture (white = paint, black = skip, hard threshold). A list of
/// masks is assignable in the Inspector; if it is empty a procedural circle mask is
/// used, which reproduces the classic round brush. Attach to the GameObject you want
/// to paint.
/// </summary>
[DisallowMultipleComponent]
public class MeshPainter : MonoBehaviour
{
/// <summary>How the square stamp is oriented around the surface normal.</summary>
public enum BrushAlignment
{
/// <summary>Stamp "up"/"right" follow the camera (projected onto the surface) - natural decal feel.</summary>
Camera,
/// <summary>Stamp "up" = world up (projected onto the surface) - stable, does not rotate with the camera.</summary>
World
}
/// <summary>When false, mouse painting is ignored. Toggled by the GameModeManager.</summary>
public bool PaintingEnabled { get; set; } = true;
[Header("Backend")]
[Tooltip("ON = GPU painting (fast), OFF = CPU painting. Can be toggled live in Play mode.")]
public bool useGpu = true;
[Header("Brush")]
[Tooltip("Color used for painting.")]
public Color brushColor = Color.red;
[Tooltip("Brush half-extent in texels (= half the square side). Converted into a 3D size per hit.")]
[Range(1, 256)]
public int brushSize = 16;
[Tooltip("Brush masks: the mask value (luminance x alpha) is the per-texel opacity " +
"(gray = 50%). Empty = built-in circle (= classic round brush). Import grayscale " +
"masks as Non-Color so midtones map 1:1; alpha-based masks (e.g. soft particles) work too.")]
public List<Texture2D> brushMasks = new List<Texture2D>();
[Tooltip("Index of the active mask in the list above. Out of range = built-in circle.")]
public int activeBrushIndex = 0;
[Tooltip("How the square stamp is oriented around the surface normal (only matters for non-symmetric masks).")]
public BrushAlignment brushAlignment = BrushAlignment.Camera;
[Tooltip("Extra rotation of the stamp around the surface normal, in degrees.")]
[Range(-180f, 180f)]
public float brushRotation = 0f;
[Tooltip("Global opacity multiplier on top of the mask (1 = mask opacity as-is, lower = fainter).")]
[Range(0f, 1f)]
public float brushOpacity = 1f;
[Header("Canvas")]
[Tooltip("Resolution of the paint texture (square).")]
public int textureSize = 1024;
[Tooltip("Base color of the texture at startup (if none exists).")]
public Color baseColor = Color.white;
[Tooltip("Edge padding (texels) bled beyond UV island edges so bilinear sampling does not pull in the base color. 0 = off.")]
[Range(0, 16)]
public int paddingTexels = 4;
[Header("Setup")]
[Tooltip("Camera for the mouse ray. Empty = Camera.main.")]
public Camera paintCamera;
[Tooltip("Only for animated meshes: re-bake the collider every frame (expensive!).")]
public bool updateColliderEveryFrame = false;
// --- shared references ---
private Renderer _renderer;
private SkinnedMeshRenderer _skinned;
private MeshCollider _collider;
private Mesh _bakedMesh;
private Material _materialInstance;
// Mesh caches (in local/collider space).
private Vector3[] _verts;
private int[] _tris;
private Vector2[] _uvs;
private Vector3[] _triCenter; // centroid per triangle (CPU culling)
private float[] _triRadius; // circumscribed radius per triangle (CPU culling)
private float _avgUvToLocal = 1f; // average UV unit -> local unit
private bool _hasLastScreen;
private Vector2 _lastScreen;
private bool _gpuActive;
private bool _setupValid;
// --- CPU backend ---
private Texture2D _paintTexture;
private Color32[] _pixels;
private bool[] _painted; // true = painted by the brush (dilation source)
private int[] _fillStamp; // per-call marker for padding texels
private int _dilateStamp; // bumped per call so old padding is not a source
private readonly List<int> _dilateScratch = new List<int>(512);
private readonly RaycastHit[] _rayHits = new RaycastHit[32];
private int _dx0, _dy0, _dx1, _dy1; // dirty rectangle (CPU upload)
// --- GPU backend ---
public Shader brushShader; // optional; else Shader.Find("Hidden/MeshPaintBrush")
public Shader dilateShader; // optional; else Shader.Find("Hidden/MeshPaintDilate")
private Material _brushMat;
private Material _dilateMat;
private RenderTexture _displayRT;
private RenderTexture _tempRT; // scratch for the dilation blit (ping-pong)
private bool _drewThisStroke;
private bool _prevSRGB;
// --- brush mask (shared) ---
private Texture2D _proceduralMask; // built-in circle, used when the list is empty/invalid
private float[] _proceduralOpacity; // cached opacity (luma*alpha) of the procedural mask (CPU)
private int _procW, _procH;
private Texture2D _resolvedMask; // currently active mask (user or procedural)
private float[] _maskOpacity; // opacity (luma*alpha) of the resolved mask (CPU sampling)
private int _maskW, _maskH;
private const float ScreenStepPixels = 5f; // interpolation step along mouse movement
private const int MaxStepsPerFrame = 256; // raycast safeguard for fast movement
private const float SquareReach = 1.7321f; // sqrt(3): max 3D distance from center to a painted box texel (CPU culling)
private static readonly int BaseMapId = Shader.PropertyToID("_BaseMap");
private static readonly int MainTexId = Shader.PropertyToID("_MainTex");
private static readonly int BaseColorId = Shader.PropertyToID("_BaseColor");
private static readonly int ColorId = Shader.PropertyToID("_Color");
private static readonly int BrushColorId = Shader.PropertyToID("_BrushColor");
private static readonly int BrushCenterId = Shader.PropertyToID("_BrushCenter");
private static readonly int BrushRadiusId = Shader.PropertyToID("_BrushRadius");
private static readonly int BrushUId = Shader.PropertyToID("_BrushU");
private static readonly int BrushVId = Shader.PropertyToID("_BrushV");
private static readonly int BrushNormalId = Shader.PropertyToID("_BrushNormal");
private static readonly int MaskTexId = Shader.PropertyToID("_MaskTex");
private static readonly int BrushOpacityId = Shader.PropertyToID("_BrushOpacity");
private static readonly int PadId = Shader.PropertyToID("_Pad");
private void Start()
{
if (!paintCamera) paintCamera = Camera.main;
_renderer = GetComponent<Renderer>();
_skinned = GetComponent<SkinnedMeshRenderer>();
if (!_renderer)
{
Debug.LogError("[MeshPainter] No Renderer found on this GameObject.", this);
enabled = false;
return;
}
textureSize = Mathf.Clamp(textureSize, 32, 4096);
SetupCollider();
BuildMeshCaches();
if (_uvs == null || _uvs.Length == 0)
{
Debug.LogError("[MeshPainter] Mesh has no UVs - painting not possible.", this);
enabled = false;
return;
}
// Own material instance so the shared asset is not modified; tint to white
// so the painted color appears unaltered.
_materialInstance = _renderer.material;
if (_materialInstance.HasProperty(BaseColorId)) _materialInstance.SetColor(BaseColorId, Color.white);
else if (_materialInstance.HasProperty(ColorId)) _materialInstance.SetColor(ColorId, Color.white);
SetupBackend(useGpu, fromColor32: BuildBaseColors());
EnsureProceduralMask();
ResolveActiveMask();
_setupValid = true;
Debug.Log($"[MeshPainter] Ready ({(_gpuActive ? "GPU" : "CPU")} painting, seamless). " +
"Hold the left mouse button and drag over the mesh.", this);
}
// --- Setup (shared) ---
private void SetupCollider()
{
_collider = GetComponent<MeshCollider>();
if (!_collider) _collider = gameObject.AddComponent<MeshCollider>();
_collider.convex = false; // non-convex provides triangleIndex / textureCoord
_bakedMesh = new Mesh { name = "MeshPainter_Baked" };
BakeColliderMesh();
}
private void BakeColliderMesh()
{
if (_skinned)
{
// Static skinned mesh: bake into local coordinates (scale = 1).
_skinned.BakeMesh(_bakedMesh);
_collider.sharedMesh = null;
_collider.sharedMesh = _bakedMesh;
}
else
{
var mf = GetComponent<MeshFilter>();
if (mf) _collider.sharedMesh = mf.sharedMesh;
}
}
/// <summary>Reads vertices/triangles/UVs of the collider mesh and computes culling data.</summary>
private void BuildMeshCaches()
{
Mesh m = _collider.sharedMesh;
if (!m) return;
_verts = m.vertices;
_tris = m.triangles;
_uvs = m.uv;
// Fallback to the original mesh UVs if the baked mesh dropped them.
if ((_uvs == null || _uvs.Length != _verts.Length) && _skinned && _skinned.sharedMesh)
_uvs = _skinned.sharedMesh.uv;
if (_uvs == null || _uvs.Length != _verts.Length) return;
int triCount = _tris.Length / 3;
_triCenter = new Vector3[triCount];
_triRadius = new float[triCount];
double sumArea3D = 0.0, sumAreaUv = 0.0;
for (int t = 0; t < triCount; t++)
{
int i0 = _tris[t * 3], i1 = _tris[t * 3 + 1], i2 = _tris[t * 3 + 2];
Vector3 a = _verts[i0], b = _verts[i1], c = _verts[i2];
Vector3 center = (a + b + c) / 3f;
_triCenter[t] = center;
float ra = (a - center).sqrMagnitude;
float rb = (b - center).sqrMagnitude;
float rc = (c - center).sqrMagnitude;
_triRadius[t] = Mathf.Sqrt(Mathf.Max(ra, Mathf.Max(rb, rc)));
sumArea3D += 0.5 * Vector3.Cross(b - a, c - a).magnitude;
Vector2 ua = _uvs[i0], ub = _uvs[i1], uc = _uvs[i2];
sumAreaUv += 0.5 * Mathf.Abs(Cross2D(ub - ua, uc - ua));
}
if (sumAreaUv > 1e-9) _avgUvToLocal = (float)System.Math.Sqrt(sumArea3D / sumAreaUv);
}
/// <summary>Builds the base-color texels from the existing material texture or baseColor.</summary>
private Color32[] BuildBaseColors()
{
var px = new Color32[textureSize * textureSize];
Texture sourceTex = null;
var shared = _renderer.sharedMaterial;
if (shared)
{
if (shared.HasProperty(BaseMapId)) sourceTex = shared.GetTexture(BaseMapId);
if (!sourceTex && shared.HasProperty(MainTexId)) sourceTex = shared.GetTexture(MainTexId);
}
var src = sourceTex as Texture2D;
if (src)
{
try
{
if (src.width == textureSize && src.height == textureSize && src.isReadable)
return src.GetPixels32();
}
catch { /* not readable -> fall back to baseColor */ }
}
Color32 c = baseColor;
for (int i = 0; i < px.Length; i++) px[i] = c;
return px;
}
// --- Backend selection / switching ---
private void SetupBackend(bool gpu, Color32[] fromColor32)
{
if (gpu) SetupGpu(fromColor32);
else SetupCpu(fromColor32);
_gpuActive = gpu;
}
/// <summary>Switches the backend at runtime, carrying over the painted content.</summary>
private void SwitchBackend(bool toGpu)
{
Color32[] current = (_gpuActive ? ReadGpuPixels() : (Color32[])_pixels.Clone());
if (_gpuActive) TeardownGpu();
else TeardownCpu();
SetupBackend(toGpu, current);
_hasLastScreen = false;
Debug.Log($"[MeshPainter] Backend switched -> {(toGpu ? "GPU" : "CPU")} painting.", this);
}
// --- CPU backend ---
private void SetupCpu(Color32[] initial)
{
_paintTexture = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, false)
{
name = "MeshPainter_Canvas",
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear
};
_pixels = (initial != null && initial.Length == textureSize * textureSize)
? initial
: BuildBaseColors();
_painted = new bool[textureSize * textureSize];
_fillStamp = new int[textureSize * textureSize];
_dilateStamp = 0;
_paintTexture.SetPixels32(_pixels);
_paintTexture.Apply(false);
AssignTextureToMaterial(_paintTexture);
}
private void TeardownCpu()
{
if (_paintTexture) { Destroy(_paintTexture); _paintTexture = null; }
_pixels = null;
_painted = null;
_fillStamp = null;
}
// --- GPU backend ---
private void SetupGpu(Color32[] initial)
{
if (!brushShader) brushShader = Shader.Find("Hidden/MeshPaintBrush");
if (!dilateShader) dilateShader = Shader.Find("Hidden/MeshPaintDilate");
if (!brushShader || !dilateShader)
{
Debug.LogError("[MeshPainter] GPU shaders not found (Hidden/MeshPaintBrush or " +
"Hidden/MeshPaintDilate). Falling back to CPU painting.", this);
useGpu = false;
SetupCpu(initial);
_gpuActive = false;
return;
}
_brushMat = new Material(brushShader) { hideFlags = HideFlags.HideAndDontSave };
_dilateMat = new Material(dilateShader) { hideFlags = HideFlags.HideAndDontSave };
_displayRT = CreateRT("MeshPainter_DisplayRT");
_tempRT = CreateRT("MeshPainter_TempRT");
// Seed the display RT. Alpha = 0 marks "unpainted" so the base color is not
// mistaken for a dilation source.
var seed = initial ?? BuildBaseColors();
UploadColorsToRT(seed, _displayRT, paintedAlpha: false);
AssignTextureToMaterial(_displayRT);
}
private void TeardownGpu()
{
if (_displayRT) { _displayRT.Release(); Destroy(_displayRT); _displayRT = null; }
if (_tempRT) { _tempRT.Release(); Destroy(_tempRT); _tempRT = null; }
if (_brushMat) { Destroy(_brushMat); _brushMat = null; }
if (_dilateMat) { Destroy(_dilateMat); _dilateMat = null; }
}
private RenderTexture CreateRT(string rtName)
{
var rt = new RenderTexture(textureSize, textureSize, 0, RenderTextureFormat.ARGB32, 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)
{
var tmp = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, false);
// Alpha marks painted (255) vs unpainted (0) for the dilation pass.
if (paintedAlpha)
{
for (int i = 0; i < colors.Length; i++) colors[i].a = 255;
}
else
{
for (int i = 0; i < colors.Length; i++) colors[i].a = 0;
}
tmp.SetPixels32(colors);
tmp.Apply(false);
Graphics.Blit(tmp, rt);
Destroy(tmp);
}
/// <summary>Reads the display RT back as Color32 (for the backend switch).</summary>
private Color32[] ReadGpuPixels()
{
var prev = RenderTexture.active;
RenderTexture.active = _displayRT;
var tex = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, false);
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;
}
// --- Material ---
private void AssignTextureToMaterial(Texture tex)
{
if (!_materialInstance) return;
if (_materialInstance.HasProperty(BaseMapId)) _materialInstance.SetTexture(BaseMapId, tex);
if (_materialInstance.HasProperty(MainTexId)) _materialInstance.SetTexture(MainTexId, tex);
}
// --- Brush mask ---
/// <summary>Builds the built-in circle mask (white inscribed circle on black), used when no mask is assigned.</summary>
private void EnsureProceduralMask()
{
if (_proceduralMask) return;
const int s = 256;
_proceduralMask = new Texture2D(s, s, TextureFormat.RGBA32, false, true) // linear: raw mask values
{
name = "MeshPainter_CircleMask",
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear,
hideFlags = HideFlags.HideAndDontSave
};
var px = new Color32[s * s];
_proceduralOpacity = new float[s * s];
float half = (s - 1) * 0.5f;
for (int y = 0; y < s; y++)
{
for (int x = 0; x < s; x++)
{
float nx = (x - half) / half;
float ny = (y - half) / half;
bool inside = nx * nx + ny * ny <= 1f;
byte v = inside ? (byte)255 : (byte)0;
int idx = y * s + x;
px[idx] = new Color32(v, v, v, 255);
_proceduralOpacity[idx] = inside ? 1f : 0f;
}
}
_proceduralMask.SetPixels32(px);
_proceduralMask.Apply(false);
_procW = s;
_procH = s;
}
/// <summary>Picks the active mask (list entry or procedural circle) and refreshes the CPU luminance cache on change.</summary>
private void ResolveActiveMask()
{
Texture2D desired = null;
if (brushMasks != null && activeBrushIndex >= 0 && activeBrushIndex < brushMasks.Count)
desired = brushMasks[activeBrushIndex];
if (!desired) desired = _proceduralMask;
if (desired == _resolvedMask) return;
_resolvedMask = desired;
if (_resolvedMask == _proceduralMask)
{
_maskOpacity = _proceduralOpacity;
_maskW = _procW;
_maskH = _procH;
return;
}
// User mask: cache opacity (luma*alpha) for the CPU backend so it matches the GPU.
// Non-readable textures are read back via the GPU (so even built-ins like
// Default-Particle work on the CPU path, not just the GPU one).
var px = ReadMaskPixels(_resolvedMask, out int w, out int h);
_maskW = w;
_maskH = h;
_maskOpacity = new float[px.Length];
for (int i = 0; i < px.Length; i++)
{
Color32 p = px[i];
float luma = (0.299f * p.r + 0.587f * p.g + 0.114f * p.b) / 255f;
_maskOpacity[i] = luma * (p.a / 255f);
}
}
/// <summary>
/// Reads a mask texture's pixels. Readable textures are read directly (correct
/// orientation, matches GPU tex2D); non-readable ones are blitted to a temp RT and
/// read back, so any texture (incl. built-ins without Read/Write) works on the CPU.
/// </summary>
private static Color32[] ReadMaskPixels(Texture2D tex, out int w, out int h)
{
w = tex.width;
h = tex.height;
if (tex.isReadable) return tex.GetPixels32();
var tmp = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Linear);
var prev = RenderTexture.active;
Graphics.Blit(tex, tmp);
RenderTexture.active = tmp;
var readTex = new Texture2D(w, h, TextureFormat.RGBA32, false, true);
readTex.ReadPixels(new Rect(0, 0, w, h), 0, 0, false);
readTex.Apply(false);
RenderTexture.active = prev;
RenderTexture.ReleaseTemporary(tmp);
var px = readTex.GetPixels32();
Destroy(readTex);
return px;
}
/// <summary>Bilinear sample of the resolved mask opacity at uv in [0,1]. Returns 1 (full) if no mask.</summary>
private float SampleMaskOpacity(float u, float v)
{
if (_maskOpacity == null || _maskW <= 0 || _maskH <= 0) return 1f;
float fx = Mathf.Clamp01(u) * (_maskW - 1);
float fy = Mathf.Clamp01(v) * (_maskH - 1);
int x0 = Mathf.FloorToInt(fx);
int y0 = Mathf.FloorToInt(fy);
int x1 = Mathf.Min(x0 + 1, _maskW - 1);
int y1 = Mathf.Min(y0 + 1, _maskH - 1);
float tx = fx - x0;
float ty = fy - y0;
float l00 = _maskOpacity[y0 * _maskW + x0];
float l10 = _maskOpacity[y0 * _maskW + x1];
float l01 = _maskOpacity[y1 * _maskW + x0];
float l11 = _maskOpacity[y1 * _maskW + x1];
float a = Mathf.Lerp(l00, l10, tx);
float b = Mathf.Lerp(l01, l11, tx);
return Mathf.Lerp(a, b, ty);
}
/// <summary>
/// Builds an orthonormal tangent frame (u = right, v = up) for the stamp at a hit
/// with the given local-space surface normal, honoring the alignment mode and the
/// manual rotation. u/v span the tangent plane; the mask is sampled along them.
/// </summary>
private void ComputeBrushFrame(Vector3 nLocal, out Vector3 u, out Vector3 v)
{
nLocal = nLocal.normalized;
if (brushAlignment == BrushAlignment.Camera && paintCamera)
{
// Stamp horizontal follows the camera: u = camera right projected onto the surface.
Vector3 camRight = transform.InverseTransformDirection(paintCamera.transform.right);
u = ProjectOntoPlane(camRight, nLocal);
if (u.sqrMagnitude < 1e-8f)
{
Vector3 camUp = transform.InverseTransformDirection(paintCamera.transform.up);
u = ProjectOntoPlane(camUp, nLocal);
if (u.sqrMagnitude < 1e-8f) u = AnyTangent(nLocal);
}
u.Normalize();
// up = right x n, so the frame reads correctly when viewed from the front
// (normal toward the camera): u x v = -n. Using n x u would flip v -> upside down.
v = Vector3.Cross(u, nLocal).normalized;
}
else
{
// Stamp vertical = world up projected onto the surface.
Vector3 worldUp = transform.InverseTransformDirection(Vector3.up);
v = ProjectOntoPlane(worldUp, nLocal);
if (v.sqrMagnitude < 1e-8f) v = AnyTangent(nLocal);
v.Normalize();
// right = n x up (front-readable: u x v = -n). Using up x n would mirror u.
u = Vector3.Cross(nLocal, v).normalized;
}
if (Mathf.Abs(brushRotation) > 1e-4f)
{
Quaternion q = Quaternion.AngleAxis(brushRotation, nLocal);
u = q * u;
v = q * v;
}
}
private static Vector3 ProjectOntoPlane(Vector3 dir, Vector3 normal)
=> dir - normal * Vector3.Dot(dir, normal);
private static Vector3 AnyTangent(Vector3 n)
{
Vector3 t = Vector3.Cross(n, Vector3.right);
if (t.sqrMagnitude < 1e-6f) t = Vector3.Cross(n, Vector3.up);
return t.normalized;
}
// --- Update / input ---
private void Update()
{
if (!_setupValid) return;
if (useGpu != _gpuActive) SwitchBackend(useGpu);
// Painting disabled by the game mode (e.g. player-control mode): do nothing.
if (!PaintingEnabled)
{
_hasLastScreen = false;
return;
}
if (updateColliderEveryFrame && _skinned)
{
BakeColliderMesh();
BuildMeshCaches();
}
ResolveActiveMask();
if (!TryGetMouse(out bool pressed, out Vector2 screenPos))
return;
if (!pressed)
{
_hasLastScreen = false;
return;
}
if (!paintCamera) return;
if (_gpuActive) BeginGpuStroke();
else { _dx0 = textureSize; _dy0 = textureSize; _dx1 = -1; _dy1 = -1; }
if (_hasLastScreen)
{
// Interpolate along the mouse movement and re-raycast at each step so the
// stroke follows the mesh surface.
float dist = Vector2.Distance(_lastScreen, screenPos);
int steps = Mathf.Clamp(Mathf.CeilToInt(dist / ScreenStepPixels), 1, MaxStepsPerFrame);
for (int s = 1; s <= steps; s++)
PaintAtScreen(Vector2.Lerp(_lastScreen, screenPos, s / (float)steps));
}
else
{
PaintAtScreen(screenPos);
}
if (_gpuActive) EndGpuStroke();
else
{
if (paddingTexels > 0) DilatePadding(paddingTexels);
FlushDirty();
}
_lastScreen = screenPos;
_hasLastScreen = true;
}
/// <summary>Raycasts at a screen position and stamps the masked square brush onto the mesh hit point.</summary>
private void PaintAtScreen(Vector2 screenPos)
{
Ray ray = paintCamera.ScreenPointToRay(screenPos);
// Pick the nearest hit that belongs to the paint mesh. A plain raycast would
// return only the closest collider, so anything in front of the mesh (the
// player capsule, props, the camera-collision proxy) could block painting.
int hitCount = Physics.RaycastNonAlloc(ray, _rayHits, 1000f, ~0, QueryTriggerInteraction.Ignore);
RaycastHit hit = default;
float nearest = float.MaxValue;
bool found = false;
for (int i = 0; i < hitCount; i++)
{
if (_rayHits[i].collider != _collider) continue;
if (_rayHits[i].distance < nearest)
{
nearest = _rayHits[i].distance;
hit = _rayHits[i];
found = true;
}
}
if (!found) return;
if (_verts == null || hit.triangleIndex < 0) return;
Vector3 pLocal = transform.InverseTransformPoint(hit.point);
// Convert the brush radius (texels) into a local 3D radius using the hit
// triangle's UV density so the brush keeps a constant visual size.
float scale = _avgUvToLocal;
int t = hit.triangleIndex;
if (t * 3 + 2 < _tris.Length)
{
int i0 = _tris[t * 3], i1 = _tris[t * 3 + 1], i2 = _tris[t * 3 + 2];
float area3D = 0.5f * Vector3.Cross(_verts[i1] - _verts[i0], _verts[i2] - _verts[i0]).magnitude;
float areaUv = 0.5f * Mathf.Abs(Cross2D(_uvs[i1] - _uvs[i0], _uvs[i2] - _uvs[i0]));
if (areaUv > 1e-9f)
{
float s = Mathf.Sqrt(area3D / areaUv);
scale = Mathf.Clamp(s, _avgUvToLocal * 0.25f, _avgUvToLocal * 4f); // guard degenerate UVs
}
}
float radiusLocal = (brushSize / (float)textureSize) * scale;
if (radiusLocal <= 0f) return;
// Tangent-plane frame for the square stamp / mask projection.
Vector3 nLocal = transform.InverseTransformDirection(hit.normal);
ComputeBrushFrame(nLocal, out Vector3 u, out Vector3 v);
nLocal = nLocal.normalized;
if (_gpuActive) DrawBrushGpu(pLocal, radiusLocal, u, v, nLocal);
else PaintStamp3D(pLocal, radiusLocal, u, v, nLocal);
}
// --- GPU painting ---
private void BeginGpuStroke()
{
_drewThisStroke = false;
_prevSRGB = GL.sRGBWrite;
GL.sRGBWrite = true; // correct sRGB encoding when writing into the sRGB RT
Graphics.SetRenderTarget(_displayRT);
_brushMat.SetColor(BrushColorId, brushColor);
_brushMat.SetTexture(MaskTexId, _resolvedMask);
_brushMat.SetFloat(BrushOpacityId, Mathf.Clamp01(brushOpacity));
}
private void DrawBrushGpu(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n)
{
_brushMat.SetVector(BrushCenterId, pLocal);
_brushMat.SetFloat(BrushRadiusId, radiusLocal);
_brushMat.SetVector(BrushUId, u);
_brushMat.SetVector(BrushVId, v);
_brushMat.SetVector(BrushNormalId, n);
if (!_brushMat.SetPass(0)) return;
int sub = _bakedMesh.subMeshCount;
for (int i = 0; i < sub; i++)
Graphics.DrawMeshNow(_bakedMesh, Matrix4x4.identity, i);
_drewThisStroke = true;
}
private void EndGpuStroke()
{
Graphics.SetRenderTarget(null);
// Edge padding against UV seams: dilate the display RT into _tempRT, then swap.
if (_drewThisStroke && paddingTexels > 0)
{
_dilateMat.SetInt(PadId, paddingTexels);
Graphics.Blit(_displayRT, _tempRT, _dilateMat, 0);
var swap = _displayRT; _displayRT = _tempRT; _tempRT = swap;
AssignTextureToMaterial(_displayRT);
}
GL.sRGBWrite = _prevSRGB;
}
// --- CPU painting (rasterization + dilation) ---
/// <summary>
/// Paints a square stamp in the surface tangent plane: rasterizes triangles in
/// range and blends every texel whose 3D position lies inside the box (|du|,|dv|,
/// |dn| &lt;= radius) over the canvas, weighted by the mask opacity. Mirrors the
/// GPU brush shader (mask value = per-texel opacity, gray = 50%).
/// </summary>
private void PaintStamp3D(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n)
{
float br = brushColor.r * 255f, bg = brushColor.g * 255f, bb = brushColor.b * 255f;
float globalOpacity = Mathf.Clamp01(brushOpacity);
float invR = 1f / radiusLocal;
int triCount = _tris.Length / 3;
float ts = textureSize;
for (int t = 0; t < triCount; t++)
{
// Fast culling: stamp box (corner reach = sqrt(3)*radius) vs. the triangle's circumscribed circle.
float reach = radiusLocal * SquareReach + _triRadius[t];
if ((_triCenter[t] - pLocal).sqrMagnitude > reach * reach) continue;
int i0 = _tris[t * 3], i1 = _tris[t * 3 + 1], i2 = _tris[t * 3 + 2];
Vector3 v0 = _verts[i0], v1 = _verts[i1], v2 = _verts[i2];
Vector2 uv0 = _uvs[i0], uv1 = _uvs[i1], uv2 = _uvs[i2];
// UV -> pixel.
float p0x = uv0.x * ts, p0y = uv0.y * ts;
float p1x = uv1.x * ts, p1y = uv1.y * ts;
float p2x = uv2.x * ts, p2y = uv2.y * ts;
int minX = Mathf.Clamp(Mathf.FloorToInt(Mathf.Min(p0x, Mathf.Min(p1x, p2x))), 0, textureSize - 1);
int maxX = Mathf.Clamp(Mathf.CeilToInt(Mathf.Max(p0x, Mathf.Max(p1x, p2x))), 0, textureSize - 1);
int minY = Mathf.Clamp(Mathf.FloorToInt(Mathf.Min(p0y, Mathf.Min(p1y, p2y))), 0, textureSize - 1);
int maxY = Mathf.Clamp(Mathf.CeilToInt(Mathf.Max(p0y, Mathf.Max(p1y, p2y))), 0, textureSize - 1);
if (maxX < minX || maxY < minY) continue;
// Barycentric denominator.
float d = (p1y - p2y) * (p0x - p2x) + (p2x - p1x) * (p0y - p2y);
if (Mathf.Abs(d) < 1e-7f) continue;
float invD = 1f / d;
const float eps = -1e-4f; // tolerance against 1px cracks between triangles
for (int y = minY; y <= maxY; y++)
{
float py = y + 0.5f;
int rowStart = y * textureSize;
for (int x = minX; x <= maxX; x++)
{
float px = x + 0.5f;
float a = ((p1y - p2y) * (px - p2x) + (p2x - p1x) * (py - p2y)) * invD;
if (a < eps) continue;
float b = ((p2y - p0y) * (px - p2x) + (p0x - p2x) * (py - p2y)) * invD;
if (b < eps) continue;
float c = 1f - a - b;
if (c < eps) continue;
// 3D position of the texel via barycentric interpolation.
float wx = a * v0.x + b * v1.x + c * v2.x;
float wy = a * v0.y + b * v1.y + c * v2.y;
float wz = a * v0.z + b * v1.z + c * v2.z;
// Project the offset onto the stamp frame (right/up/normal).
float ddx = wx - pLocal.x, ddy = wy - pLocal.y, ddz = wz - pLocal.z;
float du = ddx * u.x + ddy * u.y + ddz * u.z;
float dv = ddx * v.x + ddy * v.y + ddz * v.z;
float dn = ddx * n.x + ddy * n.y + ddz * n.z;
if (du < -radiusLocal || du > radiusLocal) continue;
if (dv < -radiusLocal || dv > radiusLocal) continue;
if (dn < -radiusLocal || dn > radiusLocal) continue;
// Mask -> opacity, then source-over blend (matches the GPU path).
float mu = du * invR * 0.5f + 0.5f;
float mv = dv * invR * 0.5f + 0.5f;
float opacity = SampleMaskOpacity(mu, mv) * globalOpacity;
if (opacity <= 0f) continue;
int idx = rowStart + x;
Color32 dst = _pixels[idx];
float it = 1f - opacity;
_pixels[idx] = new Color32(
(byte)(dst.r * it + br * opacity),
(byte)(dst.g * it + bg * opacity),
(byte)(dst.b * it + bb * opacity),
255);
_painted[idx] = true;
if (x < _dx0) _dx0 = x;
if (x > _dx1) _dx1 = x;
if (y < _dy0) _dy0 = y;
if (y > _dy1) _dy1 = y;
}
}
}
}
/// <summary>
/// CPU edge padding against UV seams: bleeds the freshly painted colors outward
/// by 'pad' texels. The dilation source is only brush-painted texels (_painted).
/// </summary>
private void DilatePadding(int pad)
{
if (_dx1 < _dx0 || _dy1 < _dy0) return; // nothing painted
int x0 = Mathf.Max(0, _dx0 - pad);
int y0 = Mathf.Max(0, _dy0 - pad);
int x1 = Mathf.Min(textureSize - 1, _dx1 + pad);
int y1 = Mathf.Min(textureSize - 1, _dy1 + pad);
_dilateStamp++;
for (int iter = 0; iter < pad; iter++)
{
_dilateScratch.Clear();
for (int y = y0; y <= y1; y++)
{
int row = y * textureSize;
for (int x = x0; x <= x1; x++)
{
int idx = row + x;
if (_painted[idx] || _fillStamp[idx] == _dilateStamp) continue;
if (TryFindPaddingSource(x, y, out Color32 c))
{
_pixels[idx] = c;
_dilateScratch.Add(idx);
if (x < _dx0) _dx0 = x;
if (x > _dx1) _dx1 = x;
if (y < _dy0) _dy0 = y;
if (y > _dy1) _dy1 = y;
}
}
}
for (int k = 0; k < _dilateScratch.Count; k++)
_fillStamp[_dilateScratch[k]] = _dilateStamp;
if (_dilateScratch.Count == 0) break;
}
}
/// <summary>Searches the 8-neighborhood for a source texel (painted or already padded) and returns its color.</summary>
private bool TryFindPaddingSource(int x, int y, out Color32 col)
{
for (int oy = -1; oy <= 1; oy++)
{
int ny = y + oy;
if (ny < 0 || ny >= textureSize) continue;
int nrow = ny * textureSize;
for (int ox = -1; ox <= 1; ox++)
{
if (ox == 0 && oy == 0) continue;
int nx = x + ox;
if (nx < 0 || nx >= textureSize) continue;
int nidx = nrow + nx;
if (_painted[nidx] || _fillStamp[nidx] == _dilateStamp)
{
col = _pixels[nidx];
return true;
}
}
}
col = default;
return false;
}
/// <summary>Uploads only the region changed this frame to the GPU once (CPU backend).</summary>
private void FlushDirty()
{
if (_dx1 < _dx0 || _dy1 < _dy0) return; // nothing painted
int bw = _dx1 - _dx0 + 1;
int bh = _dy1 - _dy0 + 1;
var block = new Color32[bw * bh];
for (int y = 0; y < bh; y++)
System.Array.Copy(_pixels, (_dy0 + y) * textureSize + _dx0, block, y * bw, bw);
_paintTexture.SetPixels32(_dx0, _dy0, bw, bh, block);
_paintTexture.Apply(false);
}
// --- Helpers ---
private static float Cross2D(Vector2 a, Vector2 b) => a.x * b.y - a.y * b.x;
private bool TryGetMouse(out bool pressed, out Vector2 screenPos)
{
pressed = false;
screenPos = Vector2.zero;
#if ENABLE_INPUT_SYSTEM
var mouse = Mouse.current;
if (mouse != null)
{
pressed = mouse.leftButton.isPressed;
screenPos = mouse.position.ReadValue();
return true;
}
#endif
#if ENABLE_LEGACY_INPUT_MANAGER
pressed = Input.GetMouseButton(0);
screenPos = Input.mousePosition;
return true;
#else
return false;
#endif
}
private void OnDestroy()
{
if (_bakedMesh) Destroy(_bakedMesh);
if (_materialInstance) Destroy(_materialInstance);
if (_proceduralMask) Destroy(_proceduralMask);
TeardownCpu();
TeardownGpu();
}
}