Files
chameleon/Assets/Scripts/MeshPainter.cs
T
adminandClaude Opus 4.8 301f7e64cb Add Ctrl+Z/Ctrl+Y undo/redo with command-based network sync
Pointer-based per-painter history of the canonical maps with copy-on-write
sharing (only maps touched by a stroke are cloned). A stroke (press..release)
is one step; a Load resets the history to the loaded maps as the sole baseline.

Network sync replicates undo/redo as tiny checkpoint/restore commands instead
of broadcasting full map snapshots: every peer runs the same COW history keyed
by owner-assigned checkpoint ids and reproduces restores locally. A peer that
cannot reproduce a target (undo across its join boundary) requests a targeted
snapshot from the owner as fallback.

Hardening: ordered send FIFO keeps stamps + checkpoints + restores in
production order (a marker never overtakes the stamps it finalizes); stamp
overflow carries over to the next frame instead of being decimated, so remote
content matches the owner; remote always inserts the checkpoint id to stay
1:1 aligned. Owner-authoritative snapshot fallback replaces the silent
host no-op. Adds a logSync toggle (default on) tracing when each
checkpoint/restore/snapshot is sent, applied, or falls back.

Also includes paintings-panel layout tuning in PaintHudPanel.prefab
(inspector edits).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:19:36 +02:00

885 lines
37 KiB
C#

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
/// <summary>
/// Live mesh painting in Play mode: paints the brush onto the mesh textures with the
/// mouse. Three paint maps are driven at once (each can be toggled): albedo color
/// (_BaseMap), an HDRP-style mask map (R = metallic, G = occlusion, A = smoothness; one
/// texture bound to BOTH _MetallicGlossMap and _OcclusionMap) and emission (_EmissionMap).
/// Metallic/smoothness and occlusion share that mask map but stay independently paintable.
/// Two backends (switchable live via <see cref="useGpu"/>):
/// GPU shader painting into RenderTextures (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 (the mask value = per-texel opacity). 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.
///
/// A non-destructive brush preview (size + mask) is shown at the hover point without
/// painting (<see cref="previewMode"/>): either a flat decal quad or a real on-surface
/// overlay that wraps the mesh exactly like the paint would land.
/// </summary>
[DisallowMultipleComponent]
public partial 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>How the brush preview at the hover point is rendered.</summary>
public enum BrushPreviewMode
{
/// <summary>No preview.</summary>
Off,
/// <summary>A flat quad in the tangent plane (cheap, does not follow curvature).</summary>
FlatDecal,
/// <summary>Real on-surface overlay: wraps the mesh exactly like the paint would land.</summary>
OnSurface
}
/// <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 - channels")]
[Tooltip("Paint the albedo (base color) map.")]
public bool paintAlbedo = true;
[Tooltip("Paint metallic + smoothness into the mask map (R = metallic, A = smoothness). " +
"Shares one texture with occlusion but is toggled independently.")]
public bool paintMetallicSmoothness = false;
[Tooltip("Paint ambient occlusion into the mask map (G channel, also bound to _OcclusionMap). " +
"Shares one texture with metallic/smoothness but is toggled independently.")]
public bool paintOcclusion = false;
[Tooltip("Paint the emission map (_EmissionMap).")]
public bool paintEmission = false;
[Header("Brush - albedo")]
[Tooltip("Color used for painting. The alpha channel is the global opacity multiplier on " +
"top of the mask (alpha 1 = mask opacity as-is, lower = fainter).")]
public Color brushColor = Color.red;
[Header("Brush - metallic / smoothness / occlusion")]
[Tooltip("Metallic value painted into the mask map (0 = dielectric, 1 = metal).")]
[Range(0f, 1f)]
public float metallic = 0f;
[Tooltip("Smoothness value painted into the mask map (0 = rough, 1 = glossy).")]
[Range(0f, 1f)]
public float smoothness = 0.5f;
[Tooltip("Paint opacity for metallic + smoothness on top of the mask (1 = mask opacity " +
"as-is, lower = blends the metallic/smoothness target in more faintly, 0 = no-op). " +
"The albedo equivalent of brushColor.a, but for the metallic/smoothness channels.")]
[Range(0f, 1f)]
public float metallicSmoothnessOpacity = 1f;
[Tooltip("Ambient occlusion painted into the mask map (1 = fully lit / no darkening, " +
"0 = fully occluded / dark). NOTE: URP only darkens INDIRECT light (ambient + " +
"reflections), so the effect is subtle under strong direct lighting. 1 = no-op.")]
[Range(0f, 1f)]
public float occlusion = 0.5f;
[Tooltip("Paint opacity for occlusion on top of the mask (1 = mask opacity as-is, lower = " +
"blends the occlusion target in more faintly, 0 = no-op). The albedo equivalent of " +
"brushColor.a, but for the occlusion channel.")]
[Range(0f, 1f)]
public float occlusionOpacity = 1f;
[Header("Brush - emission")]
[Tooltip("Emission color painted into the map. Alpha = global opacity multiplier on top of the mask.")]
public Color emissionColor = Color.black;
[Tooltip("Global emission intensity multiplier on the painted emission (drives the material's _EmissionColor). " +
"Above 1 makes the emission HDR/bloom-bright.")]
public float emissionIntensity = 1f;
[Header("Brush - shape")]
[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("ON = paint once on mouse-down, then only while the mouse moves (a held but " +
"stationary button no longer keeps re-stamping the same spot). OFF = continuous painting while held.")]
public bool paintOnMoveOnly = false;
[Tooltip("Seconds between paint stamps while the button is held. 0 = paint every frame as " +
"an interpolated, continuous stroke (as before). Above 0 = deposit one stamp at the " +
"cursor every this many seconds (timed dabs, no movement interpolation - pairs well " +
"with the spray can).")]
[Min(0f)]
public float paintInterval = 0f;
[Header("Preview")]
[Tooltip("Brush preview at the hover point (shows size + mask without painting). " +
"Flat Decal = a flat quad in the tangent plane (cheap). On Surface = a real " +
"overlay that wraps the mesh exactly like the paint would land. Off = no preview.")]
public BrushPreviewMode previewMode = BrushPreviewMode.OnSurface;
[Tooltip("Opacity of the preview overlay (the mask still modulates it per texel). " +
"Can exceed 1 to make the preview show up more strongly.")]
[Range(0f, 4f)]
public float previewOpacity = 0.5f;
[Header("Canvas")]
[Tooltip("Resolution of the paint textures (square).")]
public int textureSize = 1024;
[Tooltip("Base color of the albedo 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;
// --- paint maps ---
private const int MAP_ALBEDO = 0;
// Mask map: R = metallic, G = occlusion, A = smoothness. One texture bound to BOTH
// _MetallicGlossMap (reads R/A) and _OcclusionMap (reads G).
private const int MAP_MSO = 1;
private const int MAP_EM = 2; // emission (RGB)
private const int MAP_COUNT = 3;
/// <summary>CPU backend per map: a Texture2D mirror + rasterization/dilation state.</summary>
private class CpuMap
{
public Texture2D tex;
public Color32[] pixels;
public bool[] painted; // true = painted by the brush (dilation source)
public int[] fillStamp; // per-call marker for padding texels
public int dilateStamp; // bumped per call so old padding is not a source
public int dx0, dy0, dx1, dy1; // dirty rectangle (CPU upload)
}
// --- shared references ---
private Renderer _renderer;
private SkinnedMeshRenderer _skinned;
private MeshCollider _collider;
private Mesh _bakedMesh;
// When present, this always-on baker owns the collider bake (also outside paint mode); we
// stop baking and only borrow its mesh. Null = no baker -> we keep our own legacy bake.
private SkinnedColliderBaker _baker;
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[] _islandMask; // texel lies inside a UV island: dilation source / overwrite guard (built once)
private bool _hasLastScreen;
private Vector2 _lastScreen;
private float _intervalTimer; // accumulates while held; gates timed-dab stamping (paintInterval > 0)
private bool _gpuActive;
private bool _setupValid;
// Seed colors per map in canonical (material) layout, built once at Start:
// albedo -> RGB color
// MSO -> R = metallic, G = occlusion (1 = unoccluded), A = smoothness (mask map)
// emission -> RGB emission
private Color32[][] _seedCanonical;
// --- backends ---
private CpuMap[] _cpu;
private readonly List<int> _dilateScratch = new List<int>(512);
private readonly RaycastHit[] _rayHits = new RaycastHit[32];
// --- preview overlay ---
public Shader previewShader; // optional; else Shader.Find("Hidden/MeshPaintPreview")
private Material _previewMat;
private Mesh _previewQuad; // unit quad in the tangent plane (flat decal mode)
// --- 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 MoveOnlyMinPixels = 0.75f; // move-only mode: min mouse travel before re-stamping
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 MetallicGlossMapId = Shader.PropertyToID("_MetallicGlossMap");
private static readonly int MetallicId = Shader.PropertyToID("_Metallic");
private static readonly int SmoothnessId = Shader.PropertyToID("_Smoothness");
private static readonly int EmissionMapId = Shader.PropertyToID("_EmissionMap");
private static readonly int EmissionColorId = Shader.PropertyToID("_EmissionColor");
private static readonly int OcclusionMapId = Shader.PropertyToID("_OcclusionMap");
private static readonly int OcclusionStrengthId = Shader.PropertyToID("_OcclusionStrength");
private static readonly int BrushColorId = Shader.PropertyToID("_BrushColor");
private static readonly int ColorWriteMaskId = Shader.PropertyToID("_ColorWriteMask");
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 PadId = Shader.PropertyToID("_Pad");
private static readonly int PreviewOpacityId = Shader.PropertyToID("_PreviewOpacity");
private static readonly int PreviewModeId = Shader.PropertyToID("_PreviewMode");
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 base color 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);
BuildSeedColors();
// Always start fresh from the material seed. Saved paintings are loaded explicitly from the
// HUD library (MeshPainter.Persistence), never auto-loaded on start.
SetupBackend(useGpu, _seedCanonical);
ConfigureMaterialPbr();
InitUndoHistory(); // baseline = material seed, captured before any stroke
EnsureProceduralMask();
ResolveActiveMask();
SetupPreview();
_setupValid = true;
Debug.Log($"[MeshPainter] Ready ({(_gpuActive ? "GPU" : "CPU")} painting, seamless). " +
"Hold the left mouse button and drag over the mesh.", this);
}
// --- 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 opacity 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 active stamp mask opacity at uv in [0,1]. Returns 1 (full) if no mask.</summary>
private float SampleMaskOpacity(float u, float v)
{
if (_stampOpacity == null || _stampW <= 0 || _stampH <= 0) return 1f;
float fx = Mathf.Clamp01(u) * (_stampW - 1);
float fy = Mathf.Clamp01(v) * (_stampH - 1);
int x0 = Mathf.FloorToInt(fx);
int y0 = Mathf.FloorToInt(fy);
int x1 = Mathf.Min(x0 + 1, _stampW - 1);
int y1 = Mathf.Min(y0 + 1, _stampH - 1);
float tx = fx - x0;
float ty = fy - y0;
float l00 = _stampOpacity[y0 * _stampW + x0];
float l10 = _stampOpacity[y0 * _stampW + x1];
float l01 = _stampOpacity[y1 * _stampW + x0];
float l11 = _stampOpacity[y1 * _stampW + 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;
}
// --- Brush preview ---
/// <summary>Creates the preview material and the unit quad used by the flat-decal mode.</summary>
private void SetupPreview()
{
if (!previewShader) previewShader = Shader.Find("Hidden/MeshPaintPreview");
if (!previewShader)
{
Debug.LogWarning("[MeshPainter] Preview shader (Hidden/MeshPaintPreview) not found - brush preview disabled.", this);
return;
}
_previewMat = new Material(previewShader) { hideFlags = HideFlags.HideAndDontSave };
_previewQuad = BuildPreviewQuad();
}
/// <summary>Unit quad in its XY plane ([-1,1], normal +Z, uv 0..1) - oriented per hit in the flat-decal mode.</summary>
private static Mesh BuildPreviewQuad()
{
var q = new Mesh { name = "MeshPainter_PreviewQuad", hideFlags = HideFlags.HideAndDontSave };
q.vertices = new[]
{
new Vector3(-1f, -1f, 0f), new Vector3(1f, -1f, 0f),
new Vector3(1f, 1f, 0f), new Vector3(-1f, 1f, 0f)
};
q.uv = new[]
{
new Vector2(0f, 0f), new Vector2(1f, 0f),
new Vector2(1f, 1f), new Vector2(0f, 1f)
};
q.triangles = new[] { 0, 1, 2, 0, 2, 3 };
q.RecalculateBounds();
return q;
}
/// <summary>
/// Draws the brush preview at the hover point for this frame (no painting). On-surface
/// mode re-draws the baked mesh and clips it to the brush box in the shader; flat-decal
/// mode draws the oriented quad. Both sample the active mask (size + shape preview).
/// </summary>
private void UpdatePreview(Vector2 screenPos)
{
if (!TryComputeBrushPlacement(screenPos, out Vector3 pLocal, out float radiusLocal,
out Vector3 u, out Vector3 v, out Vector3 n))
return;
_previewMat.SetColor(BrushColorId, brushColor);
// Spray paints scattered dots, but the preview shows the round footprint (the area
// the dots land in), so the hover ring stays a stable, readable size indicator.
_previewMat.SetTexture(MaskTexId, spray ? _proceduralMask : _resolvedMask);
_previewMat.SetFloat(PreviewOpacityId, Mathf.Max(0f, previewOpacity) * Mathf.Clamp01(brushColor.a));
int layer = gameObject.layer;
if (previewMode == BrushPreviewMode.OnSurface)
{
_previewMat.SetFloat(PreviewModeId, 0f);
_previewMat.SetVector(BrushCenterId, pLocal);
_previewMat.SetFloat(BrushRadiusId, radiusLocal);
_previewMat.SetVector(BrushUId, u);
_previewMat.SetVector(BrushVId, v);
_previewMat.SetVector(BrushNormalId, n);
// Draw the collider mesh again (object transform): the shader clips it to the
// brush box, so only the footprint shows, wrapped onto the real surface. Using
// the collider mesh keeps it in the same local space as the raycast hit and
// valid for both skinned (baked) and static meshes.
Mesh previewMesh = _collider ? _collider.sharedMesh : null;
if (!previewMesh) return;
Matrix4x4 m = transform.localToWorldMatrix;
int sub = previewMesh.subMeshCount;
for (int i = 0; i < sub; i++)
Graphics.DrawMesh(previewMesh, m, _previewMat, layer, paintCamera, i);
}
else // FlatDecal
{
_previewMat.SetFloat(PreviewModeId, 1f);
// Quad frame in local space: x -> u*r, y -> v*r, lifted slightly along n so
// it floats on the surface; uv 0..1 matches the on-surface mask mapping.
Vector3 pos = pLocal + n * (radiusLocal * 0.02f);
var frame = new Matrix4x4();
frame.SetColumn(0, new Vector4(u.x * radiusLocal, u.y * radiusLocal, u.z * radiusLocal, 0f));
frame.SetColumn(1, new Vector4(v.x * radiusLocal, v.y * radiusLocal, v.z * radiusLocal, 0f));
frame.SetColumn(2, new Vector4(n.x, n.y, n.z, 0f));
frame.SetColumn(3, new Vector4(pos.x, pos.y, pos.z, 1f));
Graphics.DrawMesh(_previewQuad, transform.localToWorldMatrix * frame, _previewMat, layer, paintCamera, 0);
}
}
// --- Update / input ---
private void Update()
{
if (!_setupValid) return;
if (useGpu != _gpuActive) SwitchBackend(useGpu);
RefreshEmissionColor(); // live-tunable emission intensity
// Painting disabled by the game mode (e.g. player-control mode): do nothing.
if (!PaintingEnabled)
{
EndStrokeIfActive(); // finalize an in-progress stroke if the mode was left mid-paint
_hasLastScreen = false;
return;
}
// Undo / redo (Ctrl+Z / Ctrl+Y): only here, since PaintingEnabled is true only on the local
// owner in paint mode. Commit any in-progress stroke first so it becomes its own history step.
GetUndoRedoInput(out bool wantUndo, out bool wantRedo);
if (wantUndo || wantRedo)
{
EndStrokeIfActive();
if (wantUndo) Undo();
else Redo();
return;
}
if (updateColliderEveryFrame && _skinned)
{
BakeColliderMesh();
BuildMeshCaches();
}
ResolveActiveMask();
if (!TryGetMouse(out bool pressed, out Vector2 screenPos))
return;
// Don't paint through the in-game UI: clicking a HUD slider/toggle must not
// also stamp the mesh behind it.
if (pressed && IsPointerOverUi())
pressed = false;
// Pipette (eyedropper): a fresh click samples ALL channel values at the hit point
// and adopts them into the brush instead of painting. It takes over the click
// entirely (no painting, no brush preview ring while active).
if (pipette)
{
EndStrokeIfActive(); // switching to the eyedropper ends any active stroke
if (pressed)
{
if (!_pickHeld) PickAtScreen(screenPos);
_pickHeld = true;
}
else _pickHeld = false;
_hasLastScreen = false; // keep the paint stroke state clean for when pipette turns off
return;
}
_pickHeld = false;
// Brush preview (size + mask) at the hover point - independent of painting,
// so you see the brush before pressing the button.
if (previewMode != BrushPreviewMode.Off && _previewMat && paintCamera)
UpdatePreview(screenPos);
if (!pressed)
{
EndStrokeIfActive(); // mouse released -> commit the stroke as one undo step
_hasLastScreen = false;
return;
}
if (!paintCamera) return;
bool anyChannel = AnyChannelActive(); // includes occlusion (matches IsChannelActive / PaintStamp3D)
bool firstPress = !_hasLastScreen;
float moveDist = firstPress ? 0f : Vector2.Distance(_lastScreen, screenPos);
bool stamp;
if (paintInterval > 0f)
{
// Timed-dab mode: one stamp on press, then one at the cursor every paintInterval
// seconds while held (discrete dabs, no movement interpolation).
if (firstPress)
{
_intervalTimer = 0f;
stamp = anyChannel;
}
else
{
_intervalTimer += Time.deltaTime;
stamp = anyChannel && _intervalTimer >= paintInterval;
if (stamp) _intervalTimer = 0f;
}
}
else
{
// Continuous mode: stamp every frame as an interpolated stroke. Move-only optionally
// gates it - a held but stationary button no longer re-stamps the same spot.
stamp = anyChannel && (firstPress || !paintOnMoveOnly || moveDist > MoveOnlyMinPixels);
}
if (stamp)
{
if (_gpuActive) BeginGpuStroke();
else ResetCpuDirty();
if (!firstPress && paintInterval <= 0f)
{
// Interpolate along the mouse movement and re-raycast at each step so the
// stroke follows the mesh surface.
int steps = Mathf.Clamp(Mathf.CeilToInt(moveDist / ScreenStepPixels), 1, MaxStepsPerFrame);
for (int s = 1; s <= steps; s++)
PaintAtScreen(Vector2.Lerp(_lastScreen, screenPos, s / (float)steps));
}
else
{
// First stamp of a stroke, or a timed dab: a single discrete stamp at the cursor.
PaintAtScreen(screenPos);
}
if (_gpuActive) EndGpuStroke();
else EndCpuStroke();
}
_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)
{
if (!TryComputeBrushPlacement(screenPos, out Vector3 pLocal, out float radiusLocal,
out Vector3 u, out Vector3 v, out Vector3 nLocal))
return;
// Pick what this stamp paints with: a fresh random spray pattern, or the resolved
// standard mask. Both backends read it through the _stamp* fields. When this stamp is
// being replicated (networked owner), the spray is seeded so remotes scatter identically.
if (spray)
{
if (WantsStampCapture) { _lastSpraySeed = Random.Range(1, int.MaxValue); BuildSprayStampMaskSeeded(_lastSpraySeed); }
else BuildSprayStampMask();
}
else UseResolvedStampMask();
if (_gpuActive) DrawBrushGpu(pLocal, radiusLocal, u, v, nLocal);
else PaintStamp3D(pLocal, radiusLocal, u, v, nLocal);
EmitStamp(pLocal, radiusLocal, u, v, nLocal);
MarkStrokePainted(); // a stamp landed -> this stroke is undoable
}
/// <summary>
/// Raycasts at a screen position and returns the nearest hit that belongs to THIS 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 it; we
/// scan all hits and keep the nearest on our own collider. Shared by painting, preview
/// and the pipette.
/// </summary>
private bool TryRaycastPaintMesh(Vector2 screenPos, out RaycastHit hit)
{
hit = default;
if (!paintCamera) return false;
Ray ray = paintCamera.ScreenPointToRay(screenPos);
int hitCount = Physics.RaycastNonAlloc(ray, _rayHits, 1000f, ~0, QueryTriggerInteraction.Ignore);
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;
}
}
return found;
}
/// <summary>
/// Raycasts at a screen position and returns the nearest hit on ANY collider (not just the
/// paint mesh). Used by the pipette, which can sample foreign objects' materials too.
/// </summary>
private bool TryRaycastAny(Vector2 screenPos, out RaycastHit hit)
{
hit = default;
if (!paintCamera) return false;
Ray ray = paintCamera.ScreenPointToRay(screenPos);
return Physics.Raycast(ray, out hit, 1000f, ~0, QueryTriggerInteraction.Ignore);
}
/// <summary>
/// Raycasts at a screen position against the paint mesh and computes the brush
/// placement: hit point (local space), local 3D radius (constant visual size via UV
/// density) and the orthonormal tangent frame (u/v/n). Shared by painting and preview.
/// </summary>
private bool TryComputeBrushPlacement(Vector2 screenPos, out Vector3 pLocal, out float radiusLocal,
out Vector3 u, out Vector3 v, out Vector3 nLocal)
{
pLocal = default; radiusLocal = 0f; u = default; v = default; nLocal = default;
if (!TryRaycastPaintMesh(screenPos, out RaycastHit hit)) return false;
if (_verts == null || hit.triangleIndex < 0) return false;
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
}
}
radiusLocal = (brushSize / (float)textureSize) * scale;
if (radiusLocal <= 0f) return false;
// Tangent-plane frame for the square stamp / mask projection.
nLocal = transform.InverseTransformDirection(hit.normal);
ComputeBrushFrame(nLocal, out u, out v);
nLocal = nLocal.normalized;
return true;
}
// --- Channels (shared by both backends) ---
/// <summary>True if at least one paint channel is enabled (used to gate the per-frame stroke).</summary>
private bool AnyChannelActive()
{
for (int mi = 0; mi < MAP_COUNT; mi++)
if (IsChannelActive(mi)) return true;
return false;
}
private bool IsChannelActive(int mi)
{
switch (mi)
{
case MAP_ALBEDO: return paintAlbedo;
case MAP_MSO: return paintMetallicSmoothness || paintOcclusion;
default: return paintEmission;
}
}
// --- 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
}
/// <summary>True if the pointer is currently over an interactive UI element (raycast target).</summary>
private static bool IsPointerOverUi()
{
var es = EventSystem.current;
return es && es.IsPointerOverGameObject();
}
private void OnDestroy()
{
if (_bakedMesh && !_baker) Destroy(_bakedMesh); // _baker owns its mesh; only free our own
if (_materialInstance) Destroy(_materialInstance);
if (_proceduralMask) Destroy(_proceduralMask);
if (_previewMat) Destroy(_previewMat);
if (_previewQuad) Destroy(_previewQuad);
TeardownSpray();
TeardownCpu();
TeardownGpu();
}
}