Introduce an extensible game mode system toggled with Tab: - Player Control: WASD movement with always-on mouse camera orbit - Paint: player frozen, left-click paints, right-click holds to orbit Add GameMode base class, GameModeManager, and the two concrete modes. Refactor controlled scripts to expose clean state flags instead of handling input themselves: ThirdPersonOrbitInput.LookMode, PlayerCharacterController.ControlEnabled, MeshPainter.PaintingEnabled. Also fix camera zoom: remove the obsolete /120 wheel normalization so one scroll notch maps to one zoomSpeed step under the new Input System. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
794 lines
30 KiB
C#
794 lines
30 KiB
C#
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
#if ENABLE_INPUT_SYSTEM
|
|
using UnityEngine.InputSystem;
|
|
#endif
|
|
|
|
/// <summary>
|
|
/// Live mesh painting in Play mode.
|
|
/// Paints a fixed color (brush) onto the mesh's texture with the mouse.
|
|
///
|
|
/// TWO BACKENDS (switchable via <see cref="useGpu"/>, also live in Play mode):
|
|
///
|
|
/// * GPU (default): True shader painting into a RenderTexture. The brush is
|
|
/// drawn via a vertex/fragment shader - the mesh is unwrapped into the texture
|
|
/// layout using its UVs, and per texel the actual 3D position is tested against
|
|
/// the brush sphere. Edge padding against seams runs as a GPU blit.
|
|
/// Fast, because the GPU rasterizes all texels in parallel.
|
|
///
|
|
/// * CPU: Rasterization of the hit triangles on the CPU; per texel the 3D
|
|
/// position is computed barycentrically and tested against the brush sphere.
|
|
/// Edge padding via CPU dilation. Identical visual result, but more expensive.
|
|
///
|
|
/// BOTH backends paint seamlessly: a 3D sphere around the hit point captures
|
|
/// triangles on both sides of a UV seam at once (front/back share the same 3D
|
|
/// edge), no matter where they lie in the UV layout.
|
|
///
|
|
/// Simply attach to the GameObject you want to paint (e.g. paintman_UV/paintman_UV).
|
|
/// </summary>
|
|
[DisallowMultipleComponent]
|
|
public class MeshPainter : MonoBehaviour
|
|
{
|
|
/// <summary>
|
|
/// When false, mouse painting is ignored (the canvas/material stays as-is).
|
|
/// Toggled by the GameModeManager (painting is only enabled in paint mode).
|
|
/// </summary>
|
|
public bool PaintingEnabled { get; set; } = true;
|
|
|
|
[Header("Backend")]
|
|
[Tooltip("ON = GPU painting (shader/RenderTexture, fast). OFF = CPU painting. " +
|
|
"Can be toggled live in Play mode - the painted content is carried over.")]
|
|
public bool useGpu = true;
|
|
|
|
[Header("Brush")]
|
|
[Tooltip("Color used for painting.")]
|
|
public Color brushColor = Color.red;
|
|
|
|
[Tooltip("Brush radius in texels (visual size). Converted into a 3D radius per hit.")]
|
|
[Range(1, 256)]
|
|
public int brushSize = 16;
|
|
|
|
[Header("Canvas")]
|
|
[Tooltip("Resolution of the paint texture (square).")]
|
|
public int textureSize = 1024;
|
|
|
|
[Tooltip("Base color of the texture at startup (if none exists).")]
|
|
public Color baseColor = Color.white;
|
|
|
|
[Tooltip("Edge padding (texels) against visible UV seams. The color is 'bled' beyond the " +
|
|
"UV island edges so that bilinear sampling there does not pull in the unpainted " +
|
|
"base color. 0 = off.")]
|
|
[Range(0, 16)]
|
|
public int paddingTexels = 4;
|
|
|
|
[Header("Setup")]
|
|
[Tooltip("Camera for the mouse ray. Empty = Camera.main.")]
|
|
public Camera paintCamera;
|
|
|
|
[Tooltip("Only for animated meshes: re-bake the collider every frame (expensive!).")]
|
|
public bool updateColliderEveryFrame = false;
|
|
|
|
// --- shared references ---
|
|
private Renderer _renderer;
|
|
private SkinnedMeshRenderer _skinned;
|
|
private MeshCollider _collider;
|
|
private Mesh _bakedMesh;
|
|
private Material _materialInstance;
|
|
|
|
// Mesh caches (in the transform's local space = collider space).
|
|
private Vector3[] _verts;
|
|
private int[] _tris;
|
|
private Vector2[] _uvs;
|
|
private Vector3[] _triCenter; // centroid per triangle (for fast CPU culling)
|
|
private float[] _triRadius; // circumscribed radius per triangle (for fast CPU culling)
|
|
private float _avgUvToLocal = 1f; // average conversion: UV unit -> local unit
|
|
|
|
private bool _hasLastScreen;
|
|
private Vector2 _lastScreen;
|
|
private bool _gpuActive; // currently configured backend
|
|
private bool _setupValid;
|
|
|
|
// --- CPU backend ---
|
|
private Texture2D _paintTexture;
|
|
private Color32[] _pixels;
|
|
private bool[] _painted; // true = painted by the brush (persistent dilation source)
|
|
private int[] _fillStamp; // transient marker for padding texels per dilation call
|
|
private int _dilateStamp; // incremented per call -> padding from previous frames does not count as a source
|
|
private readonly List<int> _dilateScratch = new List<int>(512);
|
|
private readonly RaycastHit[] _rayHits = new RaycastHit[32]; // buffer so other colliders (player capsule, etc.) cannot block painting
|
|
private int _dx0, _dy0, _dx1, _dy1; // dirty rectangle (CPU upload)
|
|
|
|
// --- GPU backend ---
|
|
public Shader brushShader; // optional; otherwise Shader.Find("Hidden/MeshPaintBrush")
|
|
public Shader dilateShader; // optional; otherwise Shader.Find("Hidden/MeshPaintDilate")
|
|
private Material _brushMat;
|
|
private Material _dilateMat;
|
|
private RenderTexture _displayRT; // assigned to the material as _BaseMap
|
|
private RenderTexture _tempRT; // scratch for the dilation blit (ping-pong)
|
|
private bool _drewThisStroke;
|
|
private bool _prevSRGB;
|
|
|
|
// Step size (in screen pixels) for interpolation along the mouse movement.
|
|
private const float ScreenStepPixels = 5f;
|
|
// Upper bound of raycasts per frame (safeguard for very fast movements).
|
|
private const int MaxStepsPerFrame = 256;
|
|
|
|
private static readonly int BaseMapId = Shader.PropertyToID("_BaseMap");
|
|
private static readonly int MainTexId = Shader.PropertyToID("_MainTex");
|
|
private static readonly int BaseColorId = Shader.PropertyToID("_BaseColor");
|
|
private static readonly int ColorId = Shader.PropertyToID("_Color");
|
|
private static readonly int BrushColorId = Shader.PropertyToID("_BrushColor");
|
|
private static readonly int BrushCenterId = Shader.PropertyToID("_BrushCenter");
|
|
private static readonly int BrushRadiusId = Shader.PropertyToID("_BrushRadius");
|
|
private static readonly int PadId = Shader.PropertyToID("_Pad");
|
|
|
|
private void Start()
|
|
{
|
|
if (paintCamera == null) paintCamera = Camera.main;
|
|
|
|
_renderer = GetComponent<Renderer>();
|
|
_skinned = GetComponent<SkinnedMeshRenderer>();
|
|
if (_renderer == null)
|
|
{
|
|
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.
|
|
_materialInstance = _renderer.material; // automatically creates an instance
|
|
// Tint to white so the painted color appears unaltered.
|
|
if (_materialInstance.HasProperty(BaseColorId)) _materialInstance.SetColor(BaseColorId, Color.white);
|
|
else if (_materialInstance.HasProperty(ColorId)) _materialInstance.SetColor(ColorId, Color.white);
|
|
|
|
SetupBackend(useGpu, fromColor32: BuildBaseColors());
|
|
_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 == null) _collider = gameObject.AddComponent<MeshCollider>();
|
|
_collider.convex = false; // non-convex provides triangleIndex / textureCoord
|
|
|
|
_bakedMesh = new Mesh { name = "MeshPainter_Baked" };
|
|
BakeColliderMesh();
|
|
}
|
|
|
|
private void BakeColliderMesh()
|
|
{
|
|
if (_skinned != null)
|
|
{
|
|
// 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 != null) _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 == null) return;
|
|
|
|
_verts = m.vertices;
|
|
_tris = m.triangles;
|
|
_uvs = m.uv;
|
|
|
|
// Fallback: UVs from the original mesh (BakeMesh usually keeps them, but better safe than sorry).
|
|
if ((_uvs == null || _uvs.Length != _verts.Length) && _skinned != null && _skinned.sharedMesh != null)
|
|
_uvs = _skinned.sharedMesh.uv;
|
|
|
|
if (_uvs == null || _uvs.Length != _verts.Length) return;
|
|
|
|
int triCount = _tris.Length / 3;
|
|
_triCenter = new Vector3[triCount];
|
|
_triRadius = new float[triCount];
|
|
|
|
double sumArea3D = 0.0, sumAreaUv = 0.0;
|
|
|
|
for (int t = 0; t < triCount; t++)
|
|
{
|
|
int i0 = _tris[t * 3], i1 = _tris[t * 3 + 1], i2 = _tris[t * 3 + 2];
|
|
Vector3 a = _verts[i0], b = _verts[i1], c = _verts[i2];
|
|
|
|
Vector3 center = (a + b + c) / 3f;
|
|
_triCenter[t] = center;
|
|
float ra = (a - center).sqrMagnitude;
|
|
float rb = (b - center).sqrMagnitude;
|
|
float rc = (c - center).sqrMagnitude;
|
|
_triRadius[t] = Mathf.Sqrt(Mathf.Max(ra, Mathf.Max(rb, rc)));
|
|
|
|
sumArea3D += 0.5 * Vector3.Cross(b - a, c - a).magnitude;
|
|
Vector2 ua = _uvs[i0], ub = _uvs[i1], uc = _uvs[i2];
|
|
sumAreaUv += 0.5 * Mathf.Abs(Cross2D(ub - ua, uc - ua));
|
|
}
|
|
|
|
if (sumAreaUv > 1e-9) _avgUvToLocal = (float)System.Math.Sqrt(sumArea3D / sumAreaUv);
|
|
}
|
|
|
|
/// <summary>Builds the base-color texels (copy of 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 != null)
|
|
{
|
|
if (shared.HasProperty(BaseMapId)) sourceTex = shared.GetTexture(BaseMapId);
|
|
if (sourceTex == null && shared.HasProperty(MainTexId)) sourceTex = shared.GetTexture(MainTexId);
|
|
}
|
|
|
|
var src = sourceTex as Texture2D;
|
|
if (src != null)
|
|
{
|
|
try
|
|
{
|
|
if (src.width == textureSize && src.height == textureSize && src.isReadable)
|
|
return src.GetPixels32();
|
|
}
|
|
catch { /* not readable -> fall back to baseColor */ }
|
|
}
|
|
|
|
Color32 c = baseColor;
|
|
for (int i = 0; i < px.Length; i++) px[i] = c;
|
|
return px;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Backend selection / switching
|
|
// ------------------------------------------------------------------
|
|
|
|
/// <summary>Sets up the desired backend and carries over the initial image content.</summary>
|
|
private void SetupBackend(bool gpu, Color32[] fromColor32)
|
|
{
|
|
if (gpu) SetupGpu(fromColor32);
|
|
else SetupCpu(fromColor32);
|
|
_gpuActive = gpu;
|
|
}
|
|
|
|
/// <summary>Switches the backend at runtime and carries over the currently painted content.</summary>
|
|
private void SwitchBackend(bool toGpu)
|
|
{
|
|
// save the current content as Color32
|
|
Color32[] current = (_gpuActive ? ReadGpuPixels() : (Color32[])_pixels.Clone());
|
|
|
|
if (_gpuActive) TeardownGpu();
|
|
else TeardownCpu();
|
|
|
|
SetupBackend(toGpu, current);
|
|
_hasLastScreen = false;
|
|
|
|
Debug.Log($"[MeshPainter] Backend switched -> {(toGpu ? "GPU" : "CPU")} painting.", this);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// CPU backend
|
|
// ------------------------------------------------------------------
|
|
|
|
private void SetupCpu(Color32[] initial)
|
|
{
|
|
_paintTexture = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, false)
|
|
{
|
|
name = "MeshPainter_Canvas",
|
|
wrapMode = TextureWrapMode.Clamp,
|
|
filterMode = FilterMode.Bilinear
|
|
};
|
|
|
|
_pixels = (initial != null && initial.Length == textureSize * textureSize)
|
|
? initial
|
|
: BuildBaseColors();
|
|
|
|
_painted = new bool[textureSize * textureSize];
|
|
_fillStamp = new int[textureSize * textureSize];
|
|
_dilateStamp = 0;
|
|
|
|
_paintTexture.SetPixels32(_pixels);
|
|
_paintTexture.Apply(false);
|
|
|
|
AssignTextureToMaterial(_paintTexture);
|
|
}
|
|
|
|
private void TeardownCpu()
|
|
{
|
|
if (_paintTexture != null) { Destroy(_paintTexture); _paintTexture = null; }
|
|
_pixels = null;
|
|
_painted = null;
|
|
_fillStamp = null;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// GPU backend
|
|
// ------------------------------------------------------------------
|
|
|
|
private void SetupGpu(Color32[] initial)
|
|
{
|
|
if (brushShader == null) brushShader = Shader.Find("Hidden/MeshPaintBrush");
|
|
if (dilateShader == null) dilateShader = Shader.Find("Hidden/MeshPaintDilate");
|
|
|
|
if (brushShader == null || dilateShader == null)
|
|
{
|
|
Debug.LogError("[MeshPainter] GPU shaders not found (Hidden/MeshPaintBrush or " +
|
|
"Hidden/MeshPaintDilate). Falling back to CPU painting.", this);
|
|
useGpu = false;
|
|
SetupCpu(initial);
|
|
_gpuActive = false;
|
|
return;
|
|
}
|
|
|
|
_brushMat = new Material(brushShader) { hideFlags = HideFlags.HideAndDontSave };
|
|
_dilateMat = new Material(dilateShader) { hideFlags = HideFlags.HideAndDontSave };
|
|
|
|
_displayRT = CreateRT("MeshPainter_DisplayRT");
|
|
_tempRT = CreateRT("MeshPainter_TempRT");
|
|
|
|
// Bring the initial image content into the display RT. Alpha = 0 marks "unpainted"
|
|
// (so the base color is not mistaken for a dilation source).
|
|
var seed = initial ?? BuildBaseColors();
|
|
UploadColorsToRT(seed, _displayRT, paintedAlpha: false);
|
|
|
|
AssignTextureToMaterial(_displayRT);
|
|
}
|
|
|
|
private void TeardownGpu()
|
|
{
|
|
if (_displayRT != null) { _displayRT.Release(); Destroy(_displayRT); _displayRT = null; }
|
|
if (_tempRT != null) { _tempRT.Release(); Destroy(_tempRT); _tempRT = null; }
|
|
if (_brushMat != null) { Destroy(_brushMat); _brushMat = null; }
|
|
if (_dilateMat != null) { Destroy(_dilateMat); _dilateMat = null; }
|
|
}
|
|
|
|
private RenderTexture CreateRT(string rtName)
|
|
{
|
|
var rt = new RenderTexture(textureSize, textureSize, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB)
|
|
{
|
|
name = rtName,
|
|
wrapMode = TextureWrapMode.Clamp,
|
|
filterMode = FilterMode.Bilinear,
|
|
antiAliasing = 1,
|
|
useMipMap = false,
|
|
autoGenerateMips = false
|
|
};
|
|
rt.Create();
|
|
return rt;
|
|
}
|
|
|
|
/// <summary>Writes Color32 data into a RenderTexture via a temporary texture.</summary>
|
|
private void UploadColorsToRT(Color32[] colors, RenderTexture rt, bool paintedAlpha)
|
|
{
|
|
var tmp = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, false);
|
|
if (paintedAlpha)
|
|
{
|
|
// mark the entire content as "painted" (alpha = 255) -> no dilation needed.
|
|
for (int i = 0; i < colors.Length; i++) colors[i].a = 255;
|
|
}
|
|
else
|
|
{
|
|
// mark the base color as "unpainted" (alpha = 0).
|
|
for (int i = 0; i < colors.Length; i++) colors[i].a = 0;
|
|
}
|
|
tmp.SetPixels32(colors);
|
|
tmp.Apply(false);
|
|
Graphics.Blit(tmp, rt);
|
|
Destroy(tmp);
|
|
}
|
|
|
|
/// <summary>Reads the display RT back as Color32 (for the backend switch).</summary>
|
|
private Color32[] ReadGpuPixels()
|
|
{
|
|
var prev = RenderTexture.active;
|
|
RenderTexture.active = _displayRT;
|
|
var tex = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, false);
|
|
tex.ReadPixels(new Rect(0, 0, textureSize, textureSize), 0, 0, false);
|
|
tex.Apply(false);
|
|
RenderTexture.active = prev;
|
|
var px = tex.GetPixels32();
|
|
Destroy(tex);
|
|
return px;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Material
|
|
// ------------------------------------------------------------------
|
|
|
|
private void AssignTextureToMaterial(Texture tex)
|
|
{
|
|
if (_materialInstance == null) return;
|
|
if (_materialInstance.HasProperty(BaseMapId)) _materialInstance.SetTexture(BaseMapId, tex);
|
|
if (_materialInstance.HasProperty(MainTexId)) _materialInstance.SetTexture(MainTexId, tex);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Update / input
|
|
// ------------------------------------------------------------------
|
|
|
|
private void Update()
|
|
{
|
|
if (!_setupValid) return;
|
|
|
|
// Live backend switching.
|
|
if (useGpu != _gpuActive) SwitchBackend(useGpu);
|
|
|
|
// Painting disabled by the game mode (e.g. player-control mode): do nothing.
|
|
if (!PaintingEnabled)
|
|
{
|
|
_hasLastScreen = false;
|
|
return;
|
|
}
|
|
|
|
if (updateColliderEveryFrame && _skinned != null)
|
|
{
|
|
BakeColliderMesh();
|
|
BuildMeshCaches();
|
|
}
|
|
|
|
if (!TryGetMouse(out bool pressed, out Vector2 screenPos))
|
|
return;
|
|
|
|
if (!pressed)
|
|
{
|
|
_hasLastScreen = false;
|
|
return;
|
|
}
|
|
|
|
if (paintCamera == null) return;
|
|
|
|
if (_gpuActive) BeginGpuStroke();
|
|
else { _dx0 = textureSize; _dy0 = textureSize; _dx1 = -1; _dy1 = -1; }
|
|
|
|
if (_hasLastScreen)
|
|
{
|
|
// Interpolate along the mouse movement in screen space and re-raycast at
|
|
// each step -> the stroke follows the mesh surface.
|
|
float dist = Vector2.Distance(_lastScreen, screenPos);
|
|
int steps = Mathf.Clamp(Mathf.CeilToInt(dist / ScreenStepPixels), 1, MaxStepsPerFrame);
|
|
for (int s = 1; s <= steps; s++)
|
|
PaintAtScreen(Vector2.Lerp(_lastScreen, screenPos, s / (float)steps));
|
|
}
|
|
else
|
|
{
|
|
PaintAtScreen(screenPos);
|
|
}
|
|
|
|
if (_gpuActive) EndGpuStroke();
|
|
else
|
|
{
|
|
if (paddingTexels > 0) DilatePadding(paddingTexels);
|
|
FlushDirty();
|
|
}
|
|
|
|
_lastScreen = screenPos;
|
|
_hasLastScreen = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Raycast at a screen position. If the ray hits the mesh, a 3D sphere is painted
|
|
/// around the hit point (seamless) - via GPU or CPU depending on the backend.
|
|
/// </summary>
|
|
private void PaintAtScreen(Vector2 screenPos)
|
|
{
|
|
Ray ray = paintCamera.ScreenPointToRay(screenPos);
|
|
|
|
// Collect all hits and pick the nearest one that belongs to the paint mesh.
|
|
// A plain Physics.Raycast returns only the closest collider, so anything in
|
|
// front of the mesh (the player's CharacterController capsule, future props,
|
|
// the camera-collision proxy) would block painting. Filtering by collider
|
|
// keeps painting working from every camera angle.
|
|
int hitCount = Physics.RaycastNonAlloc(ray, _rayHits, 1000f, ~0, QueryTriggerInteraction.Ignore);
|
|
RaycastHit hit = default;
|
|
float nearest = float.MaxValue;
|
|
bool found = false;
|
|
for (int i = 0; i < hitCount; i++)
|
|
{
|
|
if (_rayHits[i].collider != _collider) continue;
|
|
if (_rayHits[i].distance < nearest)
|
|
{
|
|
nearest = _rayHits[i].distance;
|
|
hit = _rayHits[i];
|
|
found = true;
|
|
}
|
|
}
|
|
if (!found) return;
|
|
if (_verts == null || hit.triangleIndex < 0) return;
|
|
|
|
// Bring the hit point into local mesh space (= collider/vertex space).
|
|
Vector3 pLocal = transform.InverseTransformPoint(hit.point);
|
|
|
|
// Convert the brush radius (texels) into a local 3D radius using the UV
|
|
// density of the hit triangle -> the brush stays visually the same 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);
|
|
// guard against degenerate UVs: keep close to the average.
|
|
scale = Mathf.Clamp(s, _avgUvToLocal * 0.25f, _avgUvToLocal * 4f);
|
|
}
|
|
}
|
|
|
|
float radiusLocal = (brushSize / (float)textureSize) * scale;
|
|
if (radiusLocal <= 0f) return;
|
|
|
|
if (_gpuActive) DrawBrushGpu(pLocal, radiusLocal);
|
|
else PaintSphere3D(pLocal, radiusLocal);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// GPU painting
|
|
// ------------------------------------------------------------------
|
|
|
|
private void BeginGpuStroke()
|
|
{
|
|
_drewThisStroke = false;
|
|
_prevSRGB = GL.sRGBWrite;
|
|
GL.sRGBWrite = true; // correct sRGB encoding when writing into the sRGB RT (linear project)
|
|
Graphics.SetRenderTarget(_displayRT);
|
|
_brushMat.SetColor(BrushColorId, brushColor);
|
|
}
|
|
|
|
private void DrawBrushGpu(Vector3 pLocal, float radiusLocal)
|
|
{
|
|
_brushMat.SetVector(BrushCenterId, pLocal);
|
|
_brushMat.SetFloat(BrushRadiusId, radiusLocal);
|
|
if (!_brushMat.SetPass(0)) return;
|
|
|
|
int sub = _bakedMesh.subMeshCount;
|
|
for (int i = 0; i < sub; i++)
|
|
Graphics.DrawMeshNow(_bakedMesh, Matrix4x4.identity, i);
|
|
|
|
_drewThisStroke = true;
|
|
}
|
|
|
|
private void EndGpuStroke()
|
|
{
|
|
Graphics.SetRenderTarget(null);
|
|
|
|
// Edge padding against UV seams: blit the display RT through the dilation
|
|
// shader into _tempRT and then swap (the result ends up in _displayRT again).
|
|
if (_drewThisStroke && paddingTexels > 0)
|
|
{
|
|
_dilateMat.SetInt(PadId, paddingTexels);
|
|
Graphics.Blit(_displayRT, _tempRT, _dilateMat, 0);
|
|
|
|
var swap = _displayRT; _displayRT = _tempRT; _tempRT = swap;
|
|
AssignTextureToMaterial(_displayRT);
|
|
}
|
|
|
|
GL.sRGBWrite = _prevSRGB;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// CPU painting (rasterization + dilation)
|
|
// ------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Paints a sphere in local 3D space: rasterizes all triangles in range into
|
|
/// the texture and colors every texel whose 3D position lies inside the sphere.
|
|
/// </summary>
|
|
private void PaintSphere3D(Vector3 pLocal, float radiusLocal)
|
|
{
|
|
Color32 col = brushColor;
|
|
float r2 = radiusLocal * radiusLocal;
|
|
int triCount = _tris.Length / 3;
|
|
float ts = textureSize;
|
|
|
|
for (int t = 0; t < triCount; t++)
|
|
{
|
|
// Fast culling: sphere against the triangle's circumscribed circle.
|
|
float reach = radiusLocal + _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;
|
|
|
|
// Bounding box in pixel space.
|
|
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; // small 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;
|
|
|
|
float ddx = wx - pLocal.x, ddy = wy - pLocal.y, ddz = wz - pLocal.z;
|
|
if (ddx * ddx + ddy * ddy + ddz * ddz > r2) continue;
|
|
|
|
_pixels[rowStart + x] = col;
|
|
_painted[rowStart + x] = true;
|
|
|
|
if (x < _dx0) _dx0 = x;
|
|
if (x > _dx1) _dx1 = x;
|
|
if (y < _dy0) _dy0 = y;
|
|
if (y > _dy1) _dy1 = y;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Edge padding / texture dilation against visible UV seams (CPU).
|
|
/// Colors a thin border (pad texels) around the freshly painted areas with their
|
|
/// color. The dilation source is exclusively brush-painted texels (_painted).
|
|
/// </summary>
|
|
private void DilatePadding(int pad)
|
|
{
|
|
if (_dx1 < _dx0 || _dy1 < _dy0) return; // nothing painted
|
|
|
|
int x0 = Mathf.Max(0, _dx0 - pad);
|
|
int y0 = Mathf.Max(0, _dy0 - pad);
|
|
int x1 = Mathf.Min(textureSize - 1, _dx1 + pad);
|
|
int y1 = Mathf.Min(textureSize - 1, _dy1 + pad);
|
|
|
|
_dilateStamp++; // unique per call -> padding from previous frames does not count as a source
|
|
|
|
for (int iter = 0; iter < pad; iter++)
|
|
{
|
|
_dilateScratch.Clear();
|
|
|
|
for (int y = y0; y <= y1; y++)
|
|
{
|
|
int row = y * textureSize;
|
|
for (int x = x0; x <= x1; x++)
|
|
{
|
|
int idx = row + x;
|
|
if (_painted[idx] || _fillStamp[idx] == _dilateStamp) continue;
|
|
|
|
if (TryFindPaddingSource(x, y, out Color32 c))
|
|
{
|
|
_pixels[idx] = c;
|
|
_dilateScratch.Add(idx);
|
|
|
|
if (x < _dx0) _dx0 = x;
|
|
if (x > _dx1) _dx1 = x;
|
|
if (y < _dy0) _dy0 = y;
|
|
if (y > _dy1) _dy1 = y;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (int k = 0; k < _dilateScratch.Count; k++)
|
|
_fillStamp[_dilateScratch[k]] = _dilateStamp;
|
|
|
|
if (_dilateScratch.Count == 0) break;
|
|
}
|
|
}
|
|
|
|
/// <summary>Searches the 8-neighborhood for a source texel (painted or already padded) and returns its color.</summary>
|
|
private bool TryFindPaddingSource(int x, int y, out Color32 col)
|
|
{
|
|
for (int oy = -1; oy <= 1; oy++)
|
|
{
|
|
int ny = y + oy;
|
|
if (ny < 0 || ny >= textureSize) continue;
|
|
int nrow = ny * textureSize;
|
|
for (int ox = -1; ox <= 1; ox++)
|
|
{
|
|
if (ox == 0 && oy == 0) continue;
|
|
int nx = x + ox;
|
|
if (nx < 0 || nx >= textureSize) continue;
|
|
int nidx = nrow + nx;
|
|
if (_painted[nidx] || _fillStamp[nidx] == _dilateStamp)
|
|
{
|
|
col = _pixels[nidx];
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
col = default;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Uploads only the region changed this frame to the GPU once (CPU backend).</summary>
|
|
private void FlushDirty()
|
|
{
|
|
if (_dx1 < _dx0 || _dy1 < _dy0) return; // nothing painted
|
|
|
|
int bw = _dx1 - _dx0 + 1;
|
|
int bh = _dy1 - _dy0 + 1;
|
|
var block = new Color32[bw * bh];
|
|
for (int y = 0; y < bh; y++)
|
|
System.Array.Copy(_pixels, (_dy0 + y) * textureSize + _dx0, block, y * bw, bw);
|
|
|
|
_paintTexture.SetPixels32(_dx0, _dy0, bw, bh, block);
|
|
_paintTexture.Apply(false);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Helpers
|
|
// ------------------------------------------------------------------
|
|
|
|
private static float Cross2D(Vector2 a, Vector2 b) => a.x * b.y - a.y * b.x;
|
|
|
|
private bool TryGetMouse(out bool pressed, out Vector2 screenPos)
|
|
{
|
|
pressed = false;
|
|
screenPos = Vector2.zero;
|
|
#if ENABLE_INPUT_SYSTEM
|
|
var mouse = Mouse.current;
|
|
if (mouse != null)
|
|
{
|
|
pressed = mouse.leftButton.isPressed;
|
|
screenPos = mouse.position.ReadValue();
|
|
return true;
|
|
}
|
|
#endif
|
|
#if ENABLE_LEGACY_INPUT_MANAGER
|
|
pressed = Input.GetMouseButton(0);
|
|
screenPos = Input.mousePosition;
|
|
return true;
|
|
#else
|
|
return false;
|
|
#endif
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
if (_bakedMesh != null) Destroy(_bakedMesh);
|
|
if (_materialInstance != null) Destroy(_materialInstance);
|
|
TeardownCpu();
|
|
TeardownGpu();
|
|
}
|
|
}
|