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