Add a geometry-based gate in PaintAtScreen that estimates how much of the brush footprint lands on faces whose normals deviate sharply from the hit surface. When that fraction exceeds the threshold the whole dab is suppressed (no draw, emit, or undo mark) instead of smearing around the edge. Backend-independent (no GPU readback) and winding-robust. Tunable via cornerGuard / cornerGuardAngle / cornerGuardMaxFraction. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1121 lines
50 KiB
C#
1121 lines
50 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
using UnityEngine.EventSystems;
|
|
#if ENABLE_INPUT_SYSTEM
|
|
using UnityEngine.InputSystem;
|
|
#endif
|
|
using Random = UnityEngine.Random; // System (for Action) also defines Random -> keep the UnityEngine one
|
|
|
|
/// <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. Driven by the GameModeManager (true only in
|
|
/// PaintMode on the local owner). Defaults OFF so a painter that is not (yet) managed by a mode -
|
|
/// the pre-spawn window, a remote replica, or any binding gap - never paints from local input.</summary>
|
|
public bool PaintingEnabled { get; set; } = false;
|
|
|
|
/// <summary>True once Start has built the material instance and mesh caches (the camo knobs below
|
|
/// become readable from the material). The HUD binds in the avatar's OnNetworkSpawn, which runs
|
|
/// BEFORE this Start, so it must wait for this before reading the camo knobs into its sliders.</summary>
|
|
public bool IsReady => _setupValid;
|
|
|
|
/// <summary>
|
|
/// Live camouflage knobs on the player's <c>Custom/PlayerCamoLit</c> material instance, surfaced so
|
|
/// the paint HUD can tune them at runtime. Backed directly by the material instance, which is the
|
|
/// authored source of truth: the getter reads it, the setter writes it. Both are a no-op when the
|
|
/// material lacks the property (non-camo shader) or before Start builds the instance, so they never
|
|
/// clobber the authored value. Runtime tuning is persisted by the HUD (PaintHud.Persistence saves them
|
|
/// to the same JSON as the brush values and carries them across a scene-switch respawn) and replicated
|
|
/// to other peers owner-authoritatively by <see cref="PlayerPaintSync"/>, so remotes show the same camo.
|
|
/// </summary>
|
|
public float CamoHalfLambert
|
|
{
|
|
get => _materialInstance && _materialInstance.HasProperty(HalfLambertId) ? _materialInstance.GetFloat(HalfLambertId) : 0f;
|
|
set { if (_materialInstance && _materialInstance.HasProperty(HalfLambertId)) _materialInstance.SetFloat(HalfLambertId, value); }
|
|
}
|
|
|
|
/// <summary>Companion to <see cref="CamoHalfLambert"/>: additive brightness shift (-1..1) of the
|
|
/// flattened diffuse term, so the body tone can be tuned to match the scene.</summary>
|
|
public float CamoHalfLambertBrightness
|
|
{
|
|
get => _materialInstance && _materialInstance.HasProperty(HalfLambertBrightnessId) ? _materialInstance.GetFloat(HalfLambertBrightnessId) : 0f;
|
|
set { if (_materialInstance && _materialInstance.HasProperty(HalfLambertBrightnessId)) _materialInstance.SetFloat(HalfLambertBrightnessId, value); }
|
|
}
|
|
|
|
/// <summary>Companion to <see cref="CamoHalfLambert"/>: scales down the direct specular highlight.</summary>
|
|
public float CamoSpecularDampen
|
|
{
|
|
get => _materialInstance && _materialInstance.HasProperty(SpecularDampenId) ? _materialInstance.GetFloat(SpecularDampenId) : 0f;
|
|
set { if (_materialInstance && _materialInstance.HasProperty(SpecularDampenId)) _materialInstance.SetFloat(SpecularDampenId, value); }
|
|
}
|
|
|
|
[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("Texels added/removed per mouse-wheel notch while Shift is held (paint mode brush resize).")]
|
|
public int brushSizeScrollStep = 4;
|
|
|
|
/// <summary>
|
|
/// Raised after Shift+wheel changes <see cref="brushSize"/> so the in-game HUD can re-sync its
|
|
/// brush-size slider (the slider then re-clamps the value to its own configured range).
|
|
/// </summary>
|
|
public event Action BrushSizeChanged;
|
|
|
|
[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("Brush - corner guard")]
|
|
[Tooltip("Suppresses a whole brush dab when too much of its footprint would wrap around a " +
|
|
"corner onto an adjacent, steeply angled surface (the classic 'painted around the " +
|
|
"corner' bleed, worst with large brushes). The dab is blocked entirely, not clipped: " +
|
|
"you simply cannot paint at a spot that would smear around the corner - aim cleaner or " +
|
|
"shrink the brush. OFF = paint anywhere (old behavior).")]
|
|
public bool cornerGuard = true;
|
|
|
|
[Tooltip("Surface under the brush whose normal deviates from the hit face by more than this " +
|
|
"angle counts as 'around the corner'. Lower = stricter (gentle bends already block); " +
|
|
"higher = only sharp corners count. 180 = effectively off.")]
|
|
[Range(0f, 180f)]
|
|
public float cornerGuardAngle = 50f;
|
|
|
|
[Tooltip("Block the dab once this share of the brush footprint is around the corner. " +
|
|
"0 = block at the slightest wrap, 1 = effectively off (only a fully wrapped dab blocks).")]
|
|
[Range(0f, 1f)]
|
|
public float cornerGuardMaxFraction = 0.25f;
|
|
|
|
[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 HalfLambertId = Shader.PropertyToID("_HalfLambert");
|
|
private static readonly int HalfLambertBrightnessId = Shader.PropertyToID("_HalfLambertBrightness");
|
|
private static readonly int SpecularDampenId = Shader.PropertyToID("_SpecularDampen");
|
|
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, out int _))
|
|
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;
|
|
}
|
|
|
|
// Modifier keys: Shift or Ctrl = pipette, Shift/Ctrl + wheel resizes the brush. Runs before the
|
|
// pipette/paint branches below so this frame's pipette state is current.
|
|
HandleBrushModifiers();
|
|
|
|
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;
|
|
|
|
// Brush preview (size + mask) at the hover point - independent of painting AND of the
|
|
// pipette, so you always see the brush footprint: before pressing the button, while the
|
|
// momentary pipette holds Shift/Ctrl, and live as Shift/Ctrl+wheel resizes the brush.
|
|
if (previewMode != BrushPreviewMode.Off && _previewMat && paintCamera)
|
|
UpdatePreview(screenPos);
|
|
|
|
// 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),
|
|
// but the brush preview above stays visible.
|
|
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;
|
|
|
|
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, out int hitTri))
|
|
return;
|
|
|
|
// Corner guard: if too much of the footprint would wrap around a corner, suppress the
|
|
// whole dab here - before drawing, emitting or marking the stroke painted, so nothing is
|
|
// clipped and remotes never replay a dab the owner refused to paint.
|
|
if (cornerGuard && DabWrapsAroundCorner(pLocal, radiusLocal, u, v, nLocal, hitTri))
|
|
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>
|
|
/// True if a brush dab at this placement would wrap excessively around a corner: more than
|
|
/// <see cref="cornerGuardMaxFraction"/> of its footprint sits on surface whose face normal
|
|
/// deviates from the hit face by more than <see cref="cornerGuardAngle"/>. Used to suppress
|
|
/// the whole dab (not clip it). Geometry-only, so it behaves identically on the GPU and CPU
|
|
/// backends and needs no texture readback.
|
|
/// </summary>
|
|
private bool DabWrapsAroundCorner(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n, int hitTri)
|
|
{
|
|
if (_verts == null || _tris == null || _triCenter == null || _triRadius == null) return false;
|
|
if (hitTri < 0 || hitTri * 3 + 2 >= _tris.Length) return false;
|
|
|
|
// Reference normal = the hit triangle's geometric face normal, built with the SAME
|
|
// cross-product convention as every candidate below, so mesh winding cancels in the
|
|
// dot comparison (we only judge relative deviation from the surface we actually hit).
|
|
int h0 = _tris[hitTri * 3], h1 = _tris[hitTri * 3 + 1], h2 = _tris[hitTri * 3 + 2];
|
|
Vector3 fnHit = Vector3.Cross(_verts[h1] - _verts[h0], _verts[h2] - _verts[h0]);
|
|
if (fnHit.sqrMagnitude < 1e-12f) return false; // degenerate hit triangle -> allow
|
|
fnHit.Normalize();
|
|
|
|
float cosThresh = Mathf.Cos(Mathf.Clamp(cornerGuardAngle, 0f, 180f) * Mathf.Deg2Rad);
|
|
float frac = CornerWrapFraction(_verts, _tris, _triCenter, _triRadius,
|
|
pLocal, radiusLocal, u, v, n, fnHit, cosThresh);
|
|
return frac > cornerGuardMaxFraction;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Estimates the share (0..1) of the brush footprint sitting on surface whose geometric face
|
|
/// normal deviates from <paramref name="fnHit"/> by more than the threshold (given as its
|
|
/// cosine). Per in-range triangle (same circumscribed-circle cull as the painter), the area is
|
|
/// weighted by how many of {v0,v1,v2,centroid} fall inside the brush box - a cheap, backend-
|
|
/// agnostic footprint estimate (no GPU readback). Winding cancels because both normals use the
|
|
/// same cross convention. Returns 0 when nothing is in range (caller then allows the dab).
|
|
/// Static + array-parameterized so the geometry math is unit-testable without a live component.
|
|
/// </summary>
|
|
private static float CornerWrapFraction(
|
|
Vector3[] verts, int[] tris, Vector3[] triCenter, float[] triRadius,
|
|
Vector3 center, float r, Vector3 u, Vector3 v, Vector3 n, Vector3 fnHit, float cosThresh)
|
|
{
|
|
double totalW = 0.0, cornerW = 0.0;
|
|
int triCount = tris.Length / 3;
|
|
for (int t = 0; t < triCount; t++)
|
|
{
|
|
float reach = r * SquareReach + triRadius[t];
|
|
if ((triCenter[t] - center).sqrMagnitude > reach * reach) continue;
|
|
|
|
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 ctr = (a + b + c) * (1f / 3f);
|
|
|
|
int inside = (InBrushBox(a, center, u, v, n, r) ? 1 : 0)
|
|
+ (InBrushBox(b, center, u, v, n, r) ? 1 : 0)
|
|
+ (InBrushBox(c, center, u, v, n, r) ? 1 : 0)
|
|
+ (InBrushBox(ctr, center, u, v, n, r) ? 1 : 0);
|
|
if (inside == 0) continue;
|
|
|
|
Vector3 cross = Vector3.Cross(b - a, c - a);
|
|
float area2 = cross.magnitude; // 2 x triangle area (relative weight is enough)
|
|
if (area2 < 1e-12f) continue;
|
|
|
|
double w = inside * 0.25 * area2;
|
|
totalW += w;
|
|
|
|
float aligned = Vector3.Dot(cross / area2, fnHit); // -1..1, winding-cancelled
|
|
if (aligned < cosThresh) cornerW += w; // deviates past the threshold angle
|
|
}
|
|
|
|
return totalW > 1e-9 ? (float)(cornerW / totalW) : 0f;
|
|
}
|
|
|
|
/// <summary>True if a local-space point lies inside the brush box (same |du|,|dv|,|dn| <= r bound the stamp paints).</summary>
|
|
private static bool InBrushBox(Vector3 p, Vector3 center, Vector3 u, Vector3 v, Vector3 n, float r)
|
|
{
|
|
Vector3 d = p - center;
|
|
return Mathf.Abs(Vector3.Dot(d, u)) <= r
|
|
&& Mathf.Abs(Vector3.Dot(d, v)) <= r
|
|
&& Mathf.Abs(Vector3.Dot(d, n)) <= r;
|
|
}
|
|
|
|
/// <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, out int hitTri)
|
|
{
|
|
pLocal = default; radiusLocal = 0f; u = default; v = default; nLocal = default; hitTri = -1;
|
|
|
|
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;
|
|
hitTri = t;
|
|
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
|
|
}
|
|
|
|
// --- Shift/Ctrl modifiers (paint mode) ---
|
|
|
|
private bool _pipetteModHeld; // edge tracking for the momentary pipette (Shift or Ctrl)
|
|
private bool _pipetteBeforeMod; // pipette toggle state to restore when the modifier is released
|
|
|
|
/// <summary>
|
|
/// While Shift or Ctrl is held the brush enters the pipette (eyedropper), restoring the toggle's
|
|
/// prior state on release. Shift or Ctrl + the mouse wheel resizes the brush (the orbit camera
|
|
/// ignores the wheel while either is held, so it does not also zoom). Called every paint-mode frame.
|
|
/// </summary>
|
|
private void HandleBrushModifiers()
|
|
{
|
|
// Momentary pipette: arm on the Shift/Ctrl press (remembering the toggle's state), force it on
|
|
// while held (re-arms after a one-shot pick), and restore the prior state on release.
|
|
bool shift = ShiftHeld();
|
|
bool ctrl = CtrlHeld();
|
|
bool mod = shift || ctrl;
|
|
if (mod && !_pipetteModHeld) _pipetteBeforeMod = pipette;
|
|
if (mod) pipette = true;
|
|
else if (_pipetteModHeld) pipette = _pipetteBeforeMod;
|
|
_pipetteModHeld = mod;
|
|
|
|
// Shift or Ctrl + wheel resizes the brush (the orbit camera ignores the wheel while either is
|
|
// held, so it does not also zoom).
|
|
if (!mod) return;
|
|
|
|
float scroll = ReadScroll();
|
|
if (Mathf.Approximately(scroll, 0f)) return;
|
|
|
|
int step = Mathf.Max(1, brushSizeScrollStep);
|
|
int next = Mathf.Clamp(brushSize + (int)Mathf.Sign(scroll) * step, 1, 256);
|
|
if (next == brushSize) return;
|
|
|
|
brushSize = next;
|
|
BrushSizeChanged?.Invoke();
|
|
}
|
|
|
|
private static bool ShiftHeld()
|
|
{
|
|
#if ENABLE_INPUT_SYSTEM
|
|
var kb = Keyboard.current;
|
|
if (kb != null) return kb.leftShiftKey.isPressed || kb.rightShiftKey.isPressed;
|
|
#endif
|
|
#if ENABLE_LEGACY_INPUT_MANAGER
|
|
return Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift);
|
|
#else
|
|
return false;
|
|
#endif
|
|
}
|
|
|
|
private static bool CtrlHeld()
|
|
{
|
|
#if ENABLE_INPUT_SYSTEM
|
|
var kb = Keyboard.current;
|
|
if (kb != null) return kb.leftCtrlKey.isPressed || kb.rightCtrlKey.isPressed;
|
|
#endif
|
|
#if ENABLE_LEGACY_INPUT_MANAGER
|
|
return Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl);
|
|
#else
|
|
return false;
|
|
#endif
|
|
}
|
|
|
|
private static float ReadScroll()
|
|
{
|
|
#if ENABLE_INPUT_SYSTEM
|
|
if (Mouse.current != null) return Mouse.current.scroll.ReadValue().y;
|
|
#endif
|
|
#if ENABLE_LEGACY_INPUT_MANAGER
|
|
return Input.GetAxis("Mouse ScrollWheel");
|
|
#else
|
|
return 0f;
|
|
#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();
|
|
}
|
|
}
|