MeshPainter: Kugel-Brush statt Box, cornerGuard entfernt

Stamp testet den Brush wieder als 3D-Kugel (du²+dv²+dn² = r²) statt
als Box (|du|,|dv|,|dn| = r) — konsistent über CPU, GPU-Brush und
Preview. Die Box reichte diagonal bis r·√3 und spannte einen 2r-dicken
Slab entlang der Normale, wodurch an Nähten/Kanten/dünner Geometrie auf
die Gegenseite/Nachbarinseln gemalt wurde. Die Kugel reicht in jede
Richtung exakt r und überbrückt echte 3D-Nähte weiterhin, greift aber
nicht mehr über r hinaus.

cornerGuard war nur ein Pflaster gegen genau diesen Box-Übergriff und
ist damit überflüssig: Config-Felder, Suppress-Check und Helper
(DabWrapsAroundCorner, CornerWrapFraction, InBrushBox, SquareReach)
entfernt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-07-14 22:11:41 +02:00
1 parent 2195427e6c
commit 1f42c990fb
5 files changed
+27 -131

No files matched your search

@@ -122,10 +122,12 @@ public partial class MeshPainter
}
/// <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| &lt;=
/// radius) over each active map, weighted by the mask opacity. Mirrors the GPU brush
/// path (mask value = per-texel opacity, gray = 50%).
/// Paints a stamp in the surface tangent plane: rasterizes triangles in range and blends
/// every texel whose 3D position lies inside the brush SPHERE (du²+dv²+dn² &lt;= radius²) over
/// each active map, weighted by the mask opacity. The mask is still sampled along du/dv, so the
/// 2D mask shape is preserved - but the sphere reach (instead of a box) matches the original
/// isotropic brush, so a dab no longer bleeds across a UV seam / around a corner. 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)
{
@@ -164,13 +166,14 @@ public partial class MeshPainter
CpuMap furm = _cpu[MAP_FUR];
float invR = 1f / radiusLocal;
float r2 = radiusLocal * 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];
// Fast culling: brush sphere (reach = radius) vs. 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];
@@ -219,9 +222,9 @@ public partial class MeshPainter
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;
// Brush SPHERE (not box): reach is r in every direction, so the dab can't
// bleed across a seam / around a corner. Also guarantees |du|,|dv| <= r -> mu,mv in [0,1].
if (du * du + dv * dv + dn * dn > r2) continue;
float mu = du * invR * 0.5f + 0.5f;
float mv = dv * invR * 0.5f + 0.5f;
@@ -298,25 +298,6 @@ public partial class MeshPainter : MonoBehaviour
[Min(0f)]
public float paintInterval = 0f;
[Header("Brush - corner guard")]
[Tooltip("Suppresses a whole brush dab when too much of its footprint would wrap around a " +
"corner onto an adjacent, steeply angled surface (the classic 'painted around the " +
"corner' bleed, worst with large brushes). The dab is blocked entirely, not clipped: " +
"you simply cannot paint at a spot that would smear around the corner - aim cleaner or " +
"shrink the brush. OFF = paint anywhere (old behavior).")]
public bool cornerGuard = true;
[Tooltip("Surface under the brush whose normal deviates from the hit face by more than this " +
"angle counts as 'around the corner'. Lower = stricter (gentle bends already block); " +
"higher = only sharp corners count. 180 = effectively off.")]
[Range(0f, 180f)]
public float cornerGuardAngle = 50f;
[Tooltip("Block the dab once this share of the brush footprint is around the corner. " +
"0 = block at the slightest wrap, 1 = effectively off (only a fully wrapped dab blocks).")]
[Range(0f, 1f)]
public float cornerGuardMaxFraction = 0.25f;
[Header("Preview")]
[Tooltip("Brush preview at the hover point (shows size + mask without painting). " +
"Flat Decal = a flat quad in the tangent plane (cheap). On Surface = a real " +
@@ -450,7 +431,6 @@ public partial class MeshPainter : MonoBehaviour
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");
@@ -972,13 +952,7 @@ public partial class MeshPainter : MonoBehaviour
private void PaintAtScreen(Vector2 screenPos)
{
if (!TryComputeBrushPlacement(screenPos, out Vector3 pLocal, out float radiusLocal,
out Vector3 u, out Vector3 v, out Vector3 nLocal, out int hitTri))
return;
// Corner guard: if too much of the footprint would wrap around a corner, suppress the
// whole dab here - before drawing, emitting or marking the stroke painted, so nothing is
// clipped and remotes never replay a dab the owner refused to paint.
if (cornerGuard && DabWrapsAroundCorner(pLocal, radiusLocal, u, v, nLocal, hitTri))
out Vector3 u, out Vector3 v, out Vector3 nLocal, out int _))
return;
// Pick what this stamp paints with: a fresh random spray pattern, or the resolved
@@ -1034,85 +1008,6 @@ public partial class MeshPainter : MonoBehaviour
_paintParticles.Stop();
}
/// <summary>
/// True if a brush dab at this placement would wrap excessively around a corner: more than
/// <see cref="cornerGuardMaxFraction"/> of its footprint sits on surface whose face normal
/// deviates from the hit face by more than <see cref="cornerGuardAngle"/>. Used to suppress
/// the whole dab (not clip it). Geometry-only, so it behaves identically on the GPU and CPU
/// backends and needs no texture readback.
/// </summary>
private bool DabWrapsAroundCorner(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n, int hitTri)
{
if (_verts == null || _tris == null || _triCenter == null || _triRadius == null) return false;
if (hitTri < 0 || hitTri * 3 + 2 >= _tris.Length) return false;
// Reference normal = the hit triangle's geometric face normal, built with the SAME
// cross-product convention as every candidate below, so mesh winding cancels in the
// dot comparison (we only judge relative deviation from the surface we actually hit).
int h0 = _tris[hitTri * 3], h1 = _tris[hitTri * 3 + 1], h2 = _tris[hitTri * 3 + 2];
Vector3 fnHit = Vector3.Cross(_verts[h1] - _verts[h0], _verts[h2] - _verts[h0]);
if (fnHit.sqrMagnitude < 1e-12f) return false; // degenerate hit triangle -> allow
fnHit.Normalize();
float cosThresh = Mathf.Cos(Mathf.Clamp(cornerGuardAngle, 0f, 180f) * Mathf.Deg2Rad);
float frac = CornerWrapFraction(_verts, _tris, _triCenter, _triRadius,
pLocal, radiusLocal, u, v, n, fnHit, cosThresh);
return frac > cornerGuardMaxFraction;
}
/// <summary>
/// Estimates the share (0..1) of the brush footprint sitting on surface whose geometric face
/// normal deviates from <paramref name="fnHit"/> by more than the threshold (given as its
/// cosine). Per in-range triangle (same circumscribed-circle cull as the painter), the area is
/// weighted by how many of {v0,v1,v2,centroid} fall inside the brush box - a cheap, backend-
/// agnostic footprint estimate (no GPU readback). Winding cancels because both normals use the
/// same cross convention. Returns 0 when nothing is in range (caller then allows the dab).
/// Static + array-parameterized so the geometry math is unit-testable without a live component.
/// </summary>
private static float CornerWrapFraction(
Vector3[] verts, int[] tris, Vector3[] triCenter, float[] triRadius,
Vector3 center, float r, Vector3 u, Vector3 v, Vector3 n, Vector3 fnHit, float cosThresh)
{
double totalW = 0.0, cornerW = 0.0;
int triCount = tris.Length / 3;
for (int t = 0; t < triCount; t++)
{
float reach = r * SquareReach + triRadius[t];
if ((triCenter[t] - center).sqrMagnitude > reach * reach) continue;
int i0 = tris[t * 3], i1 = tris[t * 3 + 1], i2 = tris[t * 3 + 2];
Vector3 a = verts[i0], b = verts[i1], c = verts[i2];
Vector3 ctr = (a + b + c) * (1f / 3f);
int inside = (InBrushBox(a, center, u, v, n, r) ? 1 : 0)
+ (InBrushBox(b, center, u, v, n, r) ? 1 : 0)
+ (InBrushBox(c, center, u, v, n, r) ? 1 : 0)
+ (InBrushBox(ctr, center, u, v, n, r) ? 1 : 0);
if (inside == 0) continue;
Vector3 cross = Vector3.Cross(b - a, c - a);
float area2 = cross.magnitude; // 2 x triangle area (relative weight is enough)
if (area2 < 1e-12f) continue;
double w = inside * 0.25 * area2;
totalW += w;
float aligned = Vector3.Dot(cross / area2, fnHit); // -1..1, winding-cancelled
if (aligned < cosThresh) cornerW += w; // deviates past the threshold angle
}
return totalW > 1e-9 ? (float)(cornerW / totalW) : 0f;
}
/// <summary>True if a local-space point lies inside the brush box (same |du|,|dv|,|dn| &lt;= r bound the stamp paints).</summary>
private static bool InBrushBox(Vector3 p, Vector3 center, Vector3 u, Vector3 v, Vector3 n, float r)
{
Vector3 d = p - center;
return Mathf.Abs(Vector3.Dot(d, u)) <= r
&& Mathf.Abs(Vector3.Dot(d, v)) <= r
&& Mathf.Abs(Vector3.Dot(d, n)) <= r;
}
/// <summary>
/// Raycasts at a screen position and returns the nearest hit that belongs to THIS paint
/// mesh. A plain raycast would return only the closest collider, so anything in front of
@@ -1178,7 +1073,8 @@ public partial class MeshPainter : MonoBehaviour
// Brush radius (texels) -> constant local 3D radius via the MESH-WIDE average UV density -
// deliberately not the hit triangle's own (that varied up to ~7.6x at seams/stretched islands,
// painting visibly different sizes). The stamp stays a 3D box, so seams are bridged as before.
// painting visibly different sizes). The stamp is a 3D sphere, so seams are bridged (both sides
// of a shared edge) without bleeding past r onto adjacent surfaces.
radiusLocal = (brushSize / (float)textureSize) * _avgUvToLocal;
if (radiusLocal <= 0f) return false;