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:
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| <=
|
||||
/// 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² <= 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| <= 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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user