Extend the painter from a single albedo map to three paint maps: albedo (_BaseMap), packed metallic+smoothness (_MetallicGlossMap, R=metallic A=smoothness), and emission (_EmissionMap/_EmissionColor). - Per-channel toggles (paint albedo / metallic+smoothness / emission) - Metallic/Smoothness sliders and emission color + intensity brush params - One brush shader drives all maps; metallic-smoothness uses a work layout (R=metallic G=smoothness A=coverage) and a pack pass (Hidden/MeshPaintPackMS) to the URP layout after each stroke - GPU and CPU backends mirror all three maps with the same mask and seam-dilation logic - Material keywords (_METALLICSPECGLOSSMAP, _EMISSION) enabled and the new maps seeded from the original material values
1486 lines
59 KiB
C#
1486 lines
59 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 onto the mesh textures with the
|
|
/// mouse. Three paint maps are driven at once (each can be toggled): albedo color
|
|
/// (_BaseMap), metallic + smoothness (_MetallicGlossMap, R = metallic, A = smoothness)
|
|
/// and emission (_EmissionMap). 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 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 the metallic + smoothness map (_MetallicGlossMap).")]
|
|
public bool paintMetallicSmoothness = 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")]
|
|
[Tooltip("Metallic value painted into the map (0 = dielectric, 1 = metal).")]
|
|
[Range(0f, 1f)]
|
|
public float metallic = 0f;
|
|
|
|
[Tooltip("Smoothness value painted into the map (0 = rough, 1 = glossy).")]
|
|
[Range(0f, 1f)]
|
|
public float smoothness = 0.5f;
|
|
|
|
[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;
|
|
|
|
[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;
|
|
private const int MAP_MS = 1; // metallic (R) + smoothness (A)
|
|
private const int MAP_EM = 2; // emission (RGB)
|
|
private const int MAP_COUNT = 3;
|
|
|
|
/// <summary>GPU backend per map: a display RenderTexture + scratch for the dilation ping-pong.</summary>
|
|
private class GpuMap
|
|
{
|
|
public RenderTexture display; // painted into (work RT for the MS map)
|
|
public RenderTexture temp; // dilation ping-pong scratch
|
|
public RenderTexture final; // MS only: packed (R=metallic, A=smoothness) target; null otherwise
|
|
public bool drew; // anything drawn this stroke?
|
|
public bool sRGB; // sRGB color map (albedo/emission) vs linear data map (MS)
|
|
}
|
|
|
|
/// <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;
|
|
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;
|
|
|
|
// Seed colors per map in canonical (material) layout, built once at Start:
|
|
// albedo -> RGB color
|
|
// MS -> R = metallic, A = smoothness
|
|
// emission-> RGB emission
|
|
private Color32[][] _seedCanonical;
|
|
|
|
// --- backends ---
|
|
private GpuMap[] _gpu;
|
|
private CpuMap[] _cpu;
|
|
private readonly List<int> _dilateScratch = new List<int>(512);
|
|
private readonly RaycastHit[] _rayHits = new RaycastHit[32];
|
|
|
|
public Shader brushShader; // optional; else Shader.Find("Hidden/MeshPaintBrush")
|
|
public Shader dilateShader; // optional; else Shader.Find("Hidden/MeshPaintDilate")
|
|
public Shader packMsShader; // optional; else Shader.Find("Hidden/MeshPaintPackMS")
|
|
private Material _brushMat;
|
|
private Material _dilateMat;
|
|
private Material _packMat;
|
|
private bool _prevSRGB;
|
|
|
|
// --- 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 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 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();
|
|
SetupBackend(useGpu, _seedCanonical);
|
|
ConfigureMaterialPbr();
|
|
|
|
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);
|
|
}
|
|
|
|
// --- 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 per-map seed colors (in canonical/material layout) from the existing material.</summary>
|
|
private void BuildSeedColors()
|
|
{
|
|
_seedCanonical = new Color32[MAP_COUNT][];
|
|
_seedCanonical[MAP_ALBEDO] = BuildBaseColors();
|
|
|
|
// Metallic / smoothness from the material scalars (no per-texel map here).
|
|
float metallic0 = _materialInstance.HasProperty(MetallicId) ? _materialInstance.GetFloat(MetallicId) : 0f;
|
|
float smoothness0 = _materialInstance.HasProperty(SmoothnessId) ? _materialInstance.GetFloat(SmoothnessId) : 0.5f;
|
|
_seedCanonical[MAP_MS] = FillCanonical(new Color32(
|
|
(byte)(Mathf.Clamp01(metallic0) * 255f), 0, 0, (byte)(Mathf.Clamp01(smoothness0) * 255f)));
|
|
|
|
// Emission: keep the existing look (black if the material's emission is off).
|
|
Color emission0 = Color.black;
|
|
if (_materialInstance.HasProperty(EmissionColorId) &&
|
|
(_materialInstance.globalIlluminationFlags & MaterialGlobalIlluminationFlags.EmissiveIsBlack) == 0)
|
|
emission0 = _materialInstance.GetColor(EmissionColorId);
|
|
_seedCanonical[MAP_EM] = FillCanonical(new Color32(
|
|
(byte)(Mathf.Clamp01(emission0.r) * 255f),
|
|
(byte)(Mathf.Clamp01(emission0.g) * 255f),
|
|
(byte)(Mathf.Clamp01(emission0.b) * 255f), 255));
|
|
}
|
|
|
|
private Color32[] FillCanonical(Color32 c)
|
|
{
|
|
var px = new Color32[textureSize * textureSize];
|
|
for (int i = 0; i < px.Length; i++) px[i] = c;
|
|
return px;
|
|
}
|
|
|
|
/// <summary>Builds the albedo 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;
|
|
}
|
|
|
|
/// <summary>Enables the URP keywords for the painted PBR maps and seeds the material scalars.</summary>
|
|
private void ConfigureMaterialPbr()
|
|
{
|
|
if (!_materialInstance) return;
|
|
|
|
// Metallic-smoothness map: metallic from R, smoothness from the map's alpha
|
|
// (NOT the albedo alpha) scaled by _Smoothness (= 1 so the map drives it as-is).
|
|
if (_materialInstance.HasProperty(MetallicGlossMapId))
|
|
{
|
|
_materialInstance.EnableKeyword("_METALLICSPECGLOSSMAP");
|
|
_materialInstance.DisableKeyword("_SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A");
|
|
if (_materialInstance.HasProperty(SmoothnessId)) _materialInstance.SetFloat(SmoothnessId, 1f);
|
|
}
|
|
|
|
// Emission map x _EmissionColor (white * intensity). Clear the "emissive is black"
|
|
// GI flag so the emission is actually used.
|
|
if (_materialInstance.HasProperty(EmissionMapId))
|
|
{
|
|
_materialInstance.EnableKeyword("_EMISSION");
|
|
_materialInstance.globalIlluminationFlags = MaterialGlobalIlluminationFlags.RealtimeEmissive;
|
|
RefreshEmissionColor();
|
|
}
|
|
}
|
|
|
|
/// <summary>Pushes the global emission intensity to the material's _EmissionColor (live-tunable).</summary>
|
|
private void RefreshEmissionColor()
|
|
{
|
|
if (_materialInstance && _materialInstance.HasProperty(EmissionColorId))
|
|
_materialInstance.SetColor(EmissionColorId, Color.white * Mathf.Max(0f, emissionIntensity));
|
|
}
|
|
|
|
// --- Backend selection / switching ---
|
|
|
|
private void SetupBackend(bool gpu, Color32[][] canonical)
|
|
{
|
|
if (gpu) SetupGpu(canonical);
|
|
else SetupCpu(canonical);
|
|
_gpuActive = gpu;
|
|
}
|
|
|
|
/// <summary>Switches the backend at runtime, carrying over all painted maps.</summary>
|
|
private void SwitchBackend(bool toGpu)
|
|
{
|
|
var canonical = new Color32[MAP_COUNT][];
|
|
for (int mi = 0; mi < MAP_COUNT; mi++) canonical[mi] = ReadCanonical(mi);
|
|
|
|
if (_gpuActive) TeardownGpu();
|
|
else TeardownCpu();
|
|
|
|
SetupBackend(toGpu, canonical);
|
|
_hasLastScreen = false;
|
|
|
|
Debug.Log($"[MeshPainter] Backend switched -> {(toGpu ? "GPU" : "CPU")} painting.", this);
|
|
}
|
|
|
|
/// <summary>Reads a map from the active backend into canonical (material) layout.</summary>
|
|
private Color32[] ReadCanonical(int mi)
|
|
{
|
|
if (_gpuActive)
|
|
{
|
|
var gm = _gpu[mi];
|
|
// MS: read the packed final RT (R=metallic, A=smoothness). Others: the display RT.
|
|
return (mi == MAP_MS) ? ReadRtPixels(gm.final, linear: true)
|
|
: ReadRtPixels(gm.display, linear: false);
|
|
}
|
|
return (Color32[])_cpu[mi].pixels.Clone();
|
|
}
|
|
|
|
private static bool MapIsLinear(int mi) => mi == MAP_MS;
|
|
|
|
private string[] MapProps(int mi)
|
|
{
|
|
switch (mi)
|
|
{
|
|
case MAP_ALBEDO: return new[] { "_BaseMap", "_MainTex" };
|
|
case MAP_MS: return new[] { "_MetallicGlossMap" };
|
|
default: return new[] { "_EmissionMap" };
|
|
}
|
|
}
|
|
|
|
private void AssignMapTexture(int mi, Texture tex)
|
|
{
|
|
if (!_materialInstance) return;
|
|
foreach (var p in MapProps(mi))
|
|
{
|
|
int id = Shader.PropertyToID(p);
|
|
if (_materialInstance.HasProperty(id)) _materialInstance.SetTexture(id, tex);
|
|
}
|
|
}
|
|
|
|
// --- CPU backend ---
|
|
|
|
private void SetupCpu(Color32[][] canonical)
|
|
{
|
|
_cpu = new CpuMap[MAP_COUNT];
|
|
for (int mi = 0; mi < MAP_COUNT; mi++)
|
|
{
|
|
var cm = new CpuMap
|
|
{
|
|
tex = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, MapIsLinear(mi))
|
|
{
|
|
name = "MeshPainter_Canvas_" + mi,
|
|
wrapMode = TextureWrapMode.Clamp,
|
|
filterMode = FilterMode.Bilinear
|
|
},
|
|
pixels = SeedCpuPixels(mi, canonical),
|
|
painted = new bool[textureSize * textureSize],
|
|
fillStamp = new int[textureSize * textureSize],
|
|
dilateStamp = 0
|
|
};
|
|
cm.tex.SetPixels32(cm.pixels);
|
|
cm.tex.Apply(false);
|
|
_cpu[mi] = cm;
|
|
AssignMapTexture(mi, cm.tex);
|
|
}
|
|
}
|
|
|
|
/// <summary>CPU pixels are stored directly in material layout; just guarantee a sane alpha for the color maps.</summary>
|
|
private Color32[] SeedCpuPixels(int mi, Color32[][] canonical)
|
|
{
|
|
var src = (canonical != null && canonical[mi] != null && canonical[mi].Length == textureSize * textureSize)
|
|
? canonical[mi] : _seedCanonical[mi];
|
|
var px = (Color32[])src.Clone();
|
|
if (mi != MAP_MS) // albedo / emission: opaque (alpha unused by the material)
|
|
for (int i = 0; i < px.Length; i++) px[i].a = 255;
|
|
return px;
|
|
}
|
|
|
|
private void TeardownCpu()
|
|
{
|
|
if (_cpu == null) return;
|
|
foreach (var cm in _cpu) if (cm != null && cm.tex) Destroy(cm.tex);
|
|
_cpu = null;
|
|
}
|
|
|
|
// --- GPU backend ---
|
|
|
|
private void SetupGpu(Color32[][] canonical)
|
|
{
|
|
if (!brushShader) brushShader = Shader.Find("Hidden/MeshPaintBrush");
|
|
if (!dilateShader) dilateShader = Shader.Find("Hidden/MeshPaintDilate");
|
|
if (!packMsShader) packMsShader = Shader.Find("Hidden/MeshPaintPackMS");
|
|
|
|
if (!brushShader || !dilateShader || !packMsShader)
|
|
{
|
|
Debug.LogError("[MeshPainter] GPU shaders not found (Hidden/MeshPaintBrush, " +
|
|
"Hidden/MeshPaintDilate or Hidden/MeshPaintPackMS). Falling back to CPU painting.", this);
|
|
useGpu = false;
|
|
SetupCpu(canonical);
|
|
_gpuActive = false;
|
|
return;
|
|
}
|
|
|
|
_brushMat = new Material(brushShader) { hideFlags = HideFlags.HideAndDontSave };
|
|
_dilateMat = new Material(dilateShader) { hideFlags = HideFlags.HideAndDontSave };
|
|
_packMat = new Material(packMsShader) { hideFlags = HideFlags.HideAndDontSave };
|
|
|
|
_gpu = new GpuMap[MAP_COUNT];
|
|
for (int mi = 0; mi < MAP_COUNT; mi++)
|
|
{
|
|
bool linear = MapIsLinear(mi);
|
|
var gm = new GpuMap
|
|
{
|
|
sRGB = !linear,
|
|
display = CreateRT("MeshPainter_DisplayRT_" + mi, linear),
|
|
temp = CreateRT("MeshPainter_TempRT_" + mi, linear),
|
|
final = (mi == MAP_MS) ? CreateRT("MeshPainter_FinalRT_" + mi, linear) : null
|
|
};
|
|
_gpu[mi] = gm;
|
|
|
|
SeedGpuMap(mi, canonical);
|
|
|
|
// Color maps render to the display RT; the MS map exposes its packed final RT.
|
|
AssignMapTexture(mi, gm.final != null ? gm.final : gm.display);
|
|
}
|
|
}
|
|
|
|
/// <summary>Seeds a GPU map's display RT from canonical data (alpha = 0 = unpainted), then packs MS to final.</summary>
|
|
private void SeedGpuMap(int mi, Color32[][] canonical)
|
|
{
|
|
var src = (canonical != null && canonical[mi] != null && canonical[mi].Length == textureSize * textureSize)
|
|
? canonical[mi] : _seedCanonical[mi];
|
|
var gm = _gpu[mi];
|
|
|
|
if (mi == MAP_MS)
|
|
{
|
|
// Canonical (R=metallic, A=smoothness) -> work layout (R=metallic, G=smoothness, B=0).
|
|
var work = new Color32[src.Length];
|
|
for (int i = 0; i < src.Length; i++) work[i] = new Color32(src[i].r, src[i].a, 0, 0);
|
|
UploadColorsToRT(work, gm.display, paintedAlpha: false, linear: true);
|
|
PackMs(gm); // build the final RT once so the material shows the seeded values
|
|
}
|
|
else
|
|
{
|
|
UploadColorsToRT((Color32[])src.Clone(), gm.display, paintedAlpha: false, linear: gm.sRGB == false);
|
|
}
|
|
}
|
|
|
|
private void TeardownGpu()
|
|
{
|
|
if (_gpu != null)
|
|
{
|
|
foreach (var gm in _gpu)
|
|
{
|
|
if (gm == null) continue;
|
|
if (gm.display) { gm.display.Release(); Destroy(gm.display); }
|
|
if (gm.temp) { gm.temp.Release(); Destroy(gm.temp); }
|
|
if (gm.final) { gm.final.Release(); Destroy(gm.final); }
|
|
}
|
|
_gpu = null;
|
|
}
|
|
if (_brushMat) { Destroy(_brushMat); _brushMat = null; }
|
|
if (_dilateMat) { Destroy(_dilateMat); _dilateMat = null; }
|
|
if (_packMat) { Destroy(_packMat); _packMat = null; }
|
|
}
|
|
|
|
private RenderTexture CreateRT(string rtName, bool linear)
|
|
{
|
|
var rt = new RenderTexture(textureSize, textureSize, 0, RenderTextureFormat.ARGB32,
|
|
linear ? RenderTextureReadWrite.Linear : RenderTextureReadWrite.sRGB)
|
|
{
|
|
name = rtName,
|
|
wrapMode = TextureWrapMode.Clamp,
|
|
filterMode = FilterMode.Bilinear,
|
|
antiAliasing = 1,
|
|
useMipMap = false,
|
|
autoGenerateMips = false
|
|
};
|
|
rt.Create();
|
|
return rt;
|
|
}
|
|
|
|
/// <summary>Writes Color32 data into a RenderTexture via a temporary texture.</summary>
|
|
private void UploadColorsToRT(Color32[] colors, RenderTexture rt, bool paintedAlpha, bool linear)
|
|
{
|
|
var tmp = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, linear);
|
|
// Alpha marks painted (255) vs unpainted (0) for the dilation pass.
|
|
byte a = paintedAlpha ? (byte)255 : (byte)0;
|
|
for (int i = 0; i < colors.Length; i++) colors[i].a = a;
|
|
tmp.SetPixels32(colors);
|
|
tmp.Apply(false);
|
|
|
|
bool prev = GL.sRGBWrite;
|
|
GL.sRGBWrite = !linear;
|
|
Graphics.Blit(tmp, rt);
|
|
GL.sRGBWrite = prev;
|
|
Destroy(tmp);
|
|
}
|
|
|
|
/// <summary>Reads a RenderTexture back as Color32 (for the backend switch).</summary>
|
|
private Color32[] ReadRtPixels(RenderTexture rt, bool linear)
|
|
{
|
|
var prev = RenderTexture.active;
|
|
RenderTexture.active = rt;
|
|
var tex = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, linear);
|
|
tex.ReadPixels(new Rect(0, 0, textureSize, textureSize), 0, 0, false);
|
|
tex.Apply(false);
|
|
RenderTexture.active = prev;
|
|
var px = tex.GetPixels32();
|
|
Destroy(tex);
|
|
return px;
|
|
}
|
|
|
|
private void PackMs(GpuMap gm)
|
|
{
|
|
bool prev = GL.sRGBWrite;
|
|
GL.sRGBWrite = false; // linear data, no conversion
|
|
Graphics.Blit(gm.display, gm.final, _packMat, 0);
|
|
GL.sRGBWrite = prev;
|
|
}
|
|
|
|
// --- 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 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;
|
|
}
|
|
|
|
// --- 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);
|
|
_previewMat.SetTexture(MaskTexId, _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)
|
|
{
|
|
_hasLastScreen = false;
|
|
return;
|
|
}
|
|
|
|
if (updateColliderEveryFrame && _skinned)
|
|
{
|
|
BakeColliderMesh();
|
|
BuildMeshCaches();
|
|
}
|
|
|
|
ResolveActiveMask();
|
|
|
|
if (!TryGetMouse(out bool pressed, out Vector2 screenPos))
|
|
return;
|
|
|
|
// 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)
|
|
{
|
|
_hasLastScreen = false;
|
|
return;
|
|
}
|
|
|
|
if (!paintCamera) return;
|
|
|
|
bool anyChannel = paintAlbedo || paintMetallicSmoothness || paintEmission;
|
|
float moveDist = _hasLastScreen ? Vector2.Distance(_lastScreen, screenPos) : 0f;
|
|
|
|
// Move-only mode: stamp once on mouse-down, then only when the mouse actually
|
|
// moves - a held but stationary button no longer re-stamps the same spot.
|
|
bool stamp = anyChannel && (!_hasLastScreen || !paintOnMoveOnly || moveDist > MoveOnlyMinPixels);
|
|
|
|
if (stamp)
|
|
{
|
|
if (_gpuActive) BeginGpuStroke();
|
|
else ResetCpuDirty();
|
|
|
|
if (_hasLastScreen)
|
|
{
|
|
// 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
|
|
{
|
|
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;
|
|
|
|
if (_gpuActive) DrawBrushGpu(pLocal, radiusLocal, u, v, nLocal);
|
|
else PaintStamp3D(pLocal, radiusLocal, u, v, nLocal);
|
|
}
|
|
|
|
/// <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;
|
|
|
|
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 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;
|
|
}
|
|
|
|
// --- GPU painting ---
|
|
|
|
private void BeginGpuStroke()
|
|
{
|
|
_prevSRGB = GL.sRGBWrite;
|
|
for (int mi = 0; mi < MAP_COUNT; mi++)
|
|
if (IsChannelActive(mi)) _gpu[mi].drew = false;
|
|
}
|
|
|
|
private void DrawBrushGpu(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n)
|
|
{
|
|
for (int mi = 0; mi < MAP_COUNT; mi++)
|
|
{
|
|
if (!IsChannelActive(mi)) continue;
|
|
var gm = _gpu[mi];
|
|
|
|
Graphics.SetRenderTarget(gm.display);
|
|
GL.sRGBWrite = gm.sRGB; // sRGB color map vs linear data map (MS)
|
|
|
|
SetBrushColorForMap(mi);
|
|
_brushMat.SetTexture(MaskTexId, _resolvedMask);
|
|
_brushMat.SetVector(BrushCenterId, pLocal);
|
|
_brushMat.SetFloat(BrushRadiusId, radiusLocal);
|
|
_brushMat.SetVector(BrushUId, u);
|
|
_brushMat.SetVector(BrushVId, v);
|
|
_brushMat.SetVector(BrushNormalId, n);
|
|
if (!_brushMat.SetPass(0)) continue;
|
|
|
|
int sub = _bakedMesh.subMeshCount;
|
|
for (int i = 0; i < sub; i++)
|
|
Graphics.DrawMeshNow(_bakedMesh, Matrix4x4.identity, i);
|
|
|
|
gm.drew = true;
|
|
}
|
|
}
|
|
|
|
private void EndGpuStroke()
|
|
{
|
|
Graphics.SetRenderTarget(null);
|
|
|
|
for (int mi = 0; mi < MAP_COUNT; mi++)
|
|
{
|
|
if (!IsChannelActive(mi)) continue;
|
|
var gm = _gpu[mi];
|
|
|
|
// Edge padding against UV seams: dilate the display RT into temp, then swap.
|
|
if (gm.drew && paddingTexels > 0)
|
|
{
|
|
GL.sRGBWrite = gm.sRGB;
|
|
_dilateMat.SetInt(PadId, paddingTexels);
|
|
Graphics.Blit(gm.display, gm.temp, _dilateMat, 0);
|
|
|
|
var swap = gm.display; gm.display = gm.temp; gm.temp = swap;
|
|
if (gm.final == null) AssignMapTexture(mi, gm.display);
|
|
}
|
|
|
|
// MS: pack the work layout (R=metallic, G=smoothness) into the URP final layout.
|
|
if (gm.final != null) PackMs(gm);
|
|
}
|
|
|
|
GL.sRGBWrite = _prevSRGB;
|
|
}
|
|
|
|
/// <summary>Sets _BrushColor for the given map. Albedo/emission are colors; MS packs (metallic, smoothness) as raw data.</summary>
|
|
private void SetBrushColorForMap(int mi)
|
|
{
|
|
switch (mi)
|
|
{
|
|
case MAP_ALBEDO:
|
|
_brushMat.SetColor(BrushColorId, brushColor);
|
|
break;
|
|
case MAP_MS:
|
|
// SetVector (not SetColor) so the raw metallic/smoothness pass through unconverted.
|
|
_brushMat.SetVector(BrushColorId, new Vector4(Mathf.Clamp01(metallic), Mathf.Clamp01(smoothness), 0f, 1f));
|
|
break;
|
|
default: // emission
|
|
_brushMat.SetColor(BrushColorId, emissionColor);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private bool IsChannelActive(int mi)
|
|
{
|
|
switch (mi)
|
|
{
|
|
case MAP_ALBEDO: return paintAlbedo;
|
|
case MAP_MS: return paintMetallicSmoothness;
|
|
default: return paintEmission;
|
|
}
|
|
}
|
|
|
|
// --- CPU painting (rasterization + dilation) ---
|
|
|
|
private void ResetCpuDirty()
|
|
{
|
|
for (int mi = 0; mi < MAP_COUNT; mi++)
|
|
{
|
|
if (!IsChannelActive(mi)) continue;
|
|
var cm = _cpu[mi];
|
|
cm.dx0 = textureSize; cm.dy0 = textureSize; cm.dx1 = -1; cm.dy1 = -1;
|
|
}
|
|
}
|
|
|
|
private void EndCpuStroke()
|
|
{
|
|
for (int mi = 0; mi < MAP_COUNT; mi++)
|
|
{
|
|
if (!IsChannelActive(mi)) continue;
|
|
var cm = _cpu[mi];
|
|
if (paddingTexels > 0) DilatePadding(cm, paddingTexels);
|
|
FlushDirty(cm);
|
|
}
|
|
}
|
|
|
|
/// <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| <=
|
|
/// radius) over each active map, weighted by the mask opacity. Mirrors the GPU brush
|
|
/// path (mask value = per-texel opacity, gray = 50%).
|
|
/// </summary>
|
|
private void PaintStamp3D(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n)
|
|
{
|
|
bool aAlb = paintAlbedo, aMS = paintMetallicSmoothness, aEM = paintEmission;
|
|
if (!aAlb && !aMS && !aEM) return;
|
|
|
|
// Albedo target.
|
|
float br = brushColor.r * 255f, bg = brushColor.g * 255f, bb = brushColor.b * 255f;
|
|
float albOpMul = Mathf.Clamp01(brushColor.a);
|
|
CpuMap alb = _cpu[MAP_ALBEDO];
|
|
|
|
// Metallic-smoothness target (R=metallic, A=smoothness).
|
|
float msR = Mathf.Clamp01(metallic) * 255f, msA = Mathf.Clamp01(smoothness) * 255f;
|
|
CpuMap ms = _cpu[MAP_MS];
|
|
|
|
// Emission target.
|
|
float er = emissionColor.r * 255f, eg = emissionColor.g * 255f, eb = emissionColor.b * 255f;
|
|
float emOpMul = Mathf.Clamp01(emissionColor.a);
|
|
CpuMap em = _cpu[MAP_EM];
|
|
|
|
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.
|
|
float mu = du * invR * 0.5f + 0.5f;
|
|
float mv = dv * invR * 0.5f + 0.5f;
|
|
float maskOp = SampleMaskOpacity(mu, mv);
|
|
if (maskOp <= 0f) continue;
|
|
|
|
int idx = rowStart + x;
|
|
|
|
if (aAlb)
|
|
{
|
|
float op = maskOp * albOpMul;
|
|
if (op > 0f)
|
|
{
|
|
Color32 dst = alb.pixels[idx];
|
|
float it = 1f - op;
|
|
alb.pixels[idx] = new Color32(
|
|
(byte)(dst.r * it + br * op),
|
|
(byte)(dst.g * it + bg * op),
|
|
(byte)(dst.b * it + bb * op),
|
|
255);
|
|
alb.painted[idx] = true;
|
|
ExpandDirty(alb, x, y);
|
|
}
|
|
}
|
|
|
|
if (aMS)
|
|
{
|
|
// msOpacity = mask (no extra alpha multiplier).
|
|
Color32 dst = ms.pixels[idx];
|
|
float it = 1f - maskOp;
|
|
ms.pixels[idx] = new Color32(
|
|
(byte)(dst.r * it + msR * maskOp), // metallic
|
|
0, 0,
|
|
(byte)(dst.a * it + msA * maskOp)); // smoothness
|
|
ms.painted[idx] = true;
|
|
ExpandDirty(ms, x, y);
|
|
}
|
|
|
|
if (aEM)
|
|
{
|
|
float op = maskOp * emOpMul;
|
|
if (op > 0f)
|
|
{
|
|
Color32 dst = em.pixels[idx];
|
|
float it = 1f - op;
|
|
em.pixels[idx] = new Color32(
|
|
(byte)(dst.r * it + er * op),
|
|
(byte)(dst.g * it + eg * op),
|
|
(byte)(dst.b * it + eb * op),
|
|
255);
|
|
em.painted[idx] = true;
|
|
ExpandDirty(em, x, y);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void ExpandDirty(CpuMap cm, int x, int y)
|
|
{
|
|
if (x < cm.dx0) cm.dx0 = x;
|
|
if (x > cm.dx1) cm.dx1 = x;
|
|
if (y < cm.dy0) cm.dy0 = y;
|
|
if (y > cm.dy1) cm.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 (cm.painted).
|
|
/// </summary>
|
|
private void DilatePadding(CpuMap cm, int pad)
|
|
{
|
|
if (cm.dx1 < cm.dx0 || cm.dy1 < cm.dy0) return; // nothing painted
|
|
|
|
int x0 = Mathf.Max(0, cm.dx0 - pad);
|
|
int y0 = Mathf.Max(0, cm.dy0 - pad);
|
|
int x1 = Mathf.Min(textureSize - 1, cm.dx1 + pad);
|
|
int y1 = Mathf.Min(textureSize - 1, cm.dy1 + pad);
|
|
|
|
cm.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 (cm.painted[idx] || cm.fillStamp[idx] == cm.dilateStamp) continue;
|
|
|
|
if (TryFindPaddingSource(cm, x, y, out Color32 c))
|
|
{
|
|
cm.pixels[idx] = c;
|
|
_dilateScratch.Add(idx);
|
|
ExpandDirty(cm, x, y);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (int k = 0; k < _dilateScratch.Count; k++)
|
|
cm.fillStamp[_dilateScratch[k]] = cm.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(CpuMap cm, 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 (cm.painted[nidx] || cm.fillStamp[nidx] == cm.dilateStamp)
|
|
{
|
|
col = cm.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(CpuMap cm)
|
|
{
|
|
if (cm.dx1 < cm.dx0 || cm.dy1 < cm.dy0) return; // nothing painted
|
|
|
|
int bw = cm.dx1 - cm.dx0 + 1;
|
|
int bh = cm.dy1 - cm.dy0 + 1;
|
|
var block = new Color32[bw * bh];
|
|
for (int y = 0; y < bh; y++)
|
|
System.Array.Copy(cm.pixels, (cm.dy0 + y) * textureSize + cm.dx0, block, y * bw, bw);
|
|
|
|
cm.tex.SetPixels32(cm.dx0, cm.dy0, bw, bh, block);
|
|
cm.tex.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);
|
|
if (_previewMat) Destroy(_previewMat);
|
|
if (_previewQuad) Destroy(_previewQuad);
|
|
TeardownCpu();
|
|
TeardownGpu();
|
|
}
|
|
}
|